diff --git a/.eslintrc.js b/.eslintrc.js index 865bcc008afbc..b70090a50e64d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -829,48 +829,12 @@ module.exports = { // typescript only for front and back end files: ['x-pack/plugins/security_solution/**/*.{ts,tsx}'], rules: { - // This will be turned on after bug fixes are complete - // '@typescript-eslint/explicit-member-accessibility': 'warn', '@typescript-eslint/no-this-alias': 'error', '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-useless-constructor': 'error', - // This will be turned on after bug fixes are complete - // '@typescript-eslint/no-object-literal-type-assertion': 'warn', '@typescript-eslint/unified-signatures': 'error', - - // eventually we want this to be a warn and then an error since this is a recommended linter rule - // for now, keeping it commented out to avoid too much IDE noise until the other linter issues - // are fixed in the next release or two - // '@typescript-eslint/explicit-function-return-type': 'warn', - - // these rules cannot be turned on and tested at the moment until this issue is resolved: - // https://github.com/prettier/prettier-eslint/issues/201 - // '@typescript-eslint/await-thenable': 'error', - // '@typescript-eslint/no-non-null-assertion': 'error' - // '@typescript-eslint/no-unnecessary-type-assertion': 'error', - // '@typescript-eslint/no-unused-vars': 'error', - // '@typescript-eslint/prefer-includes': 'error', - // '@typescript-eslint/prefer-string-starts-ends-with': 'error', - // '@typescript-eslint/promise-function-async': 'error', - // '@typescript-eslint/prefer-regexp-exec': 'error', - // '@typescript-eslint/promise-function-async': 'error', - // '@typescript-eslint/require-array-sort-compare': 'error', - // '@typescript-eslint/restrict-plus-operands': 'error', - // '@typescript-eslint/unbound-method': 'error', }, }, - // { - // // will introduced after the other warns are fixed - // // typescript and javascript for front end react performance - // files: ['x-pack/plugins/security_solution/public/**/!(*.test).{js,mjs,ts,tsx}'], - // plugins: ['react-perf'], - // rules: { - // // 'react-perf/jsx-no-new-object-as-prop': 'error', - // // 'react-perf/jsx-no-new-array-as-prop': 'error', - // // 'react-perf/jsx-no-new-function-as-prop': 'error', - // // 'react/jsx-no-bind': 'error', - // }, - // }, { // typescript and javascript for front and back end files: ['x-pack/plugins/security_solution/**/*.{js,mjs,ts,tsx}'], @@ -883,21 +847,6 @@ module.exports = { 'array-callback-return': 'error', 'no-array-constructor': 'error', complexity: 'warn', - // This will be turned on after bug fixes are mostly completed - // 'consistent-return': 'warn', - // This will be turned on after bug fixes are mostly completed - // 'func-style': ['warn', 'expression'], - // These will be turned on after bug fixes are mostly completed and we can - // run a fix-lint - /* - 'import/order': [ - 'warn', - { - groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], - 'newlines-between': 'always', - }, - ], - */ 'node/no-deprecated-api': 'error', 'no-bitwise': 'error', 'no-continue': 'error', @@ -937,12 +886,8 @@ module.exports = { 'no-useless-catch': 'error', 'no-useless-concat': 'error', 'no-useless-computed-key': 'error', - // This will be turned on after bug fixes are mostly complete - // 'no-useless-escape': 'warn', 'no-useless-rename': 'error', 'no-useless-return': 'error', - // This will be turned on after bug fixers are mostly complete - // 'no-void': 'warn', 'one-var-declaration-per-line': 'error', 'prefer-object-spread': 'error', 'prefer-promise-reject-errors': 'error', @@ -958,9 +903,6 @@ module.exports = { 'react/no-danger-with-children': 'error', 'react/no-deprecated': 'error', 'react/no-did-mount-set-state': 'error', - // Re-enable once we have better options per this issue: - // https://github.com/airbnb/javascript/issues/1875 - // 'react/no-did-update-set-state': 'error', 'react/no-direct-mutation-state': 'error', 'react/no-find-dom-node': 'error', 'react/no-redundant-should-component-update': 'error', @@ -972,8 +914,6 @@ module.exports = { 'react/no-unsafe': 'error', 'react/no-unused-prop-types': 'error', 'react/no-unused-state': 'error', - // will introduced after the other warns are fixed - // 'react/sort-comp': 'error', 'react/void-dom-elements-no-children': 'error', 'react/jsx-no-comment-textnodes': 'error', 'react/jsx-no-literals': 'error', @@ -1007,7 +947,62 @@ module.exports = { }, }, { - // typescript and javascript for front and back end + // typescript for /public and /common + files: ['x-pack/plugins/lists/public/*.{ts,tsx}', 'x-pack/plugins/lists/common/*.{ts,tsx}'], + rules: { + '@typescript-eslint/no-for-in-array': 'error', + }, + }, + { + // typescript for /public and /common + files: ['x-pack/plugins/lists/public/*.{ts,tsx}', 'x-pack/plugins/lists/common/*.{ts,tsx}'], + plugins: ['react'], + env: { + jest: true, + }, + rules: { + 'react/boolean-prop-naming': 'error', + 'react/button-has-type': 'error', + 'react/display-name': 'error', + 'react/forbid-dom-props': 'error', + 'react/no-access-state-in-setstate': 'error', + 'react/no-children-prop': 'error', + 'react/no-danger-with-children': 'error', + 'react/no-deprecated': 'error', + 'react/no-did-mount-set-state': 'error', + 'react/no-did-update-set-state': 'error', + 'react/no-direct-mutation-state': 'error', + 'react/no-find-dom-node': 'error', + 'react/no-redundant-should-component-update': 'error', + 'react/no-render-return-value': 'error', + 'react/no-typos': 'error', + 'react/no-string-refs': 'error', + 'react/no-this-in-sfc': 'error', + 'react/no-unescaped-entities': 'error', + 'react/no-unsafe': 'error', + 'react/no-unused-prop-types': 'error', + 'react/no-unused-state': 'error', + 'react/sort-comp': 'error', + 'react/void-dom-elements-no-children': 'error', + 'react/jsx-no-comment-textnodes': 'error', + 'react/jsx-no-literals': 'error', + 'react/jsx-no-target-blank': 'error', + 'react/jsx-fragments': 'error', + 'react/jsx-sort-default-props': 'error', + }, + }, + { + files: ['x-pack/plugins/lists/public/**/!(*.test).{js,mjs,ts,tsx}'], + plugins: ['react-perf'], + rules: { + 'react-perf/jsx-no-new-object-as-prop': 'error', + 'react-perf/jsx-no-new-array-as-prop': 'error', + 'react-perf/jsx-no-new-function-as-prop': 'error', + 'react/jsx-no-bind': 'error', + }, + }, + { + // typescript and javascript for front and back files: ['x-pack/plugins/lists/**/*.{js,mjs,ts,tsx}'], plugins: ['eslint-plugin-node'], env: { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7995d5d5bba25..508cd8f9e8007 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -145,7 +145,6 @@ # Operations /src/dev/ @elastic/kibana-operations /src/setup_node_env/ @elastic/kibana-operations -/src/optimize/ @elastic/kibana-operations /packages/*eslint*/ @elastic/kibana-operations /packages/*babel*/ @elastic/kibana-operations /packages/kbn-dev-utils*/ @elastic/kibana-operations @@ -232,8 +231,8 @@ /x-pack/plugins/telemetry_collection_xpack/ @elastic/kibana-core /.telemetryrc.json @elastic/kibana-core /x-pack/.telemetryrc.json @elastic/kibana-core -src/plugins/telemetry/schema/ @elastic/kibana-core @elastic/kibana-telemetry @elastic/infra-telemetry -x-pack/plugins/telemetry_collection_xpack/schema/ @elastic/kibana-core @elastic/kibana-telemetry @elastic/infra-telemetry +/src/plugins/telemetry/schema/ @elastic/kibana-core @elastic/kibana-telemetry +/x-pack/plugins/telemetry_collection_xpack/schema/ @elastic/kibana-core @elastic/kibana-telemetry # Kibana Localization /src/dev/i18n/ @elastic/kibana-localization @elastic/kibana-core diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 79571d51659d6..9445d02265725 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -31,7 +31,9 @@ jobs: - name: Run Backport uses: ./actions/backport with: - branch: master github_token: ${{secrets.KIBANAMACHINE_TOKEN}} commit_user: kibanamachine commit_email: 42973632+kibanamachine@users.noreply.github.com + auto_merge: 'true' + auto_merge_method: 'squash' + manual_backport_command_template: 'node scripts/backport --pr %pullNumber%' diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index 7c5b59aa15b16..9f0e6e0231feb 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 = "bfacf15161d96a6a39510e7b3d3b522cf61cb8b82a31e79400a84c5abcab5347", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.1/rules_nodejs-3.2.1.tar.gz"], + sha256 = "55a25a762fcf9c9b88ab54436581e671bc9f4f523cb5a1bd32459ebec7be68a8", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.2/rules_nodejs-3.2.2.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.1") +check_rules_nodejs_version(minimum_version_string = "3.2.2") # 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 181ed29399291..f063a2271aec7 100644 --- a/api_docs/charts.json +++ b/api_docs/charts.json @@ -9,7 +9,7 @@ "children": [ { "type": "Object", - "label": "{ onChange, color: selectedColor, id, label }", + "label": "{\n onChange,\n color: selectedColor,\n label,\n useLegacyColors = true,\n colorIsOverwritten = true,\n}", "isRequired": true, "signature": [ "ColorPickerProps" @@ -17,18 +17,18 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/components/color_picker.tsx", - "lineNumber": 83 + "lineNumber": 108 } } ], "signature": [ - "({ onChange, color: selectedColor, id, label }: ColorPickerProps) => JSX.Element" + "({ onChange, color: selectedColor, label, useLegacyColors, colorIsOverwritten, }: ColorPickerProps) => JSX.Element" ], "description": [], "label": "ColorPicker", "source": { "path": "src/plugins/charts/public/static/components/color_picker.tsx", - "lineNumber": 83 + "lineNumber": 108 }, "tags": [], "returnComment": [], diff --git a/api_docs/data.json b/api_docs/data.json index 7989768e180ce..13e2b402a4afd 100644 --- a/api_docs/data.json +++ b/api_docs/data.json @@ -21764,7 +21764,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 245 + "lineNumber": 246 }, "signature": [ "typeof ", @@ -21785,7 +21785,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 246 + "lineNumber": 247 }, "signature": [ "typeof ", @@ -21806,7 +21806,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 247 + "lineNumber": 248 }, "signature": [ "({ display: string; val: string; enabled(agg: ", @@ -21828,7 +21828,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 248 + "lineNumber": 249 }, "signature": [ "typeof ", @@ -21849,7 +21849,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 249 + "lineNumber": 250 }, "signature": [ "typeof ", @@ -21870,7 +21870,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 250 + "lineNumber": 251 }, "signature": [ "typeof ", @@ -21891,7 +21891,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 251 + "lineNumber": 252 }, "signature": [ "(agg: ", @@ -21913,7 +21913,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 252 + "lineNumber": 253 }, "signature": [ "(agg: ", @@ -21935,7 +21935,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 253 + "lineNumber": 254 }, "signature": [ "(...types: string[]) => (agg: ", @@ -21957,7 +21957,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 254 + "lineNumber": 255 }, "signature": [ "typeof ", @@ -21978,7 +21978,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 255 + "lineNumber": 256 }, "signature": [ "typeof ", @@ -21999,7 +21999,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 256 + "lineNumber": 257 } }, { @@ -22010,7 +22010,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 257 + "lineNumber": 258 }, "signature": [ "typeof ", @@ -22031,7 +22031,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 258 + "lineNumber": 259 }, "signature": [ "typeof ", @@ -22052,7 +22052,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 259 + "lineNumber": 260 }, "signature": [ "typeof ", @@ -22073,7 +22073,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 260 + "lineNumber": 261 } }, { @@ -22084,7 +22084,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 261 + "lineNumber": 262 }, "signature": [ "string[]" @@ -22098,7 +22098,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 262 + "lineNumber": 263 }, "signature": [ "typeof ", @@ -22119,7 +22119,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 263 + "lineNumber": 264 }, "signature": [ "typeof ", @@ -22137,7 +22137,7 @@ "label": "aggs", "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 244 + "lineNumber": 245 } }, { @@ -22148,7 +22148,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 265 + "lineNumber": 266 }, "signature": [ "typeof ", @@ -22169,7 +22169,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 266 + "lineNumber": 267 }, "signature": [ "typeof ", @@ -22190,7 +22190,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 267 + "lineNumber": 268 }, "signature": [ "typeof ", @@ -22211,7 +22211,7 @@ "description": [], "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 268 + "lineNumber": 269 }, "signature": [ "typeof ", @@ -22229,7 +22229,7 @@ "label": "search", "source": { "path": "src/plugins/data/server/index.ts", - "lineNumber": 243 + "lineNumber": 244 }, "initialIsOpen": false }, @@ -27594,4 +27594,4 @@ } ] } -} \ No newline at end of file +} diff --git a/api_docs/data_search.json b/api_docs/data_search.json index 0bdfcadd338ea..d0eb07083c2f6 100644 --- a/api_docs/data_search.json +++ b/api_docs/data_search.json @@ -1573,6 +1573,183 @@ } ], "interfaces": [ + { + "id": "def-server.IScopedSearchClient", + "type": "Interface", + "label": "IScopedSearchClient", + "signature": [ + { + "pluginId": "data", + "scope": "server", + "docId": "kibDataSearchPluginApi", + "section": "def-server.IScopedSearchClient", + "text": "IScopedSearchClient" + }, + " extends ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.ISearchClient", + "text": "ISearchClient" + } + ], + "description": [], + "tags": [], + "children": [ + { + "tags": [], + "id": "def-server.IScopedSearchClient.saveSession", + "type": "Function", + "label": "saveSession", + "description": [], + "source": { + "path": "src/plugins/data/server/search/types.ts", + "lineNumber": 90 + }, + "signature": [ + "(sessionId: string, attributes: Partial) => Promise<", + { + "pluginId": "core", + "scope": "common", + "docId": "kibCorePluginApi", + "section": "def-common.SavedObject", + "text": "SavedObject" + }, + " | undefined>" + ] + }, + { + "tags": [], + "id": "def-server.IScopedSearchClient.getSession", + "type": "Function", + "label": "getSession", + "description": [], + "source": { + "path": "src/plugins/data/server/search/types.ts", + "lineNumber": 91 + }, + "signature": [ + "(sessionId: string) => Promise<", + { + "pluginId": "core", + "scope": "common", + "docId": "kibCorePluginApi", + "section": "def-common.SavedObject", + "text": "SavedObject" + }, + ">" + ] + }, + { + "tags": [], + "id": "def-server.IScopedSearchClient.findSessions", + "type": "Function", + "label": "findSessions", + "description": [], + "source": { + "path": "src/plugins/data/server/search/types.ts", + "lineNumber": 92 + }, + "signature": [ + "(options: Pick<", + { + "pluginId": "core", + "scope": "server", + "docId": "kibCoreSavedObjectsPluginApi", + "section": "def-server.SavedObjectsFindOptions", + "text": "SavedObjectsFindOptions" + }, + ", \"filter\" | \"fields\" | \"searchAfter\" | \"search\" | \"page\" | \"perPage\" | \"sortField\" | \"sortOrder\" | \"searchFields\" | \"rootSearchFields\" | \"hasReference\" | \"hasReferenceOperator\" | \"defaultSearchOperator\" | \"namespaces\" | \"typeToNamespacesMap\" | \"preference\" | \"pit\">) => Promise<", + { + "pluginId": "core", + "scope": "server", + "docId": "kibCoreSavedObjectsPluginApi", + "section": "def-server.SavedObjectsFindResponse", + "text": "SavedObjectsFindResponse" + }, + ">" + ] + }, + { + "tags": [], + "id": "def-server.IScopedSearchClient.updateSession", + "type": "Function", + "label": "updateSession", + "description": [], + "source": { + "path": "src/plugins/data/server/search/types.ts", + "lineNumber": 93 + }, + "signature": [ + "(sessionId: string, attributes: Partial) => Promise<", + { + "pluginId": "core", + "scope": "server", + "docId": "kibCoreSavedObjectsPluginApi", + "section": "def-server.SavedObjectsUpdateResponse", + "text": "SavedObjectsUpdateResponse" + }, + ">" + ] + }, + { + "tags": [], + "id": "def-server.IScopedSearchClient.cancelSession", + "type": "Function", + "label": "cancelSession", + "description": [], + "source": { + "path": "src/plugins/data/server/search/types.ts", + "lineNumber": 94 + }, + "signature": [ + "(sessionId: string) => Promise<{}>" + ] + }, + { + "tags": [], + "id": "def-server.IScopedSearchClient.deleteSession", + "type": "Function", + "label": "deleteSession", + "description": [], + "source": { + "path": "src/plugins/data/server/search/types.ts", + "lineNumber": 95 + }, + "signature": [ + "(sessionId: string) => Promise<{}>" + ] + }, + { + "tags": [], + "id": "def-server.IScopedSearchClient.extendSession", + "type": "Function", + "label": "extendSession", + "description": [], + "source": { + "path": "src/plugins/data/server/search/types.ts", + "lineNumber": 96 + }, + "signature": [ + "(sessionId: string, expires: Date) => Promise<", + { + "pluginId": "core", + "scope": "server", + "docId": "kibCoreSavedObjectsPluginApi", + "section": "def-server.SavedObjectsUpdateResponse", + "text": "SavedObjectsUpdateResponse" + }, + ">" + ] + } + ], + "source": { + "path": "src/plugins/data/server/search/types.ts", + "lineNumber": 89 + }, + "initialIsOpen": false + }, { "id": "def-server.ISearchSessionService", "type": "Interface", @@ -19293,4 +19470,4 @@ } ] } -} \ No newline at end of file +} diff --git a/api_docs/discover.json b/api_docs/discover.json index 69e1d0366a712..267669692051f 100644 --- a/api_docs/discover.json +++ b/api_docs/discover.json @@ -40,6 +40,25 @@ "lineNumber": 18 }, "initialIsOpen": false + }, + { + "id": "def-public.loadSharingDataHelpers", + "type": "Function", + "label": "loadSharingDataHelpers", + "signature": [ + "() => Promise" + ], + "description": [], + "children": [], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/discover/public/shared/index.ts", + "lineNumber": 12 + }, + "initialIsOpen": false } ], "interfaces": [ @@ -892,21 +911,6 @@ "interfaces": [], "enums": [], "misc": [ - { - "tags": [], - "id": "def-common.AGGS_TERMS_SIZE_SETTING", - "type": "string", - "label": "AGGS_TERMS_SIZE_SETTING", - "description": [], - "source": { - "path": "src/plugins/discover/common/index.ts", - "lineNumber": 11 - }, - "signature": [ - "\"discover:aggs:terms:size\"" - ], - "initialIsOpen": false - }, { "tags": [], "id": "def-common.CONTEXT_DEFAULT_SIZE_SETTING", @@ -915,7 +919,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 16 + "lineNumber": 15 }, "signature": [ "\"context:defaultSize\"" @@ -930,7 +934,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 17 + "lineNumber": 16 }, "signature": [ "\"context:step\"" @@ -945,7 +949,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 18 + "lineNumber": 17 }, "signature": [ "\"context:tieBreakerFields\"" @@ -975,7 +979,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 14 + "lineNumber": 13 }, "signature": [ "\"doc_table:hideTimeColumn\"" @@ -990,7 +994,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 19 + "lineNumber": 18 }, "signature": [ "\"doc_table:legacy\"" @@ -1005,7 +1009,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 15 + "lineNumber": 14 }, "signature": [ "\"fields:popularLimit\"" @@ -1020,7 +1024,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 20 + "lineNumber": 19 }, "signature": [ "\"discover:modifyColumnsOnSwitch\"" @@ -1050,7 +1054,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 21 + "lineNumber": 20 }, "signature": [ "\"discover:searchFieldsFromSource\"" @@ -1065,7 +1069,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 13 + "lineNumber": 12 }, "signature": [ "\"discover:searchOnPageLoad\"" @@ -1080,7 +1084,7 @@ "description": [], "source": { "path": "src/plugins/discover/common/index.ts", - "lineNumber": 12 + "lineNumber": 11 }, "signature": [ "\"discover:sort:defaultOrder\"" diff --git a/api_docs/expressions.json b/api_docs/expressions.json index ff04fcd03f046..ee496cc7c06a3 100644 --- a/api_docs/expressions.json +++ b/api_docs/expressions.json @@ -33883,4 +33883,4 @@ } ] } -} \ No newline at end of file +} diff --git a/api_docs/reporting.json b/api_docs/reporting.json index e07e3493a9d85..44050591f71cb 100644 --- a/api_docs/reporting.json +++ b/api_docs/reporting.json @@ -851,7 +851,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 65 + "lineNumber": 69 } }, { @@ -873,7 +873,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 65 + "lineNumber": 69 } } ], @@ -881,7 +881,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 65 + "lineNumber": 69 } }, { @@ -917,7 +917,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 75 + "lineNumber": 79 } } ], @@ -925,7 +925,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 75 + "lineNumber": 79 } }, { @@ -961,7 +961,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 89 + "lineNumber": 93 } } ], @@ -969,7 +969,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 89 + "lineNumber": 93 } }, { @@ -985,7 +985,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 102 + "lineNumber": 106 } }, { @@ -1001,7 +1001,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 113 + "lineNumber": 117 } }, { @@ -1017,7 +1017,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 120 + "lineNumber": 124 } }, { @@ -1053,7 +1053,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 127 + "lineNumber": 131 } } ], @@ -1061,7 +1061,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 127 + "lineNumber": 131 } }, { @@ -1079,7 +1079,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 135 + "lineNumber": 139 } }, { @@ -1102,7 +1102,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 155 + "lineNumber": 159 } }, { @@ -1126,7 +1126,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 165 + "lineNumber": 169 } }, { @@ -1149,7 +1149,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 173 + "lineNumber": 177 } }, { @@ -1210,7 +1210,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 177 + "lineNumber": 181 } } ], @@ -1218,7 +1218,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 177 + "lineNumber": 181 } }, { @@ -1242,7 +1242,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 181 + "lineNumber": 185 } }, { @@ -1266,7 +1266,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 185 + "lineNumber": 189 } }, { @@ -1290,7 +1290,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 195 + "lineNumber": 199 } }, { @@ -1313,7 +1313,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 204 + "lineNumber": 208 } }, { @@ -1336,7 +1336,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 211 + "lineNumber": 216 } }, { @@ -1382,7 +1382,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 220 + "lineNumber": 225 } } ], @@ -1390,7 +1390,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 220 + "lineNumber": 225 } }, { @@ -1435,7 +1435,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 226 + "lineNumber": 231 } }, { @@ -1454,7 +1454,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 226 + "lineNumber": 231 } } ], @@ -1462,7 +1462,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 226 + "lineNumber": 231 } }, { @@ -1500,7 +1500,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 240 + "lineNumber": 245 } }, { @@ -1513,7 +1513,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 240 + "lineNumber": 245 } }, { @@ -1532,7 +1532,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 240 + "lineNumber": 245 } } ], @@ -1540,7 +1540,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 240 + "lineNumber": 245 } }, { @@ -1593,7 +1593,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 260 + "lineNumber": 265 } }, { @@ -1612,7 +1612,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 260 + "lineNumber": 265 } } ], @@ -1620,7 +1620,55 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 260 + "lineNumber": 265 + } + }, + { + "id": "def-server.ReportingCore.getDataService", + "type": "Function", + "label": "getDataService", + "signature": [ + "() => Promise<", + { + "pluginId": "data", + "scope": "server", + "docId": "kibDataPluginApi", + "section": "def-server.DataPluginStart", + "text": "DataPluginStart" + }, + ">" + ], + "description": [], + "children": [], + "tags": [], + "returnComment": [], + "source": { + "path": "x-pack/plugins/reporting/server/core.ts", + "lineNumber": 275 + } + }, + { + "id": "def-server.ReportingCore.getEsClient", + "type": "Function", + "label": "getEsClient", + "signature": [ + "() => Promise<", + { + "pluginId": "core", + "scope": "server", + "docId": "kibCorePluginApi", + "section": "def-server.IClusterClient", + "text": "IClusterClient" + }, + ">" + ], + "description": [], + "children": [], + "tags": [], + "returnComment": [], + "source": { + "path": "x-pack/plugins/reporting/server/core.ts", + "lineNumber": 280 } }, { @@ -1642,7 +1690,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 270 + "lineNumber": 285 } } ], @@ -1650,7 +1698,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 270 + "lineNumber": 285 } }, { @@ -1672,7 +1720,7 @@ "description": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 274 + "lineNumber": 289 } } ], @@ -1680,7 +1728,7 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 274 + "lineNumber": 289 } }, { @@ -1696,13 +1744,13 @@ "returnComment": [], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 278 + "lineNumber": 293 } } ], "source": { "path": "x-pack/plugins/reporting/server/core.ts", - "lineNumber": 54 + "lineNumber": 58 }, "initialIsOpen": false }, diff --git a/docs/apm/correlations.asciidoc b/docs/apm/correlations.asciidoc new file mode 100644 index 0000000000000..e184ca6bfa656 --- /dev/null +++ b/docs/apm/correlations.asciidoc @@ -0,0 +1,123 @@ +[role="xpack"] +[[correlations]] +=== Find latency and error correlations + +**Correlations** surface attributes of your data that are potentially correlated with high-latency or erroneous transactions. +By default, a number of attributes commonly known to cause performance issues, like version, +infrastructure, and location, are included, but all are completely customizable to your APM data. +Find something interesting? A quick click of a button will auto-query your data as you work to resolve the underlying issue. + +For example, a site reliability engineer, who is responsible for keeping production systems up and running, +notices an increase in latency in certain transactions. +Analyzing metadata or tags that exist in high-latency transactions but not in lower-latency transactions +can potentially point towards the root cause. +They may find that a particular piece of hardware, like a host or pod, has failed, increasing latency. +Or, perhaps set of users, based on IP address or region, is facing increased latency due to local data center issues. + +[discrete] +[[view-correlations]] +=== View correlations + +With a service selected, click **View correlations**: + +[role="screenshot"] +image::apm/images/correlations.png[Correlations] + +Queries within the APM app apply to the correlations shown in the correlations fly-out. + +If a correlated field seems noteworthy, use the **Filter** quick links: + +* `+` creates a new query in the APM app for filtering transactions containing the selected value. +* `-` creates a new query in the APM app to filter out transactions containing the selected value. + +[discrete] +[[correlations-latency]] +==== Find high-latency correlations + +Correlations help you discover which fields are contributing to increased service latency. + +A latency distribution chart visualizes the overall latency of the selected service's transactions. +Correlated attributes are sorted by _Impact_–a visual representation of the +{ref}/search-aggregations-bucket-significantterms-aggregation.html[significant terms aggregation] +score that powers correlations. +Attributes with a high impact, or attributes present in a large percentage of slow transactions, +may contribute to increased latency. + +To find high-latency correlations, hover over each potentially correlated attribute to +compare the latency distribution of transactions with and without the selected attribute. + +For example, in the screenshot below, the field `user_agent.name` and value `HeadlessChrome` +exists primarily in higher-latency transactions between 3.7 and 8.7 seconds. + +[role="screenshot"] +image::apm/images/correlations-hover.png[Correlations hover effect] + +Select the `+` filter to create a new query in the APM app for transactions with +`user_agent.name: HeadlessChrome`. With the "noise" now filtered out, +you can begin viewing sample traces to continue your investigation. + +As you sift through high-latency transactions, you'll likely notice other interesting attributes. +Return to the correlations fly-out and select *Customize fields* to search on these new attributes. +You may need to do this a few times–each time filtering out more and more noise and bringing you +closer to a diagnosis. + +[discrete] +[[correlations-error-rate]] +==== Find error rate correlations + +Correlations help you discover which fields are contributing to failed transactions. + +The Error rate over time chart visualizes the change in error rate over the selected time frame. +Correlated attributes are sorted by _Impact_–a visual representation of the +{ref}/search-aggregations-bucket-significantterms-aggregation.html[significant terms aggregation] +score that powers correlations. +Attributes with a high impact, or attributes present in a large percentage of failed transactions, +may contribute to increased error rates. + +To find error rate correlations, hover over each potentially correlated attribute to +compare the error rate distribution of transactions with and without the selected attribute. + +For example, in the screenshot below, the field `url.original` and value `http://localhost:3100...` +existed in 100% of failed transactions between 6:00 and 10:30. + +[role="screenshot"] +image::apm/images/error-rate-hover.png[Correlations errors hover effect] + +Select the `+` filter to create a new query in the APM app for transactions with +`url.original: http://localhost:3100...`. With the "noise" now filtered out, +you can begin viewing sample traces to continue your investigation. + +As you sift through erroneous transactions, you'll likely notice other interesting attributes. +Return to the correlations fly-out and select *Customize fields* to search on these new attributes. +You may need to do this a few times–each time filtering out more and more noise and bringing you +closer to a diagnosis. + +[discrete] +[[correlations-customize-fields]] +==== Customize fields + +Correlations are only as good as the data they're searching for. +By default, a handful of attributes commonly known to cause performance issues are included. +During the course of an investigation however, you may to need to add and remove fields from +this list multiple times as you narrow in on a diagnosis. + +Add and remove fields under the **Customize fields** dropdown. +The following fields are selected by default. +To keep the default list manageable, only the first six matching fields with wildcards are used. + +**Frontend (RUM) agent:** + +* `labels.*` +* `user.*` +* `user_agent.name` +* `user_agent.os.name` +* `url.original` + +**Backend agents:** + +* `labels.*` +* `host.ip` +* `service.node.name` +* `service.version` + +TIP: Want to start over? Select **reset** to clear your customizations. diff --git a/docs/apm/how-to-guides.asciidoc b/docs/apm/how-to-guides.asciidoc index 9a415375f17fd..b4e49a69d5a7e 100644 --- a/docs/apm/how-to-guides.asciidoc +++ b/docs/apm/how-to-guides.asciidoc @@ -9,6 +9,7 @@ Learn how to perform common APM app tasks. * <> * <> * <> +* <> * <> * <> * <> @@ -22,6 +23,8 @@ include::custom-links.asciidoc[] include::filters.asciidoc[] +include::correlations.asciidoc[] + include::machine-learning.asciidoc[] include::advanced-queries.asciidoc[] diff --git a/docs/apm/images/correlations-hover.png b/docs/apm/images/correlations-hover.png new file mode 100644 index 0000000000000..b903a8cdf8de6 Binary files /dev/null and b/docs/apm/images/correlations-hover.png differ diff --git a/docs/apm/images/correlations.png b/docs/apm/images/correlations.png new file mode 100644 index 0000000000000..e35e800cb9e01 Binary files /dev/null and b/docs/apm/images/correlations.png differ diff --git a/docs/apm/images/error-rate-hover.png b/docs/apm/images/error-rate-hover.png new file mode 100644 index 0000000000000..69f0009309318 Binary files /dev/null and b/docs/apm/images/error-rate-hover.png differ diff --git a/docs/apm/troubleshooting.asciidoc b/docs/apm/troubleshooting.asciidoc index 5049321363f88..8cab7bb03da75 100644 --- a/docs/apm/troubleshooting.asciidoc +++ b/docs/apm/troubleshooting.asciidoc @@ -20,7 +20,7 @@ don't forget to check our other troubleshooting guides or discussion forum: * {apm-php-ref}/troubleshooting.html[PHP agent troubleshooting] * {apm-py-ref}/troubleshooting.html[Python agent troubleshooting] * {apm-ruby-ref}/debugging.html[Ruby agent troubleshooting] -* {apm-rum-ref/troubleshooting.html[RUM troubleshooting] +* {apm-rum-ref}/troubleshooting.html[RUM troubleshooting] * https://discuss.elastic.co/c/apm[APM discussion forum]. [discrete] diff --git a/docs/canvas/canvas-edit-workpads.asciidoc b/docs/canvas/canvas-edit-workpads.asciidoc index 6ad2d89be4a42..9f2808c9ad451 100644 --- a/docs/canvas/canvas-edit-workpads.asciidoc +++ b/docs/canvas/canvas-edit-workpads.asciidoc @@ -22,13 +22,13 @@ each element instead of updating them manually. For example, to change the index pattern for a set of charts: -Specify the variable options. - +. Specify the variable options. ++ [role="screenshot"] image::images/specify_variable_syntax.png[Image describing how to specify the variable syntax] - -Copy the variable, then apply it to each element you want to update in the *Expression editor*. - ++ +. Copy the variable, then apply it to each element you want to update in the *Expression editor*. ++ [role="screenshot"] image::images/copy_variable_syntax.png[Image demonstrating expression editor] diff --git a/docs/canvas/canvas-present-workpad.asciidoc b/docs/canvas/canvas-present-workpad.asciidoc index b1492f57e46f8..438e09b701fa3 100644 --- a/docs/canvas/canvas-present-workpad.asciidoc +++ b/docs/canvas/canvas-present-workpad.asciidoc @@ -20,7 +20,7 @@ image::images/canvas-autoplay-interval.png[Element autoplay interval] [role="screenshot"] image::images/canvas-fullscreen.png[Image showing how to enter fullscreen mode from view dropdown] -. When you are ready to exit fullscreen mode, press the Esc (Escape) key. +. When you are ready to exit fullscreen mode, press Esc. [float] [[zoom-in-out]] @@ -48,4 +48,4 @@ Change how often the data refreshes on your workpad. [role="screenshot"] image::images/canvas-refresh-interval.png[Element data refresh interval] + -To manually refresh the data, click image:canvas/images/canvas-refresh-data.png[]. +To manually refresh the data, click image:canvas/images/canvas-refresh-data.png[Canvas refresh data button]. diff --git a/docs/canvas/canvas-share-workpad.asciidoc b/docs/canvas/canvas-share-workpad.asciidoc index 68078b74da171..348d15f39ad76 100644 --- a/docs/canvas/canvas-share-workpad.asciidoc +++ b/docs/canvas/canvas-share-workpad.asciidoc @@ -23,7 +23,7 @@ Want to export multiple workpads? Go to the *Canvas* home page, select the workp [[add-workpad-website]] === Share the workpad on a website -beta[] Canvas allows you to create _shareables_, which are workpads that you download and securely share on any website. +beta[] *Canvas* allows you to create _shareables_, which are workpads that you download and securely share on any website. To customize the behavior of the workpad on your website, you can choose to autoplay the pages or hide the workpad toolbar. . Click *Share > Share on a website*. @@ -32,7 +32,7 @@ To customize the behavior of the workpad on your website, you can choose to auto . To customize the workpad behavior to autoplay the pages or hide the toolbar, use the inline parameters. + -To make sure that your data remains secure, the data in the JSON file is not connected to {kib}. Canvas does not display elements that manipulate the data on the workpad. +To make sure that your data remains secure, the data in the JSON file is not connected to {kib}. *Canvas* does not display elements that manipulate the data on the workpad. + [role="screenshot"] image::canvas/images/canvas-embed_workpad.gif[Image showing how to share the workpad on a website] diff --git a/docs/canvas/canvas-tutorial.asciidoc b/docs/canvas/canvas-tutorial.asciidoc index 6456ba02bb8a8..89114affb9322 100644 --- a/docs/canvas/canvas-tutorial.asciidoc +++ b/docs/canvas/canvas-tutorial.asciidoc @@ -2,17 +2,16 @@ [[canvas-tutorial]] == Tutorial: Create a workpad for monitoring sales -To get up and running with Canvas, add the Sample eCommerce orders data, then use the data to create a workpad for monitoring sales at an eCommerce store. +To familiarize yourself with *Canvas*, add the Sample eCommerce orders data, then use the data to create a workpad for monitoring sales at an eCommerce store. [float] -=== Before you begin +=== Open and set up Canvas -For this tutorial, you'll need to add the <>. +To create a workpad of the eCommerce store data, add the data set, then create the workpad. -[float] -=== Create your workpad +. On the {kib} *Home* page, click *Try our sample data*. -Your first step to working with Canvas is to create a workpad. +. From *Sample eCommerce orders data*, click *Add data*. . Open the main menu, then click *Canvas*. @@ -59,7 +58,7 @@ The query selects the total price field and sets it to the sum_total_price field .. Change the *Label* to `Total sales`. -. The error is gone, but the element could use some formatting. To format the number, use the Canvas expression language. +. The error is gone, but the element could use some formatting. To format the number, use the *Canvas* expression language. .. Click *Expression editor*. + @@ -118,7 +117,7 @@ Your workpad is complete! [float] === What's next? -Now that you know the Canvas basics, you're ready to explore on your own. +Now that you know the basics, you're ready to explore on your own. Here are some things to try: @@ -126,4 +125,4 @@ Here are some things to try: * Build presentations of your own data with <>. -* Deep dive into the {kibana-ref}/canvas-function-reference.html[expression language and functions] that drive Canvas. +* Deep dive into the {kibana-ref}/canvas-function-reference.html[expression language and functions] that drive *Canvas*. diff --git a/docs/canvas/images/canvas-gs-example.png b/docs/canvas/images/canvas-gs-example.png index a9b960342709f..bae32ef96a93f 100644 Binary files a/docs/canvas/images/canvas-gs-example.png and b/docs/canvas/images/canvas-gs-example.png differ diff --git a/docs/developer/telemetry.asciidoc b/docs/developer/telemetry.asciidoc index 75f42a860624b..45d2a140cf8b9 100644 --- a/docs/developer/telemetry.asciidoc +++ b/docs/developer/telemetry.asciidoc @@ -6,6 +6,7 @@ To help us provide a good developer experience, we track some straightforward me 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. 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 026032a7b0740..dc0e60b5986a4 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 @@ -99,7 +99,6 @@ readonly links: { readonly luceneExpressions: string; }; readonly indexPatterns: { - readonly loadingData: string; readonly introduction: string; }; readonly addData: string; @@ -163,5 +162,6 @@ readonly links: { readonly ccs: Record; readonly plugins: Record; readonly snapshotRestore: Record; + readonly ingest: Record; }; ``` 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 d653623d5fe22..8bcb1a8b6ca1a 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 loadingData: string;
readonly introduction: 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>;
} | | +| [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 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/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index b4faa4299a929..8dd4667002ead 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -300,7 +300,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttributeSingle](./kibana-plugin-core-server.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-server.savedobjectattribute.md) | | [SavedObjectMigrationFn](./kibana-plugin-core-server.savedobjectmigrationfn.md) | A migration function for a [saved object type](./kibana-plugin-core-server.savedobjectstype.md) used to migrate it to a given version | | [SavedObjectSanitizedDoc](./kibana-plugin-core-server.savedobjectsanitizeddoc.md) | Describes Saved Object documents that have passed through the migration framework and are guaranteed to have a references root property. | -| [SavedObjectsClientContract](./kibana-plugin-core-server.savedobjectsclientcontract.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state.\#\# SavedObjectsClient errorsSince the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to application code. Ideally, all errors will be either:1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md)Type 1 errors are inevitable, but since all expected/handle-able errors should be Type 2 the isXYZError() helpers exposed at SavedObjectsErrorHelpers should be used to understand and manage error responses from the SavedObjectsClient.Type 2 errors are decorated versions of the source error, so if the elasticsearch client threw an error it will be decorated based on its type. That means that rather than looking for error.body.error.type or doing substring checks on error.body.error.reason, just use the helpers to understand the meaning of the error:\`\`\`js if (SavedObjectsErrorHelpers.isNotFoundError(error)) { // handle 404 }if (SavedObjectsErrorHelpers.isNotAuthorizedError(error)) { // 401 handling should be automatic, but in case you wanted to know }// always rethrow the error unless you handle it throw error; \`\`\`\#\#\# 404s from missing indexFrom the perspective of application code and APIs the SavedObjectsClient is a black box that persists objects. One of the internal details that users have no control over is that we use an elasticsearch index for persistance and that index might be missing.At the time of writing we are in the process of transitioning away from the operating assumption that the SavedObjects index is always available. Part of this transition is handling errors resulting from an index missing. These used to trigger a 500 error in most cases, and in others cause 404s with different error messages.From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The object the request/call was targeting could not be found. This is why \#14141 takes special care to ensure that 404 errors are generic and don't distinguish between index missing or document missing.See [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) See [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) | +| [SavedObjectsClientContract](./kibana-plugin-core-server.savedobjectsclientcontract.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state.\#\# SavedObjectsClient errorsSince the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to to application code. Ideally, all errors will be either:1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md)Type 1 errors are inevitable, but since all expected/handle-able errors should be Type 2 the isXYZError() helpers exposed at SavedObjectsErrorHelpers should be used to understand and manage error responses from the SavedObjectsClient.Type 2 errors are decorated versions of the source error, so if the elasticsearch client threw an error it will be decorated based on its type. That means that rather than looking for error.body.error.type or doing substring checks on error.body.error.reason, just use the helpers to understand the meaning of the error:\`\`\`js if (SavedObjectsErrorHelpers.isNotFoundError(error)) { // handle 404 }if (SavedObjectsErrorHelpers.isNotAuthorizedError(error)) { // 401 handling should be automatic, but in case you wanted to know }// always rethrow the error unless you handle it throw error; \`\`\`\#\#\# 404s from missing indexFrom the perspective of application code and APIs the SavedObjectsClient is a black box that persists objects. One of the internal details that users have no control over is that we use an elasticsearch index for persistance and that index might be missing.At the time of writing we are in the process of transitioning away from the operating assumption that the SavedObjects index is always available. Part of this transition is handling errors resulting from an index missing. These used to trigger a 500 error in most cases, and in others cause 404s with different error messages.From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The object the request/call was targeting could not be found. This is why \#14141 takes special care to ensure that 404 errors are generic and don't distinguish between index missing or document missing.See [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) See [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) | | [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md) | Describes the factory used to create instances of the Saved Objects Client. | | [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. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclientcontract.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclientcontract.md index 537cfbc175671..610356a733126 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclientcontract.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclientcontract.md @@ -8,7 +8,7 @@ Saved Objects is Kibana's data persisentence mechanism allowing plugins to use E \#\# SavedObjectsClient errors -Since the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to application code. Ideally, all errors will be either: +Since the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to to application code. Ideally, all errors will be either: 1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) 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 d408f00e33c9e..698b4bc7f2043 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, { expressions }: IndexPatternsServiceSetupDeps): void; +setup(core: CoreSetup, { logger, expressions }: IndexPatternsServiceSetupDeps): void; ``` ## Parameters @@ -15,7 +15,7 @@ setup(core: CoreSetup, { expressio | Parameter | Type | Description | | --- | --- | --- | | core | CoreSetup<DataPluginStartDependencies, DataPluginStart> | | -| { expressions } | IndexPatternsServiceSetupDeps | | +| { logger, expressions } | IndexPatternsServiceSetupDeps | | Returns: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.cancelsession.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.cancelsession.md new file mode 100644 index 0000000000000..3b38e64ecc3da --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.cancelsession.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IScopedSearchClient](./kibana-plugin-plugins-data-server.iscopedsearchclient.md) > [cancelSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.cancelsession.md) + +## IScopedSearchClient.cancelSession property + +Signature: + +```typescript +cancelSession: IScopedSearchSessionsClient['cancel']; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.deletesession.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.deletesession.md new file mode 100644 index 0000000000000..609c730c2911c --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.deletesession.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IScopedSearchClient](./kibana-plugin-plugins-data-server.iscopedsearchclient.md) > [deleteSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.deletesession.md) + +## IScopedSearchClient.deleteSession property + +Signature: + +```typescript +deleteSession: IScopedSearchSessionsClient['delete']; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.extendsession.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.extendsession.md new file mode 100644 index 0000000000000..33ce8f2a82d0f --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.extendsession.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IScopedSearchClient](./kibana-plugin-plugins-data-server.iscopedsearchclient.md) > [extendSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.extendsession.md) + +## IScopedSearchClient.extendSession property + +Signature: + +```typescript +extendSession: IScopedSearchSessionsClient['extend']; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.findsessions.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.findsessions.md new file mode 100644 index 0000000000000..2a78e09841e77 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.findsessions.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IScopedSearchClient](./kibana-plugin-plugins-data-server.iscopedsearchclient.md) > [findSessions](./kibana-plugin-plugins-data-server.iscopedsearchclient.findsessions.md) + +## IScopedSearchClient.findSessions property + +Signature: + +```typescript +findSessions: IScopedSearchSessionsClient['find']; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.getsession.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.getsession.md new file mode 100644 index 0000000000000..4afcf4ad29195 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.getsession.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IScopedSearchClient](./kibana-plugin-plugins-data-server.iscopedsearchclient.md) > [getSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.getsession.md) + +## IScopedSearchClient.getSession property + +Signature: + +```typescript +getSession: IScopedSearchSessionsClient['get']; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.md new file mode 100644 index 0000000000000..41ac662905b6b --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IScopedSearchClient](./kibana-plugin-plugins-data-server.iscopedsearchclient.md) + +## IScopedSearchClient interface + +Signature: + +```typescript +export interface IScopedSearchClient extends ISearchClient +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [cancelSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.cancelsession.md) | IScopedSearchSessionsClient['cancel'] | | +| [deleteSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.deletesession.md) | IScopedSearchSessionsClient['delete'] | | +| [extendSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.extendsession.md) | IScopedSearchSessionsClient['extend'] | | +| [findSessions](./kibana-plugin-plugins-data-server.iscopedsearchclient.findsessions.md) | IScopedSearchSessionsClient['find'] | | +| [getSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.getsession.md) | IScopedSearchSessionsClient['get'] | | +| [saveSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.savesession.md) | IScopedSearchSessionsClient['save'] | | +| [updateSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.updatesession.md) | IScopedSearchSessionsClient['update'] | | + diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.savesession.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.savesession.md new file mode 100644 index 0000000000000..78cd49c376005 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.savesession.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IScopedSearchClient](./kibana-plugin-plugins-data-server.iscopedsearchclient.md) > [saveSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.savesession.md) + +## IScopedSearchClient.saveSession property + +Signature: + +```typescript +saveSession: IScopedSearchSessionsClient['save']; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.updatesession.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.updatesession.md new file mode 100644 index 0000000000000..5e010f9168e43 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iscopedsearchclient.updatesession.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IScopedSearchClient](./kibana-plugin-plugins-data-server.iscopedsearchclient.md) > [updateSession](./kibana-plugin-plugins-data-server.iscopedsearchclient.updatesession.md) + +## IScopedSearchClient.updateSession property + +Signature: + +```typescript +updateSession: IScopedSearchSessionsClient['update']; +``` 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 fd9ed1e8f635c..16d9ce457603e 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 @@ -52,6 +52,7 @@ | [IFieldSubType](./kibana-plugin-plugins-data-server.ifieldsubtype.md) | | | [IFieldType](./kibana-plugin-plugins-data-server.ifieldtype.md) | | | [IndexPatternAttributes](./kibana-plugin-plugins-data-server.indexpatternattributes.md) | Interface for an index pattern saved object | +| [IScopedSearchClient](./kibana-plugin-plugins-data-server.iscopedsearchclient.md) | | | [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) | | | [ISearchSessionService](./kibana-plugin-plugins-data-server.isearchsessionservice.md) | | | [ISearchSetup](./kibana-plugin-plugins-data-server.isearchsetup.md) | | @@ -108,6 +109,5 @@ | [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.searchrequesthandlercontext.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchrequesthandlercontext.md deleted file mode 100644 index f031ddfbd09af..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchrequesthandlercontext.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[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/glossary.asciidoc b/docs/glossary.asciidoc index 02751ec57a1cf..e6b81d624b02e 100644 --- a/docs/glossary.asciidoc +++ b/docs/glossary.asciidoc @@ -13,9 +13,8 @@ + -- // tag::action-def[] -The rule-specific response that occurs when an alerting rule fires. -A rule can have multiple actions. -See +The rule-specific response that occurs when an alerting <> +fires. A rule can have multiple actions. See {kibana-ref}/action-types.html[Connectors and actions]. // end::action-def[] -- @@ -99,7 +98,8 @@ The cluster location is the weighted centroid for all documents in the grid cell [[glossary-condition]] condition :: // tag::condition-def[] -Specifies the circumstances that must be met to trigger an alerting rule. +Specifies the circumstances that must be met to trigger an alerting +<>. // end::condition-def[] [[glossary-connector]] connector :: diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 3ee7a0471eec1..5c27a7bdacdee 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -240,10 +240,6 @@ in the current index pattern is used. The columns that appear by default on the *Discover* page. The default is `_source`. -[[discover-aggs-terms-size]]`discover:aggs:terms:size`:: -The number terms that are visualized when clicking the *Visualize* button in the -field drop down. The default is `20`. - [[discover-samplesize]]`discover:sampleSize`:: The number of rows to show in the *Discover* table. diff --git a/docs/management/ingest-pipelines/ingest-pipelines.asciidoc b/docs/management/ingest-pipelines/ingest-pipelines.asciidoc deleted file mode 100644 index d9745bfef524a..0000000000000 --- a/docs/management/ingest-pipelines/ingest-pipelines.asciidoc +++ /dev/null @@ -1,170 +0,0 @@ -[role="xpack"] -[[ingest-node-pipelines]] -== Ingest Node Pipelines - -*Ingest Node Pipelines* enables you to create and manage {es} -pipelines that perform common transformations and -enrichments on your data. For example, you might remove a field, -rename an existing field, or set a new field. - -To begin, open the main menu, then click *Stack Management > Ingest Node Pipelines*. With *Ingest Node Pipelines*, you can: - -* View a list of your pipelines and drill down into details. -* Create a pipeline that defines a series of tasks, known as processors. -* Test a pipeline before feeding it with real data to ensure the pipeline works as expected. -* Delete a pipeline that is no longer needed. - -[role="screenshot"] -image:management/ingest-pipelines/images/ingest-pipeline-list.png["Ingest node pipeline list"] - -[float] -=== Required permissions - -The minimum required permissions to access *Ingest Node Pipelines* are -the `manage_pipeline` and `cluster:monitor/nodes/info` cluster privileges. - -To add privileges, open the main menu, then click *Stack Management > Roles*. - -[role="screenshot"] -image:management/ingest-pipelines/images/ingest-pipeline-privileges.png["Privileges required for Ingest Node Pipelines"] - -[float] -[[ingest-node-pipelines-manage]] -=== Manage pipelines - -From the list view, you can to drill down into the details of a pipeline. -To -edit, clone, or delete a pipeline, use the *Actions* menu. - -If you don’t have any pipelines, you can create one using the -*Create pipeline* form. You’ll define processors to transform documents -in a specific way. To handle exceptions, you can optionally define -failure processors to execute immediately after a failed processor. -Before creating the pipeline, you can verify it provides the expected output. - -[float] -[[ingest-node-pipelines-example]] -==== Example: Create a pipeline - -In this example, you’ll create a pipeline to handle server logs in the -Common Log Format. The log looks similar to this: - -[source,js] ----------------------------------- -212.87.37.154 - - [05/May/2020:16:21:15 +0000] \"GET /favicon.ico HTTP/1.1\" -200 3638 \"-\" \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) -AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36\" ----------------------------------- - -The log contains an IP address, timestamp, and user agent. You want to give -these three items their own field in {es} for fast search and visualization. -You also want to know where the request is coming from. - -. In *Ingest Node Pipelines*, click *Create a pipeline*. -. Provide a name and description for the pipeline. -. Add a grok processor to parse the log message: - -.. Click *Add a processor* and select the *Grok* processor type. -.. Set the field input to `message` and enter the following grok pattern: -+ -[source,js] ----------------------------------- -%{IPORHOST:clientip} %{USER:ident} %{USER:auth} \[%{HTTPDATE:timestamp}\] "%{WORD:verb} %{DATA:request} HTTP/%{NUMBER:httpversion}" %{NUMBER:response:int} (?:-|%{NUMBER:bytes:int}) %{QS:referrer} %{QS:agent} ----------------------------------- -+ -.. Click *Update* to save the processor. - -. Add processors to map the date, IP, and user agent fields. - -.. Map the appropriate field to each processor type: -+ --- -* **Date**: `timestamp` -* **GeoIP**: `clientip` -* **User agent**: `agent` - -For the **Date** processor, you also need to specify the date format you want to use: `dd/MMM/YYYY:HH:mm:ss Z`. --- -Your form should look similar to this: -+ -[role="screenshot"] -image:management/ingest-pipelines/images/ingest-pipeline-processor.png["Processors for Ingest Node Pipelines"] -+ -Alternatively, you can click the **Import processors** link and define the processors as JSON: -+ -[source,js] ----------------------------------- -{ - "processors": [ - { - "grok": { - "field": "message", - "patterns": ["%{IPORHOST:clientip} %{USER:ident} %{USER:auth} \\[%{HTTPDATE:timestamp}\\] \"%{WORD:verb} %{DATA:request} HTTP/%{NUMBER:httpversion}\" %{NUMBER:response:int} (?:-|%{NUMBER:bytes:int}) %{QS:referrer} %{QS:agent}"] - } - }, - { - "date": { - "field": "timestamp", - "formats": [ "dd/MMM/YYYY:HH:mm:ss Z" ] - } - }, - { - "geoip": { - "field": "clientip" - } - }, - { - "user_agent": { - "field": "agent" - } - } - ] -} ----------------------------------- -+ -The four {ref}/ingest-processors.html[processors] will run sequentially: -{ref}/grok-processor.html[grok], {ref}/date-processor.html[date], -{ref}/geoip-processor.html[geoip], and {ref}/user-agent-processor.html[user_agent]. You can reorder processors using the arrow icon next to each processor. - -. To test the pipeline to verify that it produces the expected results, click *Add documents*. - -. In the *Documents* tab, provide a sample document for testing: -+ -[source,js] ----------------------------------- -[ - { - "_source": { - "message": "212.87.37.154 - - [05/May/2020:16:21:15 +0000] \"GET /favicon.ico HTTP/1.1\" 200 3638 \"-\" \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36\"" - } - } -] ----------------------------------- - -. Click *Run the pipeline* and check if the pipeline worked as expected. -+ -You can also -view the verbose output and refresh the output from this view. - -. If everything looks correct, close the panel, and then click *Create pipeline*. -+ -At this point, you’re ready to use the Elasticsearch index API to load -the logs data. - -. In the Kibana Console, index a document with the pipeline -you created. -+ -[source,js] ----------------------------------- -PUT my-index/_doc/1?pipeline=access_logs -{ - "message": "212.87.37.154 - - [05/May/2020:16:21:15 +0000] \"GET /favicon.ico HTTP/1.1\" 200 3638 \"-\" \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36\"" -} ----------------------------------- - -. To verify, run: -+ -[source,js] ----------------------------------- -GET my-index/_doc/1 ----------------------------------- diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index c7bdff800bb0b..a2ab1c10d9cd5 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -280,3 +280,8 @@ This content has moved. Refer to <>. [role="exclude",id="explore-dashboard-data"] This content has moved. Refer to <>. + +[role="exclude",id="ingest-node-pipelines"] +== Ingest Node Pipelines + +This content has moved. See {ref}/ingest.html[Ingest pipelines]. diff --git a/docs/user/canvas.asciidoc b/docs/user/canvas.asciidoc index 6a3cee020538d..e5ac44a4e5401 100644 --- a/docs/user/canvas.asciidoc +++ b/docs/user/canvas.asciidoc @@ -5,31 +5,52 @@ [partintro] -- -Canvas is a data visualization and presentation tool that sits within {kib}. With Canvas, you can pull live data directly from {es}, and combine the data with colors, images, text, and your imagination to create dynamic, multi-page, pixel-perfect displays. If you are a little bit creative, a little bit technical, and a whole lot curious, then Canvas is for you. +*Canvas* is a data visualization and presentation tool that allows you to pull live data from {es}, +then combine the data with colors, images, text, and your imagination to create dynamic, multi-page, pixel-perfect displays. +If you are a little bit creative, a little bit technical, and a whole lot curious, then *Canvas* is for you. -With Canvas, you can: +With *Canvas*, you can: * Create and personalize your work space with backgrounds, borders, colors, fonts, and more. * Customize your workpad with your own visualizations, such as images and text. -* Pull your data directly from Elasticsearch, then show it off with charts, graphs, progress monitors, and more. +* Pull your data directly from {es}, then show it off with charts, graphs, progress monitors, and more. * Focus the data you want to display with filters. -To begin, open the main menu, then click *Canvas*. - -[role="screenshot"] -image::images/canvas-gs-example.png[Getting started example] - -For a quick overview of Canvas, watch link:https://www.youtube.com/watch?v=ZqvF_5-1xjQ[Stand out with Canvas]. +++++ + +
+++++ [float] [[create-workpads]] == Create workpads -A _workpad_ provides you with a space where you can build presentations of your live data. With Canvas, -you can create a workpad from scratch, start with a preconfigured workpad, import an existing workpad, or use a sample data workpad. +A _workpad_ provides you with a space where you can build presentations of your live data. You can create a workpad from scratch, start with a preconfigured workpad, +import an existing workpad, or use a sample data workpad. + +[float] +[[canvas-minimum-requirements]] +=== Minimum requirements + +To create workpads, you must meet the minimum requirements. + +* If you need to set up {kib}, use https://www.elastic.co/cloud/elasticsearch-service/signup?baymax=docs-body&elektra=docs[our free trial]. + +* Make sure you have {ref}/getting-started-index.html[data indexed into {es}] and an <>. + +* Have an understanding of {ref}/documents-indices.html[{es} documents and indices]. + +* Make sure you have sufficient privileges to create and save workpads. When the read-only indicator appears, you have insufficient privileges, +and the options to create and save workpads are unavailable. For more information, refer to <>. + +To open *Canvas*, open the main menu, then click *Canvas*. [float] [[start-with-a-blank-workpad]] @@ -54,7 +75,7 @@ image::images/canvas-background-color-picker.png[Canvas color picker] [[create-workpads-from-templates]] === Create workpads from templates -If you're unsure about where to start, you can use one of the preconfigured templates that come with Canvas. +If you're unsure about where to start, you can use one of the preconfigured templates that come with *Canvas*. . On the *Canvas workpads* page, select *Templates*. @@ -90,55 +111,60 @@ Create a story about your data by adding elements to your workpad that include i [[create-elements]] === Create elements -Choose the type of element you want to use, then use the preconfigured demo data to familiarize yourself with the element. When you're ready, connect the element to your own data. By default, most of the elements you create use -demo data until you change the data source. The demo data includes a small data set that you can use to experiment with your element. - -To begin, click *Add element*, then select the element you want to use. +Choose the type of element you want to use, then use the preconfigured demo data to familiarize yourself with the element. When you're ready, connect the element to your own data. +By default, most of the elements you create use the demo data until you change the data source. The demo data includes a small data set that you can use to experiment with your element. +. Click *Add element*, then select the element you want to use. ++ [role="screenshot"] image::images/canvas-element-select.gif[Canvas elements] -When you're ready to connect the element to your data, select *Data*, then select one of the following data sources: +. To connect the element to your data, select *Data*, then select one of the following data sources: * *{es} SQL* — Access your data in {es} using {ref}/sql-spec.html[SQL syntax]. -* *{es} documents* — Access your data in {es} without using aggregations. To use, select an index and fields, and optionally enter a query using the <>. Use the *{es} documents* data source when you have low volume datasets, to view raw documents, or to plot exact, non-aggregated values on a chart. - -* *Timelion* — Access your time series data using <> queries. To use Timelion queries, you can enter a query using the <>. +* *{es} documents* — Access your data in {es} without using aggregations. To use, select an index and fields, and optionally enter a query using the <>. +Use the *{es} documents* data source when you have low volume datasets, to view raw documents, or to plot exact, non-aggregated values on a chart. +* *Timelion* — Access your time series data using <> queries. To use *Timelion* queries, you can enter a query using the <>. ++ Each element can display a different data source, and pages and workpads often contain multiple data sources. -When you're ready to save your element, select the element, then click *Edit > Save as new element*. +. To save, use the following options: +* To save a single element, select the element, then click *Edit > Save as new element*. ++ [role="screenshot"] image::images/canvas_save_element.png[] -To save a group of elements, press and hold Shift, select the elements you want to save, then click *Edit > Save as new element*. +* To save a group of elements, press and hold Shift, select the elements you want to save, then click *Edit > Save as new element*. -Elements are saved in *Add element > My elements*. +To access your saved elements, click *Add element > My elements*. [float] -[[add-saved-objects]] -=== Add saved objects +[[add-kibana-visualizations]] +=== Add panels from the Visualize Library -Add <> to your workpad, such as maps and visualizations. +Add a panel that you saved in *Visualize Library* to your workpad. . Click *Add element > Add from {kib}*. -. Select the saved object you want to add. +. Select the panel you want to add. + [role="screenshot"] image::images/canvas-map-embed.gif[] -. To use the customization options, click the panel menu, then select one of the following options: +. To use the customization options, open the panel menu, then select one of the following options: + +* *Edit map* — Opens <> so that you can edit the panel. -* *Edit map* — Opens <> or a visualization builder so that you can edit the original saved object. +* *Edit visualization* — Opens the visualization editor so that you can edit the panel. -* *Edit panel title* — Adds a title to the saved object. +* *Edit panel title* — Allows you to change the panel title. -* *Customize time range* — Exposes a time filter dedicated to the saved object. +* *Customize time range* — Allows you to change the time filter dedicated to the panel. -* *Inspect* — Allows you to drill down into the element data. +* *Inspect* — Allows you to drill down into the panel data. [float] [[add-your-own-images]] diff --git a/docs/user/management.asciidoc b/docs/user/management.asciidoc index f29718e6d588b..5644cdbfc45ec 100644 --- a/docs/user/management.asciidoc +++ b/docs/user/management.asciidoc @@ -17,10 +17,9 @@ Consult your administrator if you do not have the appropriate access. [cols="50, 50"] |=== -| <> -| Create and manage {es} -pipelines that enable you to perform common transformations and -enrichments on your data. +| {ref}/ingest.html[Ingest Node Pipelines] +| Create and manage ingest pipelines that let you perform common transformations +and enrichments on your data. | {logstash-ref}/logstash-centralized-pipeline-management.html[Logstash Pipelines] | Create, edit, and delete your Logstash pipeline configurations. @@ -187,8 +186,6 @@ include::{kib-repo-dir}/management/alerting/connector-management.asciidoc[] include::{kib-repo-dir}/management/managing-beats.asciidoc[] -include::{kib-repo-dir}/management/ingest-pipelines/ingest-pipelines.asciidoc[] - include::{kib-repo-dir}/management/managing-fields.asciidoc[] include::{kib-repo-dir}/management/managing-licenses.asciidoc[] diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index f93849e011d41..1af74aa3d8828 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -108,3 +108,4 @@ pageLoadAssetSize: fileUpload: 25664 banners: 17946 mapsEms: 26072 + cases: 102558 diff --git a/packages/kbn-optimizer/src/cli.ts b/packages/kbn-optimizer/src/cli.ts index 8f82f34646e60..6e3106dbc2af7 100644 --- a/packages/kbn-optimizer/src/cli.ts +++ b/packages/kbn-optimizer/src/cli.ts @@ -18,6 +18,7 @@ import { logOptimizerState } from './log_optimizer_state'; import { OptimizerConfig } from './optimizer'; import { runOptimizer } from './run_optimizer'; import { validateLimitsForAllBundles, updateBundleLimits } from './limits'; +import { reportOptimizerTimings } from './report_optimizer_timings'; function getLimitsPath(flags: Flags, defaultPath: string) { if (flags.limits) { @@ -144,7 +145,9 @@ export function runKbnOptimizerCli(options: { defaultLimitsPath: string }) { const update$ = runOptimizer(config); - await lastValueFrom(update$.pipe(logOptimizerState(log, config))); + await lastValueFrom( + update$.pipe(logOptimizerState(log, config), reportOptimizerTimings(log, config)) + ); if (updateLimits) { updateBundleLimits({ diff --git a/packages/kbn-optimizer/src/common/theme_tags.test.ts b/packages/kbn-optimizer/src/common/theme_tags.test.ts index d0952a22da90d..126d1b1833873 100644 --- a/packages/kbn-optimizer/src/common/theme_tags.test.ts +++ b/packages/kbn-optimizer/src/common/theme_tags.test.ts @@ -11,8 +11,8 @@ import { parseThemeTags } from './theme_tags'; it('returns default tags when passed undefined', () => { expect(parseThemeTags()).toMatchInlineSnapshot(` Array [ - "v7dark", - "v7light", + "v8dark", + "v8light", ] `); }); diff --git a/packages/kbn-optimizer/src/common/theme_tags.ts b/packages/kbn-optimizer/src/common/theme_tags.ts index e889b5d3642c8..de95bbdcbcfea 100644 --- a/packages/kbn-optimizer/src/common/theme_tags.ts +++ b/packages/kbn-optimizer/src/common/theme_tags.ts @@ -17,7 +17,7 @@ const isArrayOfStrings = (input: unknown): input is string[] => export type ThemeTags = readonly ThemeTag[]; export type ThemeTag = 'v7light' | 'v7dark' | 'v8light' | 'v8dark'; -export const DEFAULT_THEMES = tags('v7light', 'v7dark'); +export const DEFAULT_THEMES = tags('v8light', 'v8dark'); export const ALL_THEMES = tags('v7light', 'v7dark', 'v8light', 'v8dark'); export function parseThemeTags(input?: any): ThemeTags { diff --git a/packages/kbn-optimizer/src/index.ts b/packages/kbn-optimizer/src/index.ts index 8d6e89008bc68..a5838a8a0fac8 100644 --- a/packages/kbn-optimizer/src/index.ts +++ b/packages/kbn-optimizer/src/index.ts @@ -12,3 +12,4 @@ export * from './log_optimizer_state'; export * from './node'; export * from './limits'; export * from './cli'; +export * from './report_optimizer_timings'; diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 9e9e8960da21b..ad4a41824096a 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -94,8 +94,8 @@ OptimizerConfig { "profileWebpack": false, "repoRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "themeTags": Array [ - "v7dark", - "v7light", + "v8dark", + "v8light", ], "watch": false, } diff --git a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index f378b029d32e7..a86f231b79806 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -159,8 +159,8 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/_other_styles.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/styles.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/lib.ts, - /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/core/public/core_app/styles/_globals_v7dark.scss, - /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/core/public/core_app/styles/_globals_v7light.scss, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/core/public/core_app/styles/_globals_v8dark.scss, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/core/public/core_app/styles/_globals_v8light.scss, /packages/kbn-optimizer/target/worker/entry_point_creator.js, /packages/kbn-ui-shared-deps/public_path_module_creator.js, ] diff --git a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts b/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts index 746064bfb3414..832fd812d36bb 100644 --- a/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts +++ b/packages/kbn-optimizer/src/optimizer/cache_keys.test.ts @@ -94,8 +94,8 @@ describe('getOptimizerCacheKey()', () => { "optimizerCacheKey": "♻", "repoRoot": , "themeTags": Array [ - "v7dark", - "v7light", + "v8dark", + "v8light", ], }, } diff --git a/packages/kbn-optimizer/src/report_optimizer_timings.ts b/packages/kbn-optimizer/src/report_optimizer_timings.ts new file mode 100644 index 0000000000000..dcb3a0fba77b5 --- /dev/null +++ b/packages/kbn-optimizer/src/report_optimizer_timings.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 { concatMap } from 'rxjs/operators'; +import { CiStatsReporter, ToolingLog } from '@kbn/dev-utils'; + +import { OptimizerConfig } from './optimizer'; +import { OptimizerUpdate$ } from './run_optimizer'; +import { pipeClosure } from './common'; + +export function reportOptimizerTimings(log: ToolingLog, config: OptimizerConfig) { + return pipeClosure((update$: OptimizerUpdate$) => { + let sent = false; + + const cachedBundles = new Set(); + const notCachedBundles = new Set(); + + return update$.pipe( + concatMap(async (update) => { + // if we've already sent timing data then move on + if (sent) { + return update; + } + + if (update.event?.type === 'bundle cached') { + cachedBundles.add(update.event.bundle.id); + } + if (update.event?.type === 'bundle not cached') { + notCachedBundles.add(update.event.bundle.id); + } + + // wait for the optimizer to complete, either with a success or failure + if (update.state.phase !== 'issue' && update.state.phase !== 'success') { + return update; + } + + sent = true; + const reporter = CiStatsReporter.fromEnv(log); + const time = Date.now() - update.state.startTime; + + await reporter.timings({ + timings: [ + { + group: '@kbn/optimizer', + id: 'overall time', + ms: time, + meta: { + optimizerBundleCount: config.bundles.length, + optimizerBundleCacheCount: cachedBundles.size, + optimizerBundleCachePct: Math.floor( + (cachedBundles.size / config.bundles.length) * 100 + ), + optimizerWatch: config.watch, + optimizerProduction: config.dist, + optimizerProfileWebpack: config.profileWebpack, + optimizerBundleThemeTagsCount: config.themeTags.length, + optimizerCache: config.cache, + optimizerMaxWorkerCount: config.maxWorkerCount, + }, + }, + ], + }); + + return update; + }) + ); + }); +} diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/all_extracted_collectors.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/all_extracted_collectors.ts index 37bdd327f945b..1f74a2a02eb1e 100644 --- a/packages/kbn-telemetry-tools/src/tools/__fixture__/all_extracted_collectors.ts +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/all_extracted_collectors.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { ParsedUsageCollection } from '../ts_parser'; import { parsedExternallyDefinedCollector } from './parsed_externally_defined_collector'; import { parsedImportedSchemaCollector } from './parsed_imported_schema'; import { parsedImportedUsageInterface } from './parsed_imported_usage_interface'; @@ -14,15 +15,18 @@ import { parsedNestedCollector } from './parsed_nested_collector'; import { parsedSchemaDefinedWithSpreadsCollector } from './parsed_schema_defined_with_spreads_collector'; import { parsedWorkingCollector } from './parsed_working_collector'; import { parsedCollectorWithDescription } from './parsed_working_collector_with_description'; -import { ParsedUsageCollection } from '../ts_parser'; +import { parsedStatsCollector } from './parsed_stats_collector'; +import { parsedImportedInterfaceFromExport } from './parsed_imported_interface_from_export'; export const allExtractedCollectors: ParsedUsageCollection[] = [ ...parsedExternallyDefinedCollector, + ...parsedImportedInterfaceFromExport, ...parsedImportedSchemaCollector, ...parsedImportedUsageInterface, parsedIndexedInterfaceWithNoMatchingSchema, parsedNestedCollector, parsedSchemaDefinedWithSpreadsCollector, + ...parsedStatsCollector, parsedCollectorWithDescription, parsedWorkingCollector, ]; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_interface_from_export.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_interface_from_export.ts new file mode 100644 index 0000000000000..42f958d1e33c5 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_interface_from_export.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SyntaxKind } from 'typescript'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const parsedImportedInterfaceFromExport: ParsedUsageCollection[] = [ + [ + 'src/fixtures/telemetry_collectors/imported_interface_from_export/index.ts', + { + collectorName: 'importing_from_export_collector', + schema: { + value: { + some_field: { + type: 'keyword', + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + some_field: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, + }, + }, + }, + ], +]; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_stats_collector.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_stats_collector.ts new file mode 100644 index 0000000000000..828372bf0b7d9 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_stats_collector.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SyntaxKind } from 'typescript'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const parsedStatsCollector: ParsedUsageCollection[] = [ + [ + 'src/fixtures/telemetry_collectors/stats_collector.ts', + { + collectorName: 'my_stats_collector_with_schema', + schema: { + value: { + some_field: { + type: 'keyword', + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + some_field: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, + }, + }, + }, + ], +]; diff --git a/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts index 5106ac7855fc6..5eee06a5182ee 100644 --- a/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts +++ b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts @@ -24,7 +24,7 @@ describe('extractCollectors', () => { const programPaths = await getProgramPaths(configs[0]); const results = [...extractCollectors(programPaths, tsConfig)]; - expect(results).toHaveLength(9); + expect(results).toHaveLength(11); expect(results).toStrictEqual(allExtractedCollectors); }); }); diff --git a/packages/kbn-telemetry-tools/src/tools/serializer.ts b/packages/kbn-telemetry-tools/src/tools/serializer.ts index b3111af5eec94..9bde3cb839364 100644 --- a/packages/kbn-telemetry-tools/src/tools/serializer.ts +++ b/packages/kbn-telemetry-tools/src/tools/serializer.ts @@ -202,7 +202,7 @@ export function getDescriptor(node: ts.Node, program: ts.Program): Descriptor | return getDescriptor(node.typeName, program); } - if (ts.isImportSpecifier(node)) { + if (ts.isImportSpecifier(node) || ts.isExportSpecifier(node)) { const source = node.getSourceFile(); const importedModuleName = getModuleSpecifier(node); diff --git a/packages/kbn-telemetry-tools/src/tools/ts_parser.test.ts b/packages/kbn-telemetry-tools/src/tools/ts_parser.test.ts index 761645b9887da..4a58e3fc1101b 100644 --- a/packages/kbn-telemetry-tools/src/tools/ts_parser.test.ts +++ b/packages/kbn-telemetry-tools/src/tools/ts_parser.test.ts @@ -15,6 +15,8 @@ import { parsedExternallyDefinedCollector } from './__fixture__/parsed_externall import { parsedImportedUsageInterface } from './__fixture__/parsed_imported_usage_interface'; import { parsedImportedSchemaCollector } from './__fixture__/parsed_imported_schema'; import { parsedSchemaDefinedWithSpreadsCollector } from './__fixture__/parsed_schema_defined_with_spreads_collector'; +import { parsedStatsCollector } from './__fixture__/parsed_stats_collector'; +import { parsedImportedInterfaceFromExport } from './__fixture__/parsed_imported_interface_from_export'; export function loadFixtureProgram(fixtureName: string) { const fixturePath = path.resolve( @@ -89,6 +91,18 @@ describe('parseUsageCollection', () => { expect(result).toEqual(parsedImportedUsageInterface); }); + it('parses stats collectors, discarding those without schemas', () => { + const { program, sourceFile } = loadFixtureProgram('stats_collector.ts'); + const result = [...parseUsageCollection(sourceFile, program)]; + expect(result).toEqual(parsedStatsCollector); + }); + + it('follows `export { Usage } from "./path"` expressions', () => { + const { program, sourceFile } = loadFixtureProgram('imported_interface_from_export/index.ts'); + const result = [...parseUsageCollection(sourceFile, program)]; + expect(result).toEqual(parsedImportedInterfaceFromExport); + }); + it('skips files that do not define a collector', () => { const { program, sourceFile } = loadFixtureProgram('file_with_no_collector.ts'); const result = [...parseUsageCollection(sourceFile, program)]; diff --git a/packages/kbn-telemetry-tools/src/tools/ts_parser.ts b/packages/kbn-telemetry-tools/src/tools/ts_parser.ts index 1e2bb0a0dbed0..9431e7e053684 100644 --- a/packages/kbn-telemetry-tools/src/tools/ts_parser.ts +++ b/packages/kbn-telemetry-tools/src/tools/ts_parser.ts @@ -41,6 +41,24 @@ export function isMakeUsageCollectorFunction( return false; } +export function isMakeStatsCollectorFunctionWithSchema( + node: ts.Node, + sourceFile: ts.SourceFile +): node is ts.CallExpression { + if (ts.isCallExpression(node)) { + const isMakeStatsCollector = /makeStatsCollector$/.test(node.expression.getText(sourceFile)); + if (isMakeStatsCollector) { + const collectorConfig = getCollectionConfigNode(node, sourceFile); + const schemaProperty = getProperty(collectorConfig, 'schema'); + if (schemaProperty) { + return true; + } + } + } + + return false; +} + export interface CollectorDetails { collectorName: string; fetch: { typeName: string; typeDescriptor: Descriptor }; @@ -140,6 +158,7 @@ function extractCollectorDetails( throw Error(`usageCollector.schema must be be an object.`); } + // TODO: Try to infer the output type from fetch instead of being explicit const collectorNodeType = collectorNode.typeArguments; if (!collectorNodeType || collectorNodeType?.length === 0) { throw Error(`makeUsageCollector requires a Usage type makeUsageCollector({ ... }).`); @@ -172,7 +191,19 @@ export function sourceHasUsageCollector(sourceFile: ts.SourceFile) { } return false; - return true; +} + +export function sourceHasStatsCollector(sourceFile: ts.SourceFile) { + if (sourceFile.isDeclarationFile === true || (sourceFile as any).identifierCount === 0) { + return false; + } + + const identifiers = (sourceFile as any).identifiers; + if (identifiers.get('makeStatsCollector')) { + return true; + } + + return false; } export type ParsedUsageCollection = [string, CollectorDetails]; @@ -182,9 +213,12 @@ export function* parseUsageCollection( program: ts.Program ): Generator { const relativePath = path.relative(process.cwd(), sourceFile.fileName); - if (sourceHasUsageCollector(sourceFile)) { + if (sourceHasUsageCollector(sourceFile) || sourceHasStatsCollector(sourceFile)) { for (const node of traverseNodes(sourceFile)) { - if (isMakeUsageCollectorFunction(node, sourceFile)) { + if ( + isMakeUsageCollectorFunction(node, sourceFile) || + isMakeStatsCollectorFunctionWithSchema(node, sourceFile) + ) { try { const collectorDetails = extractCollectorDetails(node, program, sourceFile); yield [relativePath, collectorDetails]; diff --git a/packages/kbn-telemetry-tools/src/tools/utils.ts b/packages/kbn-telemetry-tools/src/tools/utils.ts index 52362668c2f53..c9526fe7d0403 100644 --- a/packages/kbn-telemetry-tools/src/tools/utils.ts +++ b/packages/kbn-telemetry-tools/src/tools/utils.ts @@ -65,7 +65,9 @@ export function getIdentifierDeclarationFromSource(node: ts.Node, source: ts.Sou } const identifierName = node.getText(); - const identifierDefinition: ts.Node = (source as any).locals.get(identifierName); + const identifierDefinition: ts.Node = + (source as any).locals.get(identifierName) || + (source as any).symbol.exports.get(identifierName); if (!identifierDefinition) { throw new Error(`Unable to find identifier in source ${identifierName}`); } diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 6e52245e16bbf..fa7ff3b2d4293 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -16,6 +16,7 @@ interface StartDeps { /** @internal */ export class DocLinksService { public setup() {} + public start({ injectedMetadata }: StartDeps): DocLinksStart { const DOC_LINK_VERSION = injectedMetadata.getKibanaBranch(); const ELASTIC_WEBSITE_URL = 'https://www.elastic.co/'; @@ -110,7 +111,7 @@ export class DocLinksService { runtimeFields: `${ELASTICSEARCH_DOCS}runtime.html`, scriptedFields: { scriptFields: `${ELASTICSEARCH_DOCS}search-request-script-fields.html`, - scriptAggs: `${ELASTICSEARCH_DOCS}search-aggregations.html#_values_source`, + scriptAggs: `${ELASTICSEARCH_DOCS}search-aggregations.html`, painless: `${ELASTICSEARCH_DOCS}modules-scripting-painless.html`, painlessApi: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-api-reference.html`, painlessLangSpec: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, @@ -120,7 +121,6 @@ export class DocLinksService { luceneExpressions: `${ELASTICSEARCH_DOCS}modules-scripting-expression.html`, }, indexPatterns: { - loadingData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/tutorial-load-dataset.html`, introduction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index-patterns.html`, fieldFormattersString: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/field-formatters-string.html`, }, @@ -161,7 +161,16 @@ export class DocLinksService { aggregations: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-aggregation.html`, anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/xpack-ml.html`, anomalyDetectionJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-jobs.html`, + anomalyDetectionConfiguringCategories: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-categories.html`, + anomalyDetectionBucketSpan: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#bucket-span`, + anomalyDetectionCardinality: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#cardinality`, + anomalyDetectionCreateJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html`, + anomalyDetectionDetectors: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#detectors`, + anomalyDetectionInfluencers: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-influencers.html`, + anomalyDetectionJobResource: `${ELASTICSEARCH_DOCS}ml-put-job.html#ml-put-job-path-parms`, + anomalyDetectionJobResourceAnalysisConfig: `${ELASTICSEARCH_DOCS}ml-put-job.html#put-analysisconfig`, anomalyDetectionJobTips: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#job-tips`, + anomalyDetectionModelMemoryLimits: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#model-memory-limits`, calendars: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-calendars.html`, classificationEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-classification`, customRules: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-rules.html`, @@ -192,10 +201,10 @@ export class DocLinksService { emailActionConfig: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html#configuring-email`, generalSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html#general-alert-action-settings`, indexAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index-action-type.html`, - esQuery: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-type-es-query.html`, - indexThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-type-index-threshold.html#index-action-configuration`, + esQuery: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/rule-type-es-query.html`, + indexThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/rule-type-index-threshold.html`, pagerDutyAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/pagerduty-action-type.html`, - preconfiguredConnectors: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/pre-configured-action-types-and-connectors.html`, + preconfiguredConnectors: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/pre-configured-connectors.html`, serviceNowAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/servicenow-action-type.html#configuring-servicenow`, setupPrerequisites: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alerting-getting-started.html#alerting-setup-prerequisites`, slackAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/slack-action-type.html#configuring-slack`, @@ -275,6 +284,11 @@ export class DocLinksService { registerUrl: `${ELASTICSEARCH_DOCS}snapshots-register-repository.html#snapshots-read-only-repository`, restoreSnapshot: `${ELASTICSEARCH_DOCS}snapshots-restore-snapshot.html`, }, + ingest: { + pipelines: `${ELASTICSEARCH_DOCS}ingest.html`, + pipelineFailure: `${ELASTICSEARCH_DOCS}ingest.html#handling-pipeline-failures`, + processors: `${ELASTICSEARCH_DOCS}processors.html`, + }, }, }); } @@ -376,7 +390,6 @@ export interface DocLinksStart { readonly luceneExpressions: string; }; readonly indexPatterns: { - readonly loadingData: string; readonly introduction: string; }; readonly addData: string; @@ -440,5 +453,6 @@ export interface DocLinksStart { readonly ccs: Record; readonly plugins: Record; readonly snapshotRestore: Record; + readonly ingest: Record; }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d79cba5346a73..bf25fcaa75acc 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -570,7 +570,6 @@ export interface DocLinksStart { readonly luceneExpressions: string; }; readonly indexPatterns: { - readonly loadingData: string; readonly introduction: string; }; readonly addData: string; @@ -634,6 +633,7 @@ export interface DocLinksStart { readonly ccs: Record; readonly plugins: Record; readonly snapshotRestore: Record; + readonly ingest: Record; }; } diff --git a/src/core/server/core_app/bundle_routes/bundle_route.test.mocks.ts b/src/core/server/core_app/bundle_routes/bundle_route.test.mocks.ts new file mode 100644 index 0000000000000..c7839f6a26e8b --- /dev/null +++ b/src/core/server/core_app/bundle_routes/bundle_route.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 createDynamicAssetHandlerMock = jest.fn(); +jest.doMock('./dynamic_asset_response', () => ({ + createDynamicAssetHandler: createDynamicAssetHandlerMock, +})); diff --git a/src/core/server/core_app/bundle_routes/bundle_route.test.ts b/src/core/server/core_app/bundle_routes/bundle_route.test.ts new file mode 100644 index 0000000000000..377d8432ae9a9 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/bundle_route.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createDynamicAssetHandlerMock } from './bundle_route.test.mocks'; + +import { httpServiceMock } from '../../http/http_service.mock'; +import { FileHashCache } from './file_hash_cache'; +import { registerRouteForBundle } from './bundles_route'; + +describe('registerRouteForBundle', () => { + let router: ReturnType; + let fileHashCache: FileHashCache; + + beforeEach(() => { + router = httpServiceMock.createRouter(); + fileHashCache = new FileHashCache(); + }); + + afterEach(() => { + createDynamicAssetHandlerMock.mockReset(); + }); + + it('calls `router.get` with the correct parameters', () => { + const handler = jest.fn(); + createDynamicAssetHandlerMock.mockReturnValue(handler); + + registerRouteForBundle(router, { + isDist: false, + publicPath: '/public-path/', + bundlesPath: '/bundle-path', + fileHashCache, + routePath: '/route-path/', + }); + + expect(router.get).toHaveBeenCalledTimes(1); + expect(router.get).toHaveBeenCalledWith( + { + path: '/route-path/{path*}', + options: { + authRequired: false, + }, + validate: expect.any(Object), + }, + handler + ); + }); + + it('calls `createDynamicAssetHandler` with the correct parameters', () => { + registerRouteForBundle(router, { + isDist: false, + publicPath: '/public-path/', + bundlesPath: '/bundle-path', + fileHashCache, + routePath: '/route-path/', + }); + + expect(createDynamicAssetHandlerMock).toHaveBeenCalledTimes(1); + expect(createDynamicAssetHandlerMock).toHaveBeenCalledWith({ + isDist: false, + publicPath: '/public-path/', + bundlesPath: '/bundle-path', + fileHashCache, + }); + }); +}); diff --git a/src/core/server/core_app/bundle_routes/bundles_route.ts b/src/core/server/core_app/bundle_routes/bundles_route.ts new file mode 100644 index 0000000000000..c15babe13a2ce --- /dev/null +++ b/src/core/server/core_app/bundle_routes/bundles_route.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; +import { createDynamicAssetHandler } from './dynamic_asset_response'; +import { FileHashCache } from './file_hash_cache'; + +export function registerRouteForBundle( + router: IRouter, + { + publicPath, + routePath, + bundlesPath, + fileHashCache, + isDist, + }: { + publicPath: string; + routePath: string; + bundlesPath: string; + fileHashCache: FileHashCache; + isDist: boolean; + } +) { + router.get( + { + path: `${routePath}{path*}`, + options: { + authRequired: false, + }, + validate: { + params: schema.object({ + path: schema.string(), + }), + }, + }, + createDynamicAssetHandler({ + publicPath, + bundlesPath, + isDist, + fileHashCache, + }) + ); +} diff --git a/src/core/server/core_app/bundle_routes/dynamic_asset_response.ts b/src/core/server/core_app/bundle_routes/dynamic_asset_response.ts new file mode 100644 index 0000000000000..1ad03608999c7 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/dynamic_asset_response.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createReadStream } from 'fs'; +import { resolve, extname } from 'path'; +import mime from 'mime-types'; +import agent from 'elastic-apm-node'; + +import { fstat, close } from './fs'; +import { RequestHandler } from '../../http'; +import { IFileHashCache } from './file_hash_cache'; +import { getFileHash } from './file_hash'; +import { selectCompressedFile } from './select_compressed_file'; + +const MINUTE = 60; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** + * Serve asset for the requested path. This is designed + * to replicate a subset of the features provided by Hapi's Inert + * plugin including: + * - ensure path is not traversing out of the bundle directory + * - manage use file descriptors for file access to efficiently + * interact with the file multiple times in each request + * - generate and cache etag for the file + * - write correct headers to response for client-side caching + * and invalidation + * - stream file to response + * + * It differs from Inert in some important ways: + * - cached hash/etag is based on the file on disk, but modified + * by the public path so that individual public paths have + * different etags, but can share a cache + */ +export const createDynamicAssetHandler = ({ + bundlesPath, + fileHashCache, + isDist, + publicPath, +}: { + bundlesPath: string; + publicPath: string; + fileHashCache: IFileHashCache; + isDist: boolean; +}): RequestHandler<{ path: string }, {}, {}> => { + return async (ctx, req, res) => { + agent.setTransactionName('GET ?/bundles/?'); + + let fd: number | undefined; + let fileEncoding: 'gzip' | 'br' | undefined; + + try { + const path = resolve(bundlesPath, req.params.path); + + // prevent path traversal, only process paths that resolve within bundlesPath + if (!path.startsWith(bundlesPath)) { + return res.forbidden({ + body: 'EACCES', + }); + } + + // we use and manage a file descriptor mostly because + // that's what Inert does, and since we are accessing + // the file 2 or 3 times per request it seems logical + ({ fd, fileEncoding } = await selectCompressedFile( + req.headers['accept-encoding'] as string, + path + )); + + let headers: Record; + if (isDist) { + headers = { 'cache-control': `max-age=${365 * DAY}` }; + } else { + const stat = await fstat(fd); + const hash = await getFileHash(fileHashCache, path, stat, fd); + headers = { + etag: `${hash}-${publicPath}`, + 'cache-control': 'must-revalidate', + }; + } + + // If we manually selected a compressed file, specify the encoding header. + // Otherwise, let Hapi automatically gzip the response. + if (fileEncoding) { + headers['content-encoding'] = fileEncoding; + } + + const fileExt = extname(path); + const contentType = mime.lookup(fileExt); + const mediaType = mime.contentType(contentType || fileExt); + headers['content-type'] = mediaType || ''; + + const content = createReadStream(null as any, { + fd, + start: 0, + autoClose: true, + }); + + return res.ok({ + body: content, + headers, + }); + } catch (error) { + if (fd) { + try { + await close(fd); + } catch (_) { + // ignore errors from close, we already have one to report + // and it's very likely they are the same + } + } + if (error.code === 'ENOENT') { + return res.notFound(); + } + throw error; + } + }; +}; diff --git a/src/core/server/core_app/bundle_routes/file_hash.test.mocks.ts b/src/core/server/core_app/bundle_routes/file_hash.test.mocks.ts new file mode 100644 index 0000000000000..d7f6812ba5d29 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/file_hash.test.mocks.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const generateFileHashMock = jest.fn(); +export const getFileCacheKeyMock = jest.fn(); + +jest.doMock('./utils', () => ({ + generateFileHash: generateFileHashMock, + getFileCacheKey: getFileCacheKeyMock, +})); diff --git a/src/core/server/core_app/bundle_routes/file_hash.test.ts b/src/core/server/core_app/bundle_routes/file_hash.test.ts new file mode 100644 index 0000000000000..918f435156344 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/file_hash.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 { generateFileHashMock, getFileCacheKeyMock } from './file_hash.test.mocks'; + +import { resolve } from 'path'; +import { Stats } from 'fs'; +import { getFileHash } from './file_hash'; +import { IFileHashCache } from './file_hash_cache'; + +const mockedCache = (): jest.Mocked => ({ + del: jest.fn(), + get: jest.fn(), + set: jest.fn(), +}); + +describe('getFileHash', () => { + const sampleFilePath = resolve(__dirname, 'foo.js'); + const fd = 42; + const stats: Stats = { ino: 42, size: 9000 } as any; + + beforeEach(() => { + getFileCacheKeyMock.mockImplementation((path: string, stat: Stats) => `${path}-${stat.ino}`); + }); + + afterEach(() => { + generateFileHashMock.mockReset(); + getFileCacheKeyMock.mockReset(); + }); + + it('returns the value from cache if present', async () => { + const cache = mockedCache(); + cache.get.mockReturnValue(Promise.resolve('cached-hash')); + + const hash = await getFileHash(cache, sampleFilePath, stats, fd); + + expect(cache.get).toHaveBeenCalledTimes(1); + expect(generateFileHashMock).not.toHaveBeenCalled(); + expect(hash).toEqual('cached-hash'); + }); + + it('computes the value if not present in cache', async () => { + const cache = mockedCache(); + cache.get.mockReturnValue(undefined); + + generateFileHashMock.mockReturnValue(Promise.resolve('computed-hash')); + + const hash = await getFileHash(cache, sampleFilePath, stats, fd); + + expect(generateFileHashMock).toHaveBeenCalledTimes(1); + expect(generateFileHashMock).toHaveBeenCalledWith(fd); + expect(hash).toEqual('computed-hash'); + }); + + it('sets the value in the cache if not present', async () => { + const computedHashPromise = Promise.resolve('computed-hash'); + generateFileHashMock.mockReturnValue(computedHashPromise); + + const cache = mockedCache(); + cache.get.mockReturnValue(undefined); + + await getFileHash(cache, sampleFilePath, stats, fd); + + expect(cache.set).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith(`${sampleFilePath}-${stats.ino}`, computedHashPromise); + }); +}); diff --git a/src/core/server/core_app/bundle_routes/file_hash.ts b/src/core/server/core_app/bundle_routes/file_hash.ts new file mode 100644 index 0000000000000..e309873254999 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/file_hash.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Stats } from 'fs'; +import { generateFileHash, getFileCacheKey } from './utils'; +import { IFileHashCache } from './file_hash_cache'; + +/** + * Get the hash of a file via a file descriptor + */ +export async function getFileHash(cache: IFileHashCache, path: string, stat: Stats, fd: number) { + const key = getFileCacheKey(path, stat); + + const cached = cache.get(key); + if (cached) { + return await cached; + } + + const promise = generateFileHash(fd).catch((error) => { + // don't cache failed attempts + cache.del(key); + throw error; + }); + + cache.set(key, promise); + return await promise; +} diff --git a/src/core/server/core_app/bundle_routes/file_hash_cache.test.ts b/src/core/server/core_app/bundle_routes/file_hash_cache.test.ts new file mode 100644 index 0000000000000..fb519c660e637 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/file_hash_cache.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FileHashCache } from './file_hash_cache'; + +describe('FileHashCache', () => { + it('returns the value stored', async () => { + const cache = new FileHashCache(); + cache.set('foo', Promise.resolve('bar')); + expect(await cache.get('foo')).toEqual('bar'); + }); + + it('can manually delete values', () => { + const cache = new FileHashCache(); + cache.set('foo', Promise.resolve('bar')); + cache.del('foo'); + expect(cache.get('foo')).toBeUndefined(); + }); + + it('only preserves a given amount of entries', async () => { + const cache = new FileHashCache(1); + cache.set('foo', Promise.resolve('bar')); + cache.set('hello', Promise.resolve('dolly')); + + expect(await cache.get('hello')).toEqual('dolly'); + expect(cache.get('foo')).toBeUndefined(); + }); +}); diff --git a/src/optimize/bundles_route/file_hash_cache.ts b/src/core/server/core_app/bundle_routes/file_hash_cache.ts similarity index 59% rename from src/optimize/bundles_route/file_hash_cache.ts rename to src/core/server/core_app/bundle_routes/file_hash_cache.ts index 9d288ccb77194..8242a5b595d60 100644 --- a/src/optimize/bundles_route/file_hash_cache.ts +++ b/src/core/server/core_app/bundle_routes/file_hash_cache.ts @@ -8,8 +8,22 @@ import LruCache from 'lru-cache'; -export class FileHashCache { - private lru = new LruCache>(100); +/** @internal */ +export interface IFileHashCache { + get(key: string): Promise | undefined; + + set(key: string, value: Promise): void; + + del(key: string): void; +} + +/** @internal */ +export class FileHashCache implements IFileHashCache { + private lru: LruCache>; + + constructor(maxSize: number = 250) { + this.lru = new LruCache(maxSize); + } get(key: string) { return this.lru.get(key); diff --git a/src/core/server/core_app/bundle_routes/fs.ts b/src/core/server/core_app/bundle_routes/fs.ts new file mode 100644 index 0000000000000..913b5c8423553 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/fs.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// can't use fs/promises when working with streams using file descriptors +// see https://github.com/nodejs/node/issues/35862 + +import Fs from 'fs'; +import { promisify } from 'util'; + +export const open = promisify(Fs.open); +export const close = promisify(Fs.close); +export const fstat = promisify(Fs.fstat); diff --git a/src/optimize/jest.config.js b/src/core/server/core_app/bundle_routes/index.ts similarity index 77% rename from src/optimize/jest.config.js rename to src/core/server/core_app/bundle_routes/index.ts index 8469778d775a2..5b2374a74356a 100644 --- a/src/optimize/jest.config.js +++ b/src/core/server/core_app/bundle_routes/index.ts @@ -6,8 +6,4 @@ * Side Public License, v 1. */ -module.exports = { - preset: '@kbn/test', - rootDir: '../..', - roots: ['/src/optimize'], -}; +export { registerBundleRoutes } from './register_bundle_routes'; diff --git a/src/core/server/core_app/bundle_routes/register_bundle_routes.test.mocks.ts b/src/core/server/core_app/bundle_routes/register_bundle_routes.test.mocks.ts new file mode 100644 index 0000000000000..9c93f5d403c33 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/register_bundle_routes.test.mocks.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const registerRouteForBundleMock = jest.fn(); +jest.doMock('./bundles_route', () => ({ + registerRouteForBundle: registerRouteForBundleMock, +})); + +jest.doMock('@kbn/ui-shared-deps', () => ({ + distDir: 'uiSharedDepsDistDir', +})); diff --git a/src/core/server/core_app/bundle_routes/register_bundle_routes.test.ts b/src/core/server/core_app/bundle_routes/register_bundle_routes.test.ts new file mode 100644 index 0000000000000..d51c369146957 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/register_bundle_routes.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { registerRouteForBundleMock } from './register_bundle_routes.test.mocks'; + +import { PackageInfo } from '@kbn/config'; +import { httpServiceMock } from '../../http/http_service.mock'; +import { UiPlugins } from '../../plugins'; +import { registerBundleRoutes } from './register_bundle_routes'; +import { FileHashCache } from './file_hash_cache'; + +const createPackageInfo = (parts: Partial = {}): PackageInfo => ({ + ...parts, + buildNum: 42, + buildSha: 'sha', + dist: true, + branch: 'master', + version: '8.0.0', +}); + +const createUiPlugins = (...ids: string[]): UiPlugins => ({ + browserConfigs: new Map(), + public: new Map(), + internal: ids.reduce((map, id) => { + map.set(id, { + publicTargetDir: `/plugins/${id}/public-target-dir`, + }); + return map; + }, new Map()), +}); + +describe('registerBundleRoutes', () => { + let router: ReturnType; + + beforeEach(() => { + router = httpServiceMock.createRouter(); + }); + + afterEach(() => { + registerRouteForBundleMock.mockReset(); + }); + + it('registers core and shared-dep bundles', () => { + registerBundleRoutes({ + router, + serverBasePath: '/server-base-path', + packageInfo: createPackageInfo(), + uiPlugins: createUiPlugins(), + }); + + expect(registerRouteForBundleMock).toHaveBeenCalledTimes(2); + + expect(registerRouteForBundleMock).toHaveBeenCalledWith(router, { + fileHashCache: expect.any(FileHashCache), + isDist: true, + bundlesPath: 'uiSharedDepsDistDir', + publicPath: '/server-base-path/42/bundles/kbn-ui-shared-deps/', + routePath: '/42/bundles/kbn-ui-shared-deps/', + }); + + expect(registerRouteForBundleMock).toHaveBeenCalledWith(router, { + fileHashCache: expect.any(FileHashCache), + isDist: true, + bundlesPath: expect.stringMatching(/src\/core\/target\/public/), + publicPath: '/server-base-path/42/bundles/core/', + routePath: '/42/bundles/core/', + }); + }); + + it('registers plugin bundles', () => { + registerBundleRoutes({ + router, + serverBasePath: '/server-base-path', + packageInfo: createPackageInfo(), + uiPlugins: createUiPlugins('plugin-a', 'plugin-b'), + }); + + expect(registerRouteForBundleMock).toHaveBeenCalledTimes(4); + + expect(registerRouteForBundleMock).toHaveBeenCalledWith(router, { + fileHashCache: expect.any(FileHashCache), + isDist: true, + bundlesPath: '/plugins/plugin-a/public-target-dir', + publicPath: '/server-base-path/42/bundles/plugin/plugin-a/', + routePath: '/42/bundles/plugin/plugin-a/', + }); + + expect(registerRouteForBundleMock).toHaveBeenCalledWith(router, { + fileHashCache: expect.any(FileHashCache), + isDist: true, + bundlesPath: '/plugins/plugin-b/public-target-dir', + publicPath: '/server-base-path/42/bundles/plugin/plugin-b/', + routePath: '/42/bundles/plugin/plugin-b/', + }); + }); +}); diff --git a/src/core/server/core_app/bundle_routes/register_bundle_routes.ts b/src/core/server/core_app/bundle_routes/register_bundle_routes.ts new file mode 100644 index 0000000000000..ee54f8ef34622 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/register_bundle_routes.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { join } from 'path'; +import { PackageInfo } from '@kbn/config'; +import { distDir as uiSharedDepsDistDir } from '@kbn/ui-shared-deps'; +import { IRouter } from '../../http'; +import { UiPlugins } from '../../plugins'; +import { fromRoot } from '../../utils'; +import { FileHashCache } from './file_hash_cache'; +import { registerRouteForBundle } from './bundles_route'; + +/** + * Creates the routes that serves files from `bundlesPath`. + * + * @param {Object} options + * @property {Array<{id,path}>} options.npUiPluginPublicDirs array of ids and paths that should be served for new platform plugins + * @property {string} options.regularBundlesPath + * @property {string} options.basePublicPath + * + * @return Array.of({Hapi.Route}) + */ +export function registerBundleRoutes({ + router, + serverBasePath, // serverBasePath + uiPlugins, + packageInfo, +}: { + router: IRouter; + serverBasePath: string; + uiPlugins: UiPlugins; + packageInfo: PackageInfo; +}) { + const { dist: isDist, buildNum } = packageInfo; + // rather than calculate the fileHash on every request, we + // provide a cache object to `resolveDynamicAssetResponse()` that + // will store the most recently used hashes. + const fileHashCache = new FileHashCache(); + + registerRouteForBundle(router, { + publicPath: `${serverBasePath}/${buildNum}/bundles/kbn-ui-shared-deps/`, + routePath: `/${buildNum}/bundles/kbn-ui-shared-deps/`, + bundlesPath: uiSharedDepsDistDir, + fileHashCache, + isDist, + }); + registerRouteForBundle(router, { + publicPath: `${serverBasePath}/${buildNum}/bundles/core/`, + routePath: `/${buildNum}/bundles/core/`, + bundlesPath: fromRoot(join('src', 'core', 'target', 'public')), + fileHashCache, + isDist, + }); + + [...uiPlugins.internal.entries()].forEach(([id, { publicTargetDir }]) => { + registerRouteForBundle(router, { + publicPath: `${serverBasePath}/${buildNum}/bundles/plugin/${id}/`, + routePath: `/${buildNum}/bundles/plugin/${id}/`, + bundlesPath: publicTargetDir, + fileHashCache, + isDist, + }); + }); +} diff --git a/src/core/server/core_app/bundle_routes/select_compressed_file.ts b/src/core/server/core_app/bundle_routes/select_compressed_file.ts new file mode 100644 index 0000000000000..c7b071a9c3548 --- /dev/null +++ b/src/core/server/core_app/bundle_routes/select_compressed_file.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 { extname } from 'path'; +import Accept from 'accept'; +import { open } from './fs'; + +declare module 'accept' { + // @types/accept does not include the `preferences` argument so we override the type to include it + export function encodings(encodingHeader?: string, preferences?: string[]): string[]; +} + +async function tryToOpenFile(filePath: string) { + try { + return await open(filePath, 'r'); + } catch (e) { + if (e.code === 'ENOENT') { + return undefined; + } else { + throw e; + } + } +} + +export async function selectCompressedFile(acceptEncodingHeader: string | undefined, path: string) { + let fd: number | undefined; + let fileEncoding: 'gzip' | 'br' | undefined; + const ext = extname(path); + + const supportedEncodings = Accept.encodings(acceptEncodingHeader, ['br', 'gzip']); + + // do not bother trying to look compressed versions for anything else than js or css files + if (ext === '.js' || ext === '.css') { + if (supportedEncodings[0] === 'br') { + fileEncoding = 'br'; + fd = await tryToOpenFile(`${path}.br`); + } + if (!fd && supportedEncodings.includes('gzip')) { + fileEncoding = 'gzip'; + fd = await tryToOpenFile(`${path}.gz`); + } + } + + if (!fd) { + fileEncoding = undefined; + // Use raw open to trigger exception if it does not exist + fd = await open(path, 'r'); + } + + return { fd, fileEncoding }; +} diff --git a/src/optimize/bundles_route/file_hash.ts b/src/core/server/core_app/bundle_routes/utils.ts similarity index 51% rename from src/optimize/bundles_route/file_hash.ts rename to src/core/server/core_app/bundle_routes/utils.ts index 1f5b1a979407c..a2adefcfa73c2 100644 --- a/src/optimize/bundles_route/file_hash.ts +++ b/src/core/server/core_app/bundle_routes/utils.ts @@ -6,33 +6,19 @@ * Side Public License, v 1. */ +import { createReadStream, Stats } from 'fs'; import { createHash } from 'crypto'; -import Fs from 'fs'; - import * as Rx from 'rxjs'; -import { takeUntil, map } from 'rxjs/operators'; - -import { FileHashCache } from './file_hash_cache'; - -/** - * Get the hash of a file via a file descriptor - */ -export async function getFileHash(cache: FileHashCache, path: string, stat: Fs.Stats, fd: number) { - const key = `${path}:${stat.ino}:${stat.size}:${stat.mtime.getTime()}`; - - const cached = cache.get(key); - if (cached) { - return await cached; - } +import { map, takeUntil } from 'rxjs/operators'; +export const generateFileHash = (fd: number): Promise => { const hash = createHash('sha1'); - const read = Fs.createReadStream(null as any, { + const read = createReadStream(null as any, { fd, start: 0, autoClose: false, }); - - const promise = Rx.merge( + return Rx.merge( Rx.fromEvent(read, 'data'), Rx.fromEvent(read, 'error').pipe( map((error) => { @@ -42,13 +28,8 @@ export async function getFileHash(cache: FileHashCache, path: string, stat: Fs.S ) .pipe(takeUntil(Rx.fromEvent(read, 'end'))) .forEach((chunk) => hash.update(chunk)) - .then(() => hash.digest('hex')) - .catch((error) => { - // don't cache failed attempts - cache.del(key); - throw error; - }); + .then(() => hash.digest('hex')); +}; - cache.set(key, promise); - return await promise; -} +export const getFileCacheKey = (path: string, stat: Stats) => + `${path}:${stat.ino}:${stat.size}:${stat.mtime.getTime()}`; diff --git a/src/core/server/core_app/core_app.test.mocks.ts b/src/core/server/core_app/core_app.test.mocks.ts new file mode 100644 index 0000000000000..d45df8dd52d71 --- /dev/null +++ b/src/core/server/core_app/core_app.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 registerBundleRoutesMock = jest.fn(); +jest.doMock('./bundle_routes', () => ({ + registerBundleRoutes: registerBundleRoutesMock, +})); diff --git a/src/core/server/core_app/core_app.test.ts b/src/core/server/core_app/core_app.test.ts index e08a8e0be0a41..ad7af3ac8b84d 100644 --- a/src/core/server/core_app/core_app.test.ts +++ b/src/core/server/core_app/core_app.test.ts @@ -6,28 +6,42 @@ * Side Public License, v 1. */ +import { registerBundleRoutesMock } from './core_app.test.mocks'; + import { mockCoreContext } from '../core_context.mock'; import { coreMock } from '../mocks'; import { httpResourcesMock } from '../http_resources/http_resources_service.mock'; +import type { UiPlugins } from '../plugins'; import { CoreApp } from './core_app'; +const emptyPlugins = (): UiPlugins => ({ + internal: new Map(), + public: new Map(), + browserConfigs: new Map(), +}); + describe('CoreApp', () => { + let coreContext: ReturnType; let coreApp: CoreApp; let internalCoreSetup: ReturnType; let httpResourcesRegistrar: ReturnType; beforeEach(() => { - const coreContext = mockCoreContext.create(); + coreContext = mockCoreContext.create(); internalCoreSetup = coreMock.createInternalSetup(); httpResourcesRegistrar = httpResourcesMock.createRegistrar(); internalCoreSetup.httpResources.createRegistrar.mockReturnValue(httpResourcesRegistrar); coreApp = new CoreApp(coreContext); }); + afterEach(() => { + registerBundleRoutesMock.mockReset(); + }); + describe('`/status` route', () => { it('is registered with `authRequired: false` is the status page is anonymous', () => { internalCoreSetup.status.isStatusPageAnonymous.mockReturnValue(true); - coreApp.setup(internalCoreSetup); + coreApp.setup(internalCoreSetup, emptyPlugins()); expect(httpResourcesRegistrar.register).toHaveBeenCalledWith( { @@ -43,7 +57,7 @@ describe('CoreApp', () => { it('is registered with `authRequired: true` is the status page is not anonymous', () => { internalCoreSetup.status.isStatusPageAnonymous.mockReturnValue(false); - coreApp.setup(internalCoreSetup); + coreApp.setup(internalCoreSetup, emptyPlugins()); expect(httpResourcesRegistrar.register).toHaveBeenCalledWith( { @@ -60,7 +74,7 @@ describe('CoreApp', () => { describe('`/app/{id}/{any*}` route', () => { it('is registered with the correct parameters', () => { - coreApp.setup(internalCoreSetup); + coreApp.setup(internalCoreSetup, emptyPlugins()); expect(httpResourcesRegistrar.register).toHaveBeenCalledWith( { @@ -74,4 +88,17 @@ describe('CoreApp', () => { ); }); }); + + it('calls `registerBundleRoutes` with the correct options', () => { + const uiPlugins = emptyPlugins(); + coreApp.setup(internalCoreSetup, uiPlugins); + + expect(registerBundleRoutesMock).toHaveBeenCalledTimes(1); + expect(registerBundleRoutesMock).toHaveBeenCalledWith({ + uiPlugins, + router: expect.any(Object), + packageInfo: coreContext.env.packageInfo, + serverBasePath: internalCoreSetup.http.basePath.serverBasePath, + }); + }); }); diff --git a/src/core/server/core_app/core_app.ts b/src/core/server/core_app/core_app.ts index 24ddc305d8232..dac941767ebb5 100644 --- a/src/core/server/core_app/core_app.ts +++ b/src/core/server/core_app/core_app.ts @@ -7,27 +7,32 @@ */ import Path from 'path'; -import { fromRoot } from '../../../core/server/utils'; +import { Env } from '@kbn/config'; +import { fromRoot } from '../utils'; import { InternalCoreSetup } from '../internal_types'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; +import { registerBundleRoutes } from './bundle_routes'; +import { UiPlugins } from '../plugins'; /** @internal */ export class CoreApp { private readonly logger: Logger; + private readonly env: Env; constructor(core: CoreContext) { this.logger = core.logger.get('core-app'); + this.env = core.env; } - setup(coreSetup: InternalCoreSetup) { + setup(coreSetup: InternalCoreSetup, uiPlugins: UiPlugins) { this.logger.debug('Setting up core app.'); - this.registerDefaultRoutes(coreSetup); + this.registerDefaultRoutes(coreSetup, uiPlugins); this.registerStaticDirs(coreSetup); } - private registerDefaultRoutes(coreSetup: InternalCoreSetup) { + private registerDefaultRoutes(coreSetup: InternalCoreSetup, uiPlugins: UiPlugins) { const httpSetup = coreSetup.http; const router = httpSetup.createRouter(''); const resources = coreSetup.httpResources.createRegistrar(router); @@ -48,6 +53,13 @@ export class CoreApp { res.ok({ body: { version: '0.0.1' } }) ); + registerBundleRoutes({ + router, + uiPlugins, + packageInfo: this.env.packageInfo, + serverBasePath: coreSetup.http.basePath.serverBasePath, + }); + resources.register( { path: '/app/{id}/{any*}', diff --git a/src/optimize/bundles_route/__fixtures__/outside_output.js b/src/core/server/core_app/integration_tests/__fixtures__/outside_output.js similarity index 100% rename from src/optimize/bundles_route/__fixtures__/outside_output.js rename to src/core/server/core_app/integration_tests/__fixtures__/outside_output.js diff --git a/src/optimize/index.ts b/src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/gzip_chunk.js similarity index 87% rename from src/optimize/index.ts rename to src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/gzip_chunk.js index 3073c62d55b40..ca84988e8f978 100644 --- a/src/optimize/index.ts +++ b/src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/gzip_chunk.js @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { optimizeMixin } from './optimize_mixin'; +module.exports = 'GZIP-CHUNK'; diff --git a/src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/gzip_chunk.js.gz b/src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/gzip_chunk.js.gz new file mode 100644 index 0000000000000..fbf388e74ee70 Binary files /dev/null and b/src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/gzip_chunk.js.gz differ diff --git a/src/optimize/bundles_route/__fixtures__/plugin/foo/image.png b/src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/image.png similarity index 100% rename from src/optimize/bundles_route/__fixtures__/plugin/foo/image.png rename to src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/image.png diff --git a/src/optimize/bundles_route/__fixtures__/plugin/foo/plugin.js b/src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/plugin.js similarity index 100% rename from src/optimize/bundles_route/__fixtures__/plugin/foo/plugin.js rename to src/core/server/core_app/integration_tests/__fixtures__/plugin/foo/plugin.js diff --git a/src/core/server/core_app/integration_tests/bundle_routes.test.ts b/src/core/server/core_app/integration_tests/bundle_routes.test.ts new file mode 100644 index 0000000000000..fbe2e9285ba29 --- /dev/null +++ b/src/core/server/core_app/integration_tests/bundle_routes.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { resolve } from 'path'; +import { readFile } from 'fs/promises'; +import supertest from 'supertest'; +import { contextServiceMock } from '../../context/context_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; +import { HttpService, IRouter } from '../../http'; +import { createHttpServer } from '../../http/test_utils'; +import { registerRouteForBundle } from '../bundle_routes/bundles_route'; +import { FileHashCache } from '../bundle_routes/file_hash_cache'; + +const buildNum = 1234; +const fooPluginFixture = resolve(__dirname, './__fixtures__/plugin/foo'); + +describe('bundle routes', () => { + let server: HttpService; + let contextSetup: ReturnType; + let logger: ReturnType; + let fileHashCache: FileHashCache; + + beforeEach(() => { + contextSetup = contextServiceMock.createSetupContract(); + logger = loggingSystemMock.create(); + fileHashCache = new FileHashCache(); + + server = createHttpServer({ logger }); + }); + + afterEach(async () => { + await server.stop(); + }); + + const registerFooPluginRoute = ( + router: IRouter, + { isDist = false }: { isDist?: boolean } = {} + ) => { + registerRouteForBundle(router, { + isDist, + fileHashCache, + bundlesPath: fooPluginFixture, + routePath: `/${buildNum}/bundles/plugin/foo/`, + publicPath: `/${buildNum}/bundles/plugin/foo/`, + }); + }; + + it('serves images inside from the bundle path', async () => { + const { server: innerServer, createRouter } = await server.setup({ + context: contextSetup, + }); + + registerFooPluginRoute(createRouter('')); + await server.start(); + + const response = await supertest(innerServer.listener) + .get(`/${buildNum}/bundles/plugin/foo/image.png`) + .expect(200); + + const actualImage = await readFile(resolve(fooPluginFixture, 'image.png')); + expect(response.get('content-type')).toEqual('image/png'); + expect(response.body).toEqual(actualImage); + }); + + it('serves uncompressed js files', async () => { + const { server: innerServer, createRouter } = await server.setup({ + context: contextSetup, + }); + + registerFooPluginRoute(createRouter('')); + await server.start(); + + const response = await supertest(innerServer.listener) + .get(`/${buildNum}/bundles/plugin/foo/plugin.js`) + .expect(200); + + const actualFile = await readFile(resolve(fooPluginFixture, 'plugin.js')); + expect(response.get('content-type')).toEqual('application/javascript; charset=utf-8'); + expect(actualFile.toString('utf8')).toEqual(response.text); + }); + + it('returns 404 for files outside of the bundlePath', async () => { + const { server: innerServer, createRouter } = await server.setup({ + context: contextSetup, + }); + + registerFooPluginRoute(createRouter('')); + await server.start(); + + await supertest(innerServer.listener) + .get(`/${buildNum}/bundles/plugin/foo/../outside_output.js`) + .expect(404); + }); + + it('returns 404 for non-existing files', async () => { + const { server: innerServer, createRouter } = await server.setup({ + context: contextSetup, + }); + + registerFooPluginRoute(createRouter('')); + await server.start(); + + await supertest(innerServer.listener) + .get(`/${buildNum}/bundles/plugin/foo/missing.js`) + .expect(404); + }); + + it('returns gzip version if present', async () => { + const { server: innerServer, createRouter } = await server.setup({ + context: contextSetup, + }); + + registerFooPluginRoute(createRouter('')); + await server.start(); + + const response = await supertest(innerServer.listener) + .get(`/${buildNum}/bundles/plugin/foo/gzip_chunk.js`) + .expect(200); + + expect(response.get('content-encoding')).toEqual('gzip'); + expect(response.get('content-type')).toEqual('application/javascript; charset=utf-8'); + + const actualFile = await readFile(resolve(fooPluginFixture, 'gzip_chunk.js')); + expect(actualFile.toString('utf8')).toEqual(response.text); + }); + + // supertest does not support brotli compression, cannot test + // this is covered in FTR tests anyway + it.skip('returns br version if present', () => {}); + + describe('in production mode', () => { + it('uses max-age cache-control', async () => { + const { server: innerServer, createRouter } = await server.setup({ + context: contextSetup, + }); + + registerFooPluginRoute(createRouter(''), { isDist: true }); + await server.start(); + + const response = await supertest(innerServer.listener) + .get(`/${buildNum}/bundles/plugin/foo/gzip_chunk.js`) + .expect(200); + + expect(response.get('cache-control')).toEqual('max-age=31536000'); + expect(response.get('etag')).toBeUndefined(); + }); + }); + + describe('in development mode', () => { + it('uses etag cache-control', async () => { + const { server: innerServer, createRouter } = await server.setup({ + context: contextSetup, + }); + + registerFooPluginRoute(createRouter(''), { isDist: false }); + await server.start(); + + const response = await supertest(innerServer.listener) + .get(`/${buildNum}/bundles/plugin/foo/gzip_chunk.js`) + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).toBeDefined(); + }); + }); +}); diff --git a/src/core/server/rendering/bootstrap/bootstrap_renderer.test.ts b/src/core/server/rendering/bootstrap/bootstrap_renderer.test.ts index 3803d38a968c1..36551def5eef0 100644 --- a/src/core/server/rendering/bootstrap/bootstrap_renderer.test.ts +++ b/src/core/server/rendering/bootstrap/bootstrap_renderer.test.ts @@ -180,7 +180,7 @@ describe('bootstrapRenderer', () => { expect(getThemeTagMock).toHaveBeenCalledTimes(1); expect(getThemeTagMock).toHaveBeenCalledWith({ - themeVersion: 'v7', + themeVersion: 'v8', darkMode: false, }); }); diff --git a/src/core/server/rendering/bootstrap/bootstrap_renderer.ts b/src/core/server/rendering/bootstrap/bootstrap_renderer.ts index cff593e5c5aa9..edc0f4f0a2203 100644 --- a/src/core/server/rendering/bootstrap/bootstrap_renderer.ts +++ b/src/core/server/rendering/bootstrap/bootstrap_renderer.ts @@ -50,12 +50,12 @@ export const bootstrapRendererFactory: BootstrapRendererFactory = ({ return async function bootstrapRenderer({ uiSettingsClient, request }) { let darkMode = false; - let themeVersion = 'v7'; + let themeVersion = 'v8'; try { const authenticated = isAuthenticated(request); darkMode = authenticated ? await uiSettingsClient.get('theme:darkMode') : false; - themeVersion = authenticated ? await uiSettingsClient.get('theme:version') : 'v7'; + themeVersion = authenticated ? await uiSettingsClient.get('theme:version') : 'v8'; } catch (e) { // just use the default values in case of connectivity issues with ES } diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 337dfa8824303..ef5164a8c48e1 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -221,7 +221,7 @@ export class Server { }); this.registerCoreContext(coreSetup); - this.coreApp.setup(coreSetup); + this.coreApp.setup(coreSetup, uiPlugins); setupTransaction?.end(); return coreSetup; diff --git a/src/core/server/ui_settings/settings/theme.test.ts b/src/core/server/ui_settings/settings/theme.test.ts index 5c66712b6a4ba..f0ca4f1eff4cd 100644 --- a/src/core/server/ui_settings/settings/theme.test.ts +++ b/src/core/server/ui_settings/settings/theme.test.ts @@ -35,11 +35,11 @@ describe('theme settings', () => { it('should only accept valid values', () => { expect(() => validate('v7')).not.toThrow(); - expect(() => validate('v8 (beta)')).not.toThrow(); + expect(() => validate('v8')).not.toThrow(); expect(() => validate('v12')).toThrowErrorMatchingInlineSnapshot(` "types that failed validation: - [0]: expected value to equal [v7] -- [1]: expected value to equal [v8 (beta)]" +- [1]: expected value to equal [v8]" `); }); }); diff --git a/src/core/server/ui_settings/settings/theme.ts b/src/core/server/ui_settings/settings/theme.ts index 1c2f8417600df..35b8f0217c114 100644 --- a/src/core/server/ui_settings/settings/theme.ts +++ b/src/core/server/ui_settings/settings/theme.ts @@ -27,14 +27,14 @@ export const getThemeSettings = (): Record => { name: i18n.translate('core.ui_settings.params.themeVersionTitle', { defaultMessage: 'Theme version', }), - value: 'v7', + value: 'v8', type: 'select', - options: ['v7', 'v8 (beta)'], + options: ['v7', 'v8'], description: i18n.translate('core.ui_settings.params.themeVersionText', { defaultMessage: `Switch between the theme used for the current and next version of Kibana. A page refresh is required for the setting to be applied.`, }), requiresPageReload: true, - schema: schema.oneOf([schema.literal('v7'), schema.literal('v8 (beta)')]), + schema: schema.oneOf([schema.literal('v7'), schema.literal('v8')]), }, }; }; diff --git a/src/dev/build/tasks/build_kibana_platform_plugins.ts b/src/dev/build/tasks/build_kibana_platform_plugins.ts index 973d71043f028..edff77d458f0f 100644 --- a/src/dev/build/tasks/build_kibana_platform_plugins.ts +++ b/src/dev/build/tasks/build_kibana_platform_plugins.ts @@ -11,7 +11,12 @@ import Path from 'path'; import { REPO_ROOT } from '@kbn/utils'; import { lastValueFrom } from '@kbn/std'; import { CiStatsMetric } from '@kbn/dev-utils'; -import { runOptimizer, OptimizerConfig, logOptimizerState } from '@kbn/optimizer'; +import { + runOptimizer, + OptimizerConfig, + logOptimizerState, + reportOptimizerTimings, +} from '@kbn/optimizer'; import { Task, deleteAll, write, read } from '../lib'; @@ -30,7 +35,9 @@ export const BuildKibanaPlatformPlugins: Task = { limitsPath: Path.resolve(REPO_ROOT, 'packages/kbn-optimizer/limits.yml'), }); - await lastValueFrom(runOptimizer(config).pipe(logOptimizerState(log, config))); + await lastValueFrom( + runOptimizer(config).pipe(logOptimizerState(log, config), reportOptimizerTimings(log, config)) + ); const combinedMetrics: CiStatsMetric[] = []; const metricFilePaths: string[] = []; diff --git a/src/dev/cli_dev_mode/get_server_watch_paths.test.ts b/src/dev/cli_dev_mode/get_server_watch_paths.test.ts index 7f84338b4efb8..ab113b96a5f03 100644 --- a/src/dev/cli_dev_mode/get_server_watch_paths.test.ts +++ b/src/dev/cli_dev_mode/get_server_watch_paths.test.ts @@ -28,7 +28,6 @@ it('produces the right watch and ignore list', () => { Array [ /src/core, /src/legacy/server, - /src/legacy/ui, /src/legacy/utils, /config, /x-pack/test/plugin_functional/plugins/resolver_test, diff --git a/src/dev/cli_dev_mode/get_server_watch_paths.ts b/src/dev/cli_dev_mode/get_server_watch_paths.ts index 4e00dd4ca98b9..46aa15659a513 100644 --- a/src/dev/cli_dev_mode/get_server_watch_paths.ts +++ b/src/dev/cli_dev_mode/get_server_watch_paths.ts @@ -41,7 +41,6 @@ export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) { [ fromRoot('src/core'), fromRoot('src/legacy/server'), - fromRoot('src/legacy/ui'), fromRoot('src/legacy/utils'), fromRoot('config'), ...pluginPaths, diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index f1a3737747573..e0f0432c61463 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -22,4 +22,5 @@ export const storybookAliases = { ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', observability: 'x-pack/plugins/observability/.storybook', presentation: 'src/plugins/presentation_util/storybook', + lists: 'x-pack/plugins/lists/.storybook', }; diff --git a/src/fixtures/telemetry_collectors/imported_interface_from_export/index.ts b/src/fixtures/telemetry_collectors/imported_interface_from_export/index.ts new file mode 100644 index 0000000000000..095ee9e8f6091 --- /dev/null +++ b/src/fixtures/telemetry_collectors/imported_interface_from_export/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CollectorSet } from '../../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../../core/server/logging/logger.mock'; +import type { Usage } from './types'; + +const { makeUsageCollector } = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +export const myCollector = makeUsageCollector({ + type: 'importing_from_export_collector', + isReady: () => true, + fetch() { + return { + some_field: 'abc', + }; + }, + schema: { + some_field: { + type: 'keyword', + }, + }, +}); diff --git a/src/optimize/bundles_route/index.ts b/src/fixtures/telemetry_collectors/imported_interface_from_export/types.ts similarity index 74% rename from src/optimize/bundles_route/index.ts rename to src/fixtures/telemetry_collectors/imported_interface_from_export/types.ts index 086bce552c5d0..c8dd38f414406 100644 --- a/src/optimize/bundles_route/index.ts +++ b/src/fixtures/telemetry_collectors/imported_interface_from_export/types.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export { createBundlesRoute } from './bundles_route'; -export { createProxyBundlesRoute } from './proxy_bundles_route'; +export type { Usage } from './usage_type'; diff --git a/src/fixtures/telemetry_collectors/imported_interface_from_export/usage_type.ts b/src/fixtures/telemetry_collectors/imported_interface_from_export/usage_type.ts new file mode 100644 index 0000000000000..765b8901a83e1 --- /dev/null +++ b/src/fixtures/telemetry_collectors/imported_interface_from_export/usage_type.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 interface Usage { + some_field: string; +} diff --git a/src/fixtures/telemetry_collectors/stats_collector.ts b/src/fixtures/telemetry_collectors/stats_collector.ts new file mode 100644 index 0000000000000..55d447751d4b6 --- /dev/null +++ b/src/fixtures/telemetry_collectors/stats_collector.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CollectorSet } from '../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../core/server/logging/logger.mock'; + +const { makeStatsCollector } = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +interface Usage { + some_field: string; +} + +/** + * Stats Collectors are allowed with schema and without them. + * We should collect them when the schema is defined. + */ + +export const myCollectorWithSchema = makeStatsCollector({ + type: 'my_stats_collector_with_schema', + isReady: () => true, + fetch() { + return { + some_field: 'abc', + }; + }, + schema: { + some_field: { + type: 'keyword', + }, + }, +}); + +export const myCollectorWithoutSchema = makeStatsCollector({ + type: 'my_stats_collector_without_schema', + isReady: () => true, + fetch() { + return { + some_field: 'abc', + }; + }, +}); diff --git a/src/legacy/server/kbn_server.js b/src/legacy/server/kbn_server.js index 7b8dcf46b43ca..4bc76b6a7706f 100644 --- a/src/legacy/server/kbn_server.js +++ b/src/legacy/server/kbn_server.js @@ -15,7 +15,6 @@ import { Config } from './config'; import httpMixin from './http'; import { coreMixin } from './core'; import { loggingMixin } from './logging'; -import { optimizeMixin } from '../../optimize'; /** * @typedef {import('./kbn_server').KibanaConfig} KibanaConfig @@ -63,10 +62,7 @@ export default class KbnServer { coreMixin, - loggingMixin, - - // setup routes that serve the @kbn/optimizer output - optimizeMixin + loggingMixin ) ); diff --git a/src/legacy/ui/public/documentation_links/documentation_links.ts b/src/legacy/ui/public/documentation_links/documentation_links.ts deleted file mode 100644 index 933812d29e9bb..0000000000000 --- a/src/legacy/ui/public/documentation_links/documentation_links.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -/* - WARNING: The links in this file are validated during the docs build. This is accomplished with some regex magic that - looks for these particular constants. As a result, we should not add new constants or change the existing ones. - If you absolutely must make a change, talk to Clinton Gormley first so he can update his Perl scripts. - */ -export const DOC_LINK_VERSION = 'stub'; -export const ELASTIC_WEBSITE_URL = 'stub'; - -export const documentationLinks = {}; diff --git a/src/optimize/bundles_route/bundles_route.test.ts b/src/optimize/bundles_route/bundles_route.test.ts deleted file mode 100644 index 4a5af40a66cfb..0000000000000 --- a/src/optimize/bundles_route/bundles_route.test.ts +++ /dev/null @@ -1,296 +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 { resolve } from 'path'; -import { readFileSync } from 'fs'; -import crypto from 'crypto'; - -import Chance from 'chance'; -import Hapi from '@hapi/hapi'; -import Inert from '@hapi/inert'; - -import { createBundlesRoute } from './bundles_route'; - -const chance = new Chance(); -const fooPluginFixture = resolve(__dirname, './__fixtures__/plugin/foo'); -const createHashMock = jest.spyOn(crypto, 'createHash'); - -const randomWordsCache = new Set(); -const uniqueRandomWord = (): string => { - const word = chance.word(); - - if (randomWordsCache.has(word)) { - return uniqueRandomWord(); - } - - randomWordsCache.add(word); - return word; -}; - -function createServer({ - basePublicPath = '', - isDist = false, -}: { - basePublicPath?: string; - isDist?: boolean; -} = {}) { - const buildHash = '1234'; - const npUiPluginPublicDirs = [ - { - id: 'foo', - path: fooPluginFixture, - }, - ]; - - const server = new Hapi.Server(); - server.register([Inert]); - - server.route( - createBundlesRoute({ - basePublicPath, - npUiPluginPublicDirs, - buildHash, - isDist, - }) - ); - - return server; -} - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('validation', () => { - it('validates that basePublicPath is valid', () => { - expect(() => { - createServer({ - // @ts-expect-error intentionally trying to break things - basePublicPath: 123, - }); - }).toThrowErrorMatchingInlineSnapshot(`"basePublicPath must be a string"`); - expect(() => { - createServer({ - // @ts-expect-error intentionally trying to break things - basePublicPath: {}, - }); - }).toThrowErrorMatchingInlineSnapshot(`"basePublicPath must be a string"`); - expect(() => { - createServer({ - basePublicPath: '/a/', - }); - }).toThrowErrorMatchingInlineSnapshot( - `"basePublicPath must be empty OR start and not end with a /"` - ); - expect(() => { - createServer({ - basePublicPath: 'a/', - }); - }).toThrowErrorMatchingInlineSnapshot( - `"basePublicPath must be empty OR start and not end with a /"` - ); - expect(() => { - createServer({ - basePublicPath: '/a', - }); - }).not.toThrowError(); - expect(() => { - createServer({ - basePublicPath: '', - }); - }).not.toThrowError(); - }); -}); - -describe('image', () => { - it('responds with exact file data', async () => { - const server = createServer(); - const response = await server.inject({ - url: '/1234/bundles/plugin/foo/image.png', - }); - - expect(response.statusCode).toBe(200); - const image = readFileSync(resolve(fooPluginFixture, 'image.png')); - expect(response.headers).toHaveProperty('content-length', image.length); - expect(response.headers).toHaveProperty('content-type', 'image/png'); - expect(image).toEqual(response.rawPayload); - }); -}); - -describe('js file', () => { - it('responds with no content-length and exact file data', async () => { - const server = createServer(); - const response = await server.inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }); - - expect(response.statusCode).toBe(200); - expect(response.headers).not.toHaveProperty('content-length'); - expect(response.headers).toHaveProperty( - 'content-type', - 'application/javascript; charset=utf-8' - ); - expect(readFileSync(resolve(fooPluginFixture, 'plugin.js'))).toEqual(response.rawPayload); - }); -}); - -describe('js file outside plugin', () => { - it('responds with a 404', async () => { - const server = createServer(); - - const response = await server.inject({ - url: '/1234/bundles/plugin/foo/../outside_output.js', - }); - - expect(response.statusCode).toBe(404); - expect(response.result).toEqual({ - error: 'Not Found', - message: 'Not Found', - statusCode: 404, - }); - }); -}); - -describe('missing js file', () => { - it('responds with 404', async () => { - const server = createServer(); - - const response = await server.inject({ - url: '/1234/bundles/plugin/foo/non_existent.js', - }); - - expect(response.statusCode).toBe(404); - expect(response.result).toEqual({ - error: 'Not Found', - message: 'Not Found', - statusCode: 404, - }); - }); -}); - -describe('etag', () => { - it('only calculates hash of file on first request', async () => { - const server = createServer(); - - expect(createHashMock).not.toHaveBeenCalled(); - const resp1 = await server.inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }); - - expect(createHashMock).toHaveBeenCalledTimes(1); - createHashMock.mockClear(); - expect(resp1.statusCode).toBe(200); - - const resp2 = await server.inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }); - - expect(createHashMock).not.toHaveBeenCalled(); - expect(resp2.statusCode).toBe(200); - }); - - it('is unique per basePublicPath although content is the same (by default)', async () => { - const basePublicPath1 = `/${uniqueRandomWord()}`; - const basePublicPath2 = `/${uniqueRandomWord()}`; - - const [resp1, resp2] = await Promise.all([ - createServer({ basePublicPath: basePublicPath1 }).inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }), - createServer({ basePublicPath: basePublicPath2 }).inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }), - ]); - - expect(resp1.statusCode).toBe(200); - expect(resp2.statusCode).toBe(200); - - expect(resp1.rawPayload).toEqual(resp2.rawPayload); - - expect(resp1.headers.etag).toEqual(expect.any(String)); - expect(resp2.headers.etag).toEqual(expect.any(String)); - expect(resp1.headers.etag).not.toEqual(resp2.headers.etag); - }); -}); - -describe('cache control', () => { - it('responds with 304 when etag and last modified are sent back', async () => { - const server = createServer(); - const resp = await server.inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }); - - expect(resp.statusCode).toBe(200); - - const resp2 = await server.inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - headers: { - 'if-modified-since': resp.headers['last-modified'], - 'if-none-match': resp.headers.etag, - }, - }); - - expect(resp2.statusCode).toBe(304); - expect(resp2.result).toHaveLength(0); - }); -}); - -describe('caching', () => { - describe('for non-distributable mode', () => { - it('uses "etag" header to invalidate cache', async () => { - const basePublicPath = `/${uniqueRandomWord()}`; - - const responce = await createServer({ basePublicPath }).inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }); - - expect(responce.statusCode).toBe(200); - - expect(responce.headers.etag).toEqual(expect.any(String)); - expect(responce.headers['cache-control']).toBe('must-revalidate'); - }); - - it('creates the same "etag" header for the same content with the same basePath', async () => { - const [resp1, resp2] = await Promise.all([ - createServer({ basePublicPath: '' }).inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }), - createServer({ basePublicPath: '' }).inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }), - ]); - - expect(resp1.statusCode).toBe(200); - expect(resp2.statusCode).toBe(200); - - expect(resp1.rawPayload).toEqual(resp2.rawPayload); - - expect(resp1.headers.etag).toEqual(expect.any(String)); - expect(resp2.headers.etag).toEqual(expect.any(String)); - expect(resp1.headers.etag).toEqual(resp2.headers.etag); - }); - }); - - describe('for distributable mode', () => { - it('commands to cache assets for each release for a year', async () => { - const basePublicPath = `/${uniqueRandomWord()}`; - - const responce = await createServer({ - basePublicPath, - isDist: true, - }).inject({ - url: '/1234/bundles/plugin/foo/plugin.js', - }); - - expect(responce.statusCode).toBe(200); - - expect(responce.headers.etag).toBe(undefined); - expect(responce.headers['cache-control']).toBe('max-age=31536000'); - }); - }); -}); diff --git a/src/optimize/bundles_route/bundles_route.ts b/src/optimize/bundles_route/bundles_route.ts deleted file mode 100644 index b88ca7e5c22b1..0000000000000 --- a/src/optimize/bundles_route/bundles_route.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { extname, join } from 'path'; - -import Hapi from '@hapi/hapi'; -import * as UiSharedDeps from '@kbn/ui-shared-deps'; -import agent from 'elastic-apm-node'; - -import { createDynamicAssetResponse } from './dynamic_asset_response'; -import { FileHashCache } from './file_hash_cache'; -import { assertIsNpUiPluginPublicDirs, NpUiPluginPublicDirs } from '../np_ui_plugin_public_dirs'; -import { fromRoot } from '../../core/server/utils'; - -/** - * Creates the routes that serves files from `bundlesPath`. - * - * @param {Object} options - * @property {Array<{id,path}>} options.npUiPluginPublicDirs array of ids and paths that should be served for new platform plugins - * @property {string} options.regularBundlesPath - * @property {string} options.basePublicPath - * - * @return Array.of({Hapi.Route}) - */ -export function createBundlesRoute({ - basePublicPath, - npUiPluginPublicDirs = [], - buildHash, - isDist = false, -}: { - basePublicPath: string; - npUiPluginPublicDirs?: NpUiPluginPublicDirs; - buildHash: string; - isDist?: boolean; -}) { - // rather than calculate the fileHash on every request, we - // provide a cache object to `resolveDynamicAssetResponse()` that - // will store the 100 most recently used hashes. - const fileHashCache = new FileHashCache(); - assertIsNpUiPluginPublicDirs(npUiPluginPublicDirs); - - if (typeof basePublicPath !== 'string') { - throw new TypeError('basePublicPath must be a string'); - } - - if (!basePublicPath.match(/(^$|^\/.*[^\/]$)/)) { - throw new TypeError('basePublicPath must be empty OR start and not end with a /'); - } - - return [ - buildRouteForBundles({ - publicPath: `${basePublicPath}/${buildHash}/bundles/kbn-ui-shared-deps/`, - routePath: `/${buildHash}/bundles/kbn-ui-shared-deps/`, - bundlesPath: UiSharedDeps.distDir, - fileHashCache, - isDist, - }), - ...npUiPluginPublicDirs.map(({ id, path }) => - buildRouteForBundles({ - publicPath: `${basePublicPath}/${buildHash}/bundles/plugin/${id}/`, - routePath: `/${buildHash}/bundles/plugin/${id}/`, - bundlesPath: path, - fileHashCache, - isDist, - }) - ), - buildRouteForBundles({ - publicPath: `${basePublicPath}/${buildHash}/bundles/core/`, - routePath: `/${buildHash}/bundles/core/`, - bundlesPath: fromRoot(join('src', 'core', 'target', 'public')), - fileHashCache, - isDist, - }), - ]; -} - -function buildRouteForBundles({ - publicPath, - routePath, - bundlesPath, - fileHashCache, - isDist, -}: { - publicPath: string; - routePath: string; - bundlesPath: string; - fileHashCache: FileHashCache; - isDist: boolean; -}) { - return { - method: 'GET', - path: `${routePath}{path*}`, - config: { - auth: false, - ext: { - onPreHandler: { - method(request: Hapi.Request, h: Hapi.ResponseToolkit) { - const ext = extname(request.params.path); - - agent.setTransactionName('GET ?/bundles/?'); - - if (ext !== '.js' && ext !== '.css') { - return h.continue; - } - - return createDynamicAssetResponse({ - request, - h, - bundlesPath, - fileHashCache, - publicPath, - isDist, - }); - }, - }, - }, - }, - handler: { - directory: { - path: bundlesPath, - listing: false, - lookupCompressed: true, - }, - }, - }; -} diff --git a/src/optimize/bundles_route/dynamic_asset_response.ts b/src/optimize/bundles_route/dynamic_asset_response.ts deleted file mode 100644 index 309fe6dd47d51..0000000000000 --- a/src/optimize/bundles_route/dynamic_asset_response.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import Fs from 'fs'; -import { resolve } from 'path'; -import { promisify } from 'util'; - -import Accept from 'accept'; -import Boom from '@hapi/boom'; -import Hapi from '@hapi/hapi'; - -import { FileHashCache } from './file_hash_cache'; -import { getFileHash } from './file_hash'; - -const MINUTE = 60; -const HOUR = 60 * MINUTE; -const DAY = 24 * HOUR; - -const asyncOpen = promisify(Fs.open); -const asyncClose = promisify(Fs.close); -const asyncFstat = promisify(Fs.fstat); - -async function tryToOpenFile(filePath: string) { - try { - return await asyncOpen(filePath, 'r'); - } catch (e) { - if (e.code === 'ENOENT') { - return undefined; - } else { - throw e; - } - } -} - -async function selectCompressedFile(acceptEncodingHeader: string | undefined, path: string) { - let fd: number | undefined; - let fileEncoding: 'gzip' | 'br' | undefined; - - const supportedEncodings = Accept.encodings(acceptEncodingHeader, ['br', 'gzip']); - - if (supportedEncodings[0] === 'br') { - fileEncoding = 'br'; - fd = await tryToOpenFile(`${path}.br`); - } - if (!fd && supportedEncodings.includes('gzip')) { - fileEncoding = 'gzip'; - fd = await tryToOpenFile(`${path}.gz`); - } - if (!fd) { - fileEncoding = undefined; - // Use raw open to trigger exception if it does not exist - fd = await asyncOpen(path, 'r'); - } - - return { fd, fileEncoding }; -} - -/** - * Create a Hapi response for the requested path. This is designed - * to replicate a subset of the features provided by Hapi's Inert - * plugin including: - * - ensure path is not traversing out of the bundle directory - * - manage use file descriptors for file access to efficiently - * interact with the file multiple times in each request - * - generate and cache etag for the file - * - write correct headers to response for client-side caching - * and invalidation - * - stream file to response - * - * It differs from Inert in some important ways: - * - cached hash/etag is based on the file on disk, but modified - * by the public path so that individual public paths have - * different etags, but can share a cache - */ -export async function createDynamicAssetResponse({ - request, - h, - bundlesPath, - publicPath, - fileHashCache, - isDist, -}: { - request: Hapi.Request; - h: Hapi.ResponseToolkit; - bundlesPath: string; - publicPath: string; - fileHashCache: FileHashCache; - isDist: boolean; -}) { - let fd: number | undefined; - let fileEncoding: 'gzip' | 'br' | undefined; - - try { - const path = resolve(bundlesPath, request.params.path); - - // prevent path traversal, only process paths that resolve within bundlesPath - if (!path.startsWith(bundlesPath)) { - throw Boom.forbidden(undefined, 'EACCES'); - } - - // we use and manage a file descriptor mostly because - // that's what Inert does, and since we are accessing - // the file 2 or 3 times per request it seems logical - ({ fd, fileEncoding } = await selectCompressedFile(request.headers['accept-encoding'], path)); - - const stat = await asyncFstat(fd); - const hash = isDist ? undefined : await getFileHash(fileHashCache, path, stat, fd); - - const content = Fs.createReadStream(null as any, { - fd, - start: 0, - autoClose: true, - }); - fd = undefined; // read stream is now responsible for fd - - const response = h - .response(content) - .takeover() - .code(200) - .type(request.server.mime.path(path).type); - - if (isDist) { - response.header('cache-control', `max-age=${365 * DAY}`); - } else { - response.etag(`${hash}-${publicPath}`); - response.header('cache-control', 'must-revalidate'); - } - - // If we manually selected a compressed file, specify the encoding header. - // Otherwise, let Hapi automatically gzip the response. - if (fileEncoding) { - response.header('content-encoding', fileEncoding); - } - - return response; - } catch (error) { - if (fd) { - try { - await asyncClose(fd); - } catch (_) { - // ignore errors from close, we already have one to report - // and it's very likely they are the same - } - } - - if (error.code === 'ENOENT') { - throw Boom.notFound(); - } - - throw Boom.boomify(error); - } -} diff --git a/src/optimize/bundles_route/proxy_bundles_route.ts b/src/optimize/bundles_route/proxy_bundles_route.ts deleted file mode 100644 index cb7f326b961f5..0000000000000 --- a/src/optimize/bundles_route/proxy_bundles_route.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. - */ - -export function createProxyBundlesRoute({ - host, - port, - buildHash, -}: { - host: string; - port: number; - buildHash: string; -}) { - return [buildProxyRouteForBundles(`/${buildHash}/bundles/`, host, port)]; -} - -function buildProxyRouteForBundles(routePath: string, host: string, port: number) { - return { - path: `${routePath}{path*}`, - method: 'GET', - handler: { - proxy: { - host, - port, - passThrough: true, - xforward: true, - }, - }, - config: { auth: false }, - }; -} diff --git a/src/optimize/np_ui_plugin_public_dirs.ts b/src/optimize/np_ui_plugin_public_dirs.ts deleted file mode 100644 index c5a4b8b85ce49..0000000000000 --- a/src/optimize/np_ui_plugin_public_dirs.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import KbnServer from '../legacy/server/kbn_server'; - -export type NpUiPluginPublicDirs = Array<{ - id: string; - path: string; -}>; - -export function getNpUiPluginPublicDirs(kbnServer: KbnServer): NpUiPluginPublicDirs { - return Array.from(kbnServer.newPlatform.__internals.uiPlugins.internal.entries()).map( - ([id, { publicTargetDir }]) => ({ - id, - path: publicTargetDir, - }) - ); -} - -export function isNpUiPluginPublicDirs(x: any): x is NpUiPluginPublicDirs { - return ( - Array.isArray(x) && - x.every( - (s) => typeof s === 'object' && s && typeof s.id === 'string' && typeof s.path === 'string' - ) - ); -} - -export function assertIsNpUiPluginPublicDirs(x: any): asserts x is NpUiPluginPublicDirs { - if (!isNpUiPluginPublicDirs(x)) { - throw new TypeError( - 'npUiPluginPublicDirs must be an array of objects with string `id` and `path` properties' - ); - } -} diff --git a/src/optimize/optimize_mixin.ts b/src/optimize/optimize_mixin.ts deleted file mode 100644 index dc780b0fae44c..0000000000000 --- a/src/optimize/optimize_mixin.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import Hapi from '@hapi/hapi'; - -import { createBundlesRoute } from './bundles_route'; -import { getNpUiPluginPublicDirs } from './np_ui_plugin_public_dirs'; -import KbnServer, { KibanaConfig } from '../legacy/server/kbn_server'; - -export const optimizeMixin = async ( - kbnServer: KbnServer, - server: Hapi.Server, - config: KibanaConfig -) => { - server.route( - createBundlesRoute({ - basePublicPath: config.get('server.basePath'), - npUiPluginPublicDirs: getNpUiPluginPublicDirs(kbnServer), - buildHash: kbnServer.newPlatform.env.packageInfo.buildNum.toString(), - isDist: kbnServer.newPlatform.env.packageInfo.dist, - }) - ); -}; diff --git a/src/plugins/charts/public/static/components/color_picker.scss b/src/plugins/charts/public/static/components/color_picker.scss index 85bfefca41a09..5def2b75a4c50 100644 --- a/src/plugins/charts/public/static/components/color_picker.scss +++ b/src/plugins/charts/public/static/components/color_picker.scss @@ -4,6 +4,18 @@ $visColorPickerWidth: $euiSizeL * 8; // 8 columns width: $visColorPickerWidth; } +.visColorPicker__colorBtn { + position: relative; + + input[type='radio'] { + position: absolute; + top: 50%; + left: 50%; + opacity: 0; + transform: translate(-50%, -50%); + } +} + .visColorPicker__valueDot { cursor: pointer; diff --git a/src/plugins/charts/public/static/components/color_picker.tsx b/src/plugins/charts/public/static/components/color_picker.tsx index 07372e0aec43c..4974400a3767a 100644 --- a/src/plugins/charts/public/static/components/color_picker.tsx +++ b/src/plugins/charts/public/static/components/color_picker.tsx @@ -9,12 +9,19 @@ import classNames from 'classnames'; import React, { BaseSyntheticEvent } from 'react'; -import { EuiButtonEmpty, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiFlexItem, + EuiIcon, + euiPaletteColorBlind, + EuiScreenReaderOnly, + EuiFlexGroup, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import './color_picker.scss'; -export const legendColors: string[] = [ +export const legacyColors: string[] = [ '#3F6833', '#967302', '#2F575E', @@ -74,54 +81,91 @@ export const legendColors: string[] = [ ]; interface ColorPickerProps { - id?: string; + /** + * Label that characterizes the color that is going to change + */ label: string | number | null; + /** + * Callback on the color change + */ onChange: (color: string | null, event: BaseSyntheticEvent) => void; + /** + * Initial color. + */ color: string; + /** + * Defines if the compatibility (legacy) or eui palette is going to be used. Defauls to true. + */ + useLegacyColors?: boolean; + /** + * Defines if the default color is overwritten. Defaults to true. + */ + colorIsOverwritten?: boolean; + /** + * Callback for onKeyPress event + */ + onKeyDown?: (e: React.KeyboardEvent) => void; } +const euiColors = euiPaletteColorBlind({ rotations: 4, order: 'group' }); -export const ColorPicker = ({ onChange, color: selectedColor, id, label }: ColorPickerProps) => ( -
- - - -
- {legendColors.map((color) => ( - onChange(color, e)} - onKeyPress={(e) => onChange(color, e)} - className={classNames('visColorPicker__valueDot', { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'visColorPicker__valueDot-isSelected': color === selectedColor, - })} - style={{ color }} - data-test-subj={`visColorPickerColor-${color}`} - /> - ))} +export const ColorPicker = ({ + onChange, + color: selectedColor, + label, + useLegacyColors = true, + colorIsOverwritten = true, + onKeyDown, +}: ColorPickerProps) => { + const legendColors = useLegacyColors ? legacyColors : euiColors; + + return ( +
+
+ + + + + + + {legendColors.map((color) => ( + + ))} + +
+ {legendColors.some((c) => c === selectedColor) && colorIsOverwritten && ( + + onChange(null, e)}> + + + + )}
- {legendColors.some((c) => c === selectedColor) && ( - - onChange(null, e)} - onKeyPress={(e: any) => onChange(null, e)} - > - - - - )} -
-); + ); +}; diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index 6230a16f10491..a82aa78b815ec 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -249,11 +249,11 @@ export function DashboardTopNav({ useReplace: true, }); } else { - setIsSaveInProgress(false); dashboardStateManager.resetState(); chrome.docTitle.change(dashboardStateManager.savedDashboard.lastSavedTitle); } } + setIsSaveInProgress(false); return { id }; }) .catch((error) => { diff --git a/src/plugins/data/common/es_query/es_query/filter_matches_index.test.ts b/src/plugins/data/common/es_query/es_query/filter_matches_index.test.ts index a8c9b9144707d..ad4d7ff8d78e2 100644 --- a/src/plugins/data/common/es_query/es_query/filter_matches_index.test.ts +++ b/src/plugins/data/common/es_query/es_query/filter_matches_index.test.ts @@ -31,6 +31,20 @@ describe('filterMatchesIndex', () => { expect(filterMatchesIndex(filter, indexPattern)).toBe(true); }); + it('should return true if custom filter for the same index is passed', () => { + const filter = { meta: { index: 'foo', key: 'bar', type: 'custom' } } as Filter; + const indexPattern = { id: 'foo', fields: [{ name: 'bara' }] } as IIndexPattern; + + expect(filterMatchesIndex(filter, indexPattern)).toBe(true); + }); + + it('should return false if custom filter for a different index is passed', () => { + const filter = { meta: { index: 'foo', key: 'bar', type: 'custom' } } as Filter; + const indexPattern = { id: 'food', fields: [{ name: 'bara' }] } as IIndexPattern; + + expect(filterMatchesIndex(filter, indexPattern)).toBe(false); + }); + it('should return false if the filter key does not match a field name', () => { const filter = { meta: { index: 'foo', key: 'baz' } } as Filter; const indexPattern = { id: 'foo', fields: [{ name: 'bar' }] } as IIndexPattern; diff --git a/src/plugins/data/common/es_query/es_query/filter_matches_index.ts b/src/plugins/data/common/es_query/es_query/filter_matches_index.ts index 9fd8567b76e2b..478263d5ce601 100644 --- a/src/plugins/data/common/es_query/es_query/filter_matches_index.ts +++ b/src/plugins/data/common/es_query/es_query/filter_matches_index.ts @@ -18,5 +18,12 @@ export function filterMatchesIndex(filter: Filter, indexPattern?: IIndexPattern if (!filter.meta?.key || !indexPattern) { return true; } + + // Fixes https://github.com/elastic/kibana/issues/89878 + // Custom filters may refer multiple fields. Validate the index id only. + if (filter.meta?.type === 'custom') { + return filter.meta.index === indexPattern.id; + } + return indexPattern.fields.some((field: IFieldType) => field.name === filter.meta.key); } diff --git a/src/plugins/data/common/search/expressions/index.ts b/src/plugins/data/common/search/expressions/index.ts index 8ac5ffec850f6..b38dce247261c 100644 --- a/src/plugins/data/common/search/expressions/index.ts +++ b/src/plugins/data/common/search/expressions/index.ts @@ -8,6 +8,11 @@ export * from './kibana'; export * from './kibana_context'; +export * from './kql'; +export * from './lucene'; +export * from './query_to_ast'; +export * from './timerange_to_ast'; export * from './kibana_context_type'; export * from './esaggs'; export * from './utils'; +export * from './timerange'; diff --git a/src/plugins/data/common/search/expressions/kibana_context.ts b/src/plugins/data/common/search/expressions/kibana_context.ts index 982db7505a3cf..5c2e2f418e69c 100644 --- a/src/plugins/data/common/search/expressions/kibana_context.ts +++ b/src/plugins/data/common/search/expressions/kibana_context.ts @@ -12,11 +12,13 @@ import { ExpressionFunctionDefinition, ExecutionContext } from 'src/plugins/expr import { Adapters } from 'src/plugins/inspector/common'; import { Query, uniqFilters } from '../../query'; import { ExecutionContextSearch, KibanaContext } from './kibana_context_type'; +import { KibanaQueryOutput } from './kibana_context_type'; +import { KibanaTimerangeOutput } from './timerange'; interface Arguments { - q?: string | null; + q?: KibanaQueryOutput | null; filters?: string | null; - timeRange?: string | null; + timeRange?: KibanaTimerangeOutput | null; savedSearchId?: string | null; } @@ -46,7 +48,7 @@ export const kibanaContextFunction: ExpressionFunctionKibanaContext = { }), args: { q: { - types: ['string', 'null'], + types: ['kibana_query', 'null'], aliases: ['query', '_'], default: null, help: i18n.translate('data.search.functions.kibana_context.q.help', { @@ -61,7 +63,7 @@ export const kibanaContextFunction: ExpressionFunctionKibanaContext = { }), }, timeRange: { - types: ['string', 'null'], + types: ['timerange', 'null'], default: null, help: i18n.translate('data.search.functions.kibana_context.timeRange.help', { defaultMessage: 'Specify Kibana time range filter', @@ -77,8 +79,8 @@ export const kibanaContextFunction: ExpressionFunctionKibanaContext = { }, async fn(input, args, { getSavedObject }) { - const timeRange = getParsedValue(args.timeRange, input?.timeRange); - let queries = mergeQueries(input?.query, getParsedValue(args?.q, [])); + const timeRange = args.timeRange || input?.timeRange; + let queries = mergeQueries(input?.query, args?.q || []); let filters = [...(input?.filters || []), ...getParsedValue(args?.filters, [])]; if (args.savedSearchId) { diff --git a/src/plugins/data/common/search/expressions/kibana_context_type.ts b/src/plugins/data/common/search/expressions/kibana_context_type.ts index 40adbc65317ad..090f09f7004ca 100644 --- a/src/plugins/data/common/search/expressions/kibana_context_type.ts +++ b/src/plugins/data/common/search/expressions/kibana_context_type.ts @@ -22,6 +22,8 @@ export type ExpressionValueSearchContext = ExpressionValueBoxed< ExecutionContextSearch >; +export type KibanaQueryOutput = ExpressionValueBoxed<'kibana_query', Query>; + // TODO: These two are exported for legacy reasons - remove them eventually. export type KIBANA_CONTEXT_NAME = 'kibana_context'; export type KibanaContext = ExpressionValueSearchContext; diff --git a/src/plugins/data/common/search/expressions/kql.test.ts b/src/plugins/data/common/search/expressions/kql.test.ts new file mode 100644 index 0000000000000..dcf3906e6c2f5 --- /dev/null +++ b/src/plugins/data/common/search/expressions/kql.test.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 { ExecutionContext } from 'src/plugins/expressions/common'; +import { ExpressionValueSearchContext } from './kibana_context_type'; +import { functionWrapper } from './utils'; +import { kqlFunction } from './kql'; + +describe('interpreter/functions#kql', () => { + const fn = functionWrapper(kqlFunction); + let input: Partial; + let context: ExecutionContext; + + beforeEach(() => { + input = { timeRange: { from: '0', to: '1' } }; + context = { + getSearchContext: () => ({}), + getSearchSessionId: () => undefined, + types: {}, + variables: {}, + abortSignal: {} as any, + inspectorAdapters: {} as any, + }; + }); + + it('returns an object with the correct structure', () => { + const actual = fn(input, { q: 'test' }, context); + expect(actual).toMatchInlineSnapshot( + ` + Object { + "language": "kuery", + "query": "test", + "type": "kibana_query", + } + ` + ); + }); +}); diff --git a/src/plugins/data/common/search/expressions/kql.ts b/src/plugins/data/common/search/expressions/kql.ts new file mode 100644 index 0000000000000..5dd830f92f834 --- /dev/null +++ b/src/plugins/data/common/search/expressions/kql.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { KibanaQueryOutput } from './kibana_context_type'; + +interface Arguments { + q: string; +} + +export type ExpressionFunctionKql = ExpressionFunctionDefinition< + 'kql', + null, + Arguments, + KibanaQueryOutput +>; + +export const kqlFunction: ExpressionFunctionKql = { + name: 'kql', + type: 'kibana_query', + inputTypes: ['null'], + help: i18n.translate('data.search.functions.kql.help', { + defaultMessage: 'Create kibana kql query', + }), + args: { + q: { + types: ['string'], + required: true, + aliases: ['query', '_'], + help: i18n.translate('data.search.functions.kql.q.help', { + defaultMessage: 'Specify Kibana KQL free form text query', + }), + }, + }, + + fn(input, args) { + return { + type: 'kibana_query', + language: 'kuery', + query: args.q, + }; + }, +}; diff --git a/src/plugins/data/common/search/expressions/lucene.test.ts b/src/plugins/data/common/search/expressions/lucene.test.ts new file mode 100644 index 0000000000000..d0b26aad98ed8 --- /dev/null +++ b/src/plugins/data/common/search/expressions/lucene.test.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 { ExecutionContext } from 'src/plugins/expressions/common'; +import { ExpressionValueSearchContext } from './kibana_context_type'; +import { functionWrapper } from './utils'; +import { luceneFunction } from './lucene'; + +describe('interpreter/functions#lucene', () => { + const fn = functionWrapper(luceneFunction); + let input: Partial; + let context: ExecutionContext; + + beforeEach(() => { + input = { timeRange: { from: '0', to: '1' } }; + context = { + getSearchContext: () => ({}), + getSearchSessionId: () => undefined, + types: {}, + variables: {}, + abortSignal: {} as any, + inspectorAdapters: {} as any, + }; + }); + + it('returns an object with the correct structure', () => { + const actual = fn(input, { q: '{ "test": 1 }' }, context); + expect(actual).toMatchInlineSnapshot(` + Object { + "language": "lucene", + "query": Object { + "test": 1, + }, + "type": "kibana_query", + } + `); + }); +}); diff --git a/src/plugins/data/common/search/expressions/lucene.ts b/src/plugins/data/common/search/expressions/lucene.ts new file mode 100644 index 0000000000000..a00ff7ed5f447 --- /dev/null +++ b/src/plugins/data/common/search/expressions/lucene.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { KibanaQueryOutput } from './kibana_context_type'; + +interface Arguments { + q: string; +} + +export type ExpressionFunctionLucene = ExpressionFunctionDefinition< + 'lucene', + null, + Arguments, + KibanaQueryOutput +>; + +export const luceneFunction: ExpressionFunctionLucene = { + name: 'lucene', + type: 'kibana_query', + inputTypes: ['null'], + help: i18n.translate('data.search.functions.lucene.help', { + defaultMessage: 'Create kibana lucene query', + }), + args: { + q: { + types: ['string'], + required: true, + aliases: ['query', '_'], + help: i18n.translate('data.search.functions.lucene.q.help', { + defaultMessage: 'Specify Lucene free form text query', + }), + }, + }, + + fn(input, args) { + return { + type: 'kibana_query', + language: 'lucene', + query: JSON.parse(args.q), + }; + }, +}; diff --git a/src/plugins/data/common/search/expressions/query_to_ast.test.ts b/src/plugins/data/common/search/expressions/query_to_ast.test.ts new file mode 100644 index 0000000000000..4b9c97e99e7c7 --- /dev/null +++ b/src/plugins/data/common/search/expressions/query_to_ast.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { queryToAst } from './query_to_ast'; + +describe('queryToAst', () => { + it('returns an object with the correct structure for lucene queies', () => { + const actual = queryToAst({ language: 'lucene', query: { country: 'US' } }); + expect(actual).toHaveProperty('functions'); + expect(actual.functions[0]).toHaveProperty('name', 'lucene'); + expect(actual.functions[0]).toHaveProperty('arguments', { + q: ['{"country":"US"}'], + }); + }); + + it('returns an object with the correct structure for kql queies', () => { + const actual = queryToAst({ language: 'kuery', query: 'country:US' }); + expect(actual).toHaveProperty('functions'); + expect(actual.functions[0]).toHaveProperty('name', 'kql'); + expect(actual.functions[0]).toHaveProperty('arguments', { + q: ['country:US'], + }); + }); +}); diff --git a/src/plugins/data/common/search/expressions/query_to_ast.ts b/src/plugins/data/common/search/expressions/query_to_ast.ts new file mode 100644 index 0000000000000..a9a6583f566c8 --- /dev/null +++ b/src/plugins/data/common/search/expressions/query_to_ast.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { buildExpression, buildExpressionFunction } from '../../../../expressions/common'; +import { Query } from '../../query'; +import { ExpressionFunctionKql } from './kql'; +import { ExpressionFunctionLucene } from './lucene'; + +export const queryToAst = (query: Query) => { + if (query.language === 'kuery') { + return buildExpression([ + buildExpressionFunction('kql', { q: query.query as string }), + ]); + } + return buildExpression([ + buildExpressionFunction('lucene', { q: JSON.stringify(query.query) }), + ]); +}; diff --git a/src/plugins/data/common/search/expressions/timerange.test.ts b/src/plugins/data/common/search/expressions/timerange.test.ts new file mode 100644 index 0000000000000..ae461b482e182 --- /dev/null +++ b/src/plugins/data/common/search/expressions/timerange.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExecutionContext } from 'src/plugins/expressions/common'; +import { ExpressionValueSearchContext } from './kibana_context_type'; +import { functionWrapper } from './utils'; +import { kibanaTimerangeFunction } from './timerange'; + +describe('interpreter/functions#timerange', () => { + const fn = functionWrapper(kibanaTimerangeFunction); + let input: Partial; + let context: ExecutionContext; + + beforeEach(() => { + input = { timeRange: { from: '0', to: '1' } }; + context = { + getSearchContext: () => ({}), + getSearchSessionId: () => undefined, + types: {}, + variables: {}, + abortSignal: {} as any, + inspectorAdapters: {} as any, + }; + }); + + it('returns an object with the correct structure', () => { + const actual = fn(input, { from: 'now', to: 'now-7d', mode: 'absolute' }, context); + expect(actual).toMatchInlineSnapshot( + ` + Object { + "from": "now", + "mode": "absolute", + "to": "now-7d", + "type": "timerange", + } + ` + ); + }); +}); diff --git a/src/plugins/data/common/search/expressions/timerange.ts b/src/plugins/data/common/search/expressions/timerange.ts new file mode 100644 index 0000000000000..ed09bab629519 --- /dev/null +++ b/src/plugins/data/common/search/expressions/timerange.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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition, ExpressionValueBoxed } from 'src/plugins/expressions/common'; +import { TimeRange } from '../../query'; + +export type KibanaTimerangeOutput = ExpressionValueBoxed<'timerange', TimeRange>; + +export type ExpressionFunctionKibanaTimerange = ExpressionFunctionDefinition< + 'timerange', + null, + TimeRange, + KibanaTimerangeOutput +>; + +export const kibanaTimerangeFunction: ExpressionFunctionKibanaTimerange = { + name: 'timerange', + type: 'timerange', + inputTypes: ['null'], + help: i18n.translate('data.search.functions.timerange.help', { + defaultMessage: 'Create kibana timerange', + }), + args: { + from: { + types: ['string'], + required: true, + help: i18n.translate('data.search.functions.timerange.from.help', { + defaultMessage: 'Specify the start date', + }), + }, + to: { + types: ['string'], + required: true, + help: i18n.translate('data.search.functions.timerange.to.help', { + defaultMessage: 'Specify the end date', + }), + }, + mode: { + types: ['string'], + options: ['absolute', 'relative'], + help: i18n.translate('data.search.functions.timerange.mode.help', { + defaultMessage: 'Specify the mode (absolute or relative)', + }), + }, + }, + + fn(input, args) { + return { + type: 'timerange', + from: args.from, + to: args.to, + mode: args.mode, + }; + }, +}; diff --git a/src/plugins/data/common/search/expressions/timerange_to_ast.test.ts b/src/plugins/data/common/search/expressions/timerange_to_ast.test.ts new file mode 100644 index 0000000000000..12ba1e012bb65 --- /dev/null +++ b/src/plugins/data/common/search/expressions/timerange_to_ast.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { timerangeToAst } from './timerange_to_ast'; + +describe('timerangeToAst', () => { + it('returns an object with the correct structure', () => { + const actual = timerangeToAst({ from: 'now', to: 'now-7d', mode: 'absolute' }); + expect(actual).toHaveProperty('name', 'timerange'); + expect(actual).toHaveProperty('arguments', { + from: ['now'], + mode: ['absolute'], + to: ['now-7d'], + }); + }); +}); diff --git a/src/plugins/data/common/search/expressions/timerange_to_ast.ts b/src/plugins/data/common/search/expressions/timerange_to_ast.ts new file mode 100644 index 0000000000000..ad66c12e68c83 --- /dev/null +++ b/src/plugins/data/common/search/expressions/timerange_to_ast.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { buildExpressionFunction } from '../../../../expressions/common'; +import { TimeRange } from '../../query'; +import { ExpressionFunctionKibanaTimerange } from './timerange'; + +export const timerangeToAst = (timerange: TimeRange) => { + return buildExpressionFunction('timerange', { + from: timerange.from, + to: timerange.to, + mode: timerange.mode, + }); +}; diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 6522cae3e044f..8eb73ba62244f 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -25,6 +25,9 @@ import { ISearchGeneric, SearchSourceDependencies, SearchSourceService, + kibanaTimerangeFunction, + luceneFunction, + kqlFunction, } from '../../common/search'; import { getCallMsearch } from './legacy'; import { AggsService, AggsStartDependencies } from './aggs'; @@ -102,6 +105,9 @@ export class SearchService implements Plugin { ); expressions.registerFunction(kibana); expressions.registerFunction(kibanaContextFunction); + expressions.registerFunction(luceneFunction); + expressions.registerFunction(kqlFunction); + expressions.registerFunction(kibanaTimerangeFunction); expressions.registerType(kibanaContext); expressions.registerFunction(esdsl); diff --git a/src/plugins/data/public/ui/query_string_input/_query_bar.scss b/src/plugins/data/public/ui/query_string_input/_query_bar.scss index 6f088fe641c51..c758ace75bd60 100644 --- a/src/plugins/data/public/ui/query_string_input/_query_bar.scss +++ b/src/plugins/data/public/ui/query_string_input/_query_bar.scss @@ -26,18 +26,18 @@ padding-top: $euiSizeS + 3px; box-shadow: 0 0 0 1px $euiFormBorderColor; - &:not(:focus):not(:invalid) { + &:not(.kbnQueryBar__textarea--autoHeight):not(:invalid) { @include euiYScrollWithShadows; } - &:not(:focus) { + &:not(.kbnQueryBar__textarea--autoHeight) { white-space: nowrap; overflow-y: hidden; overflow-x: hidden; } // When focused, let it scroll - &:focus { + &.kbnQueryBar__textarea--autoHeight { overflow-x: auto; overflow-y: auto; white-space: normal; diff --git a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx index 053ca6f78e910..65e84612bc508 100644 --- a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx +++ b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx @@ -86,6 +86,8 @@ export function QueryLanguageSwitcher({ isOpen={isPopoverOpen} closePopover={() => setIsPopoverOpen(false)} repositionOnScroll + ownFocus={true} + initialFocus={'[role="switch"]'} > ({ clear: jest.fn(), }); -function wrapQueryStringInputInContext(testProps: any, storage?: any) { - const defaultOptions = { - screenTitle: 'Another Screen', - intl: null as any, - }; +const QueryStringInput = withKibana(QueryStringInputUI); +function wrapQueryStringInputInContext(testProps: any, storage?: any) { const services = { ...startMock, data: dataPluginMock.createStartContract(), @@ -75,6 +73,11 @@ function wrapQueryStringInputInContext(testProps: any, storage?: any) { storage: storage || createMockStorage(), }; + const defaultOptions = { + screenTitle: 'Another Screen', + intl: null as any, + }; + return ( @@ -84,15 +87,12 @@ function wrapQueryStringInputInContext(testProps: any, storage?: any) { ); } -// FAILING: https://github.com/elastic/kibana/issues/85715 -// FAILING: https://github.com/elastic/kibana/issues/89603 -// FAILING: https://github.com/elastic/kibana/issues/89641 -describe.skip('QueryStringInput', () => { +describe('QueryStringInput', () => { beforeEach(() => { jest.clearAllMocks(); }); - it.skip('Should render the given query', async () => { + it('Should render the given query', async () => { const { getByText } = render( wrapQueryStringInputInContext({ query: kqlQuery, @@ -228,7 +228,7 @@ describe.skip('QueryStringInput', () => { expect(mockCallback).toHaveBeenCalledWith(); }); - it('Should fire onChangeQueryInputFocus callback on input blur', () => { + it('Should fire onChangeQueryInputFocus after a delay', () => { const mockCallback = jest.fn(); const component = mount( @@ -243,10 +243,93 @@ describe.skip('QueryStringInput', () => { const inputWrapper = component.find(EuiTextArea).find('textarea'); inputWrapper.simulate('blur'); + jest.advanceTimersByTime(10); + + expect(mockCallback).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(100); + expect(mockCallback).toHaveBeenCalledTimes(1); expect(mockCallback).toHaveBeenCalledWith(false); }); + it('Should not fire onChangeQueryInputFocus if input is focused back', () => { + const mockCallback = jest.fn(); + + const component = mount( + wrapQueryStringInputInContext({ + query: kqlQuery, + onChangeQueryInputFocus: mockCallback, + indexPatterns: [stubIndexPatternWithFields], + disableAutoFocus: true, + }) + ); + + const inputWrapper = component.find(EuiTextArea).find('textarea'); + inputWrapper.simulate('blur'); + + jest.advanceTimersByTime(5); + expect(mockCallback).toHaveBeenCalledTimes(0); + + inputWrapper.simulate('focus'); + + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(true); + + jest.advanceTimersByTime(100); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it('Should call onSubmit after a delay when submitOnBlur is on and blurs input', () => { + const mockCallback = jest.fn(); + + const component = mount( + wrapQueryStringInputInContext({ + query: kqlQuery, + onSubmit: mockCallback, + indexPatterns: [stubIndexPatternWithFields], + disableAutoFocus: true, + submitOnBlur: true, + }) + ); + + const inputWrapper = component.find(EuiTextArea).find('textarea'); + inputWrapper.simulate('blur'); + + jest.advanceTimersByTime(10); + + expect(mockCallback).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(100); + + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith(kqlQuery); + }); + + it("Shouldn't call onSubmit on blur by default", () => { + const mockCallback = jest.fn(); + + const component = mount( + wrapQueryStringInputInContext({ + query: kqlQuery, + onSubmit: mockCallback, + indexPatterns: [stubIndexPatternWithFields], + disableAutoFocus: true, + }) + ); + + const inputWrapper = component.find(EuiTextArea).find('textarea'); + inputWrapper.simulate('blur'); + + jest.advanceTimersByTime(10); + + expect(mockCallback).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(100); + + expect(mockCallback).toHaveBeenCalledTimes(0); + }); + it('Should use PersistedLog for recent search suggestions', async () => { const component = mount( wrapQueryStringInputInContext({ diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index 65f7e4f3964cd..5e34c401c7615 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -123,6 +123,12 @@ export default class QueryStringInputUI extends Component { private componentIsUnmounting = false; private queryBarInputDivRefInstance: RefObject = createRef(); + /** + * If any element within the container is currently focused + * @private + */ + private isFocusWithin = false; + private getQueryString = () => { return toUser(this.props.query.query); }; @@ -492,30 +498,37 @@ export default class QueryStringInputUI extends Component { private onOutsideClick = () => { if (this.state.isSuggestionsVisible) { this.setState({ isSuggestionsVisible: false, index: null }); - } - this.handleBlurHeight(); - if (this.props.onChangeQueryInputFocus) { - this.props.onChangeQueryInputFocus(false); + this.scheduleOnInputBlur(); } }; + private blurTimeoutHandle: number | undefined; + /** + * Notify parent about input's blur after a delay only + * if the focus didn't get back inside the input container + * and if suggestions were closed + * https://github.com/elastic/kibana/issues/92040 + */ + private scheduleOnInputBlur = () => { + clearTimeout(this.blurTimeoutHandle); + this.blurTimeoutHandle = window.setTimeout(() => { + if (!this.isFocusWithin && !this.state.isSuggestionsVisible && !this.componentIsUnmounting) { + this.handleBlurHeight(); + if (this.props.onChangeQueryInputFocus) { + this.props.onChangeQueryInputFocus(false); + } + + if (this.props.submitOnBlur) { + this.onSubmit(this.props.query); + } + } + }, 50); + }; + private onInputBlur = () => { - this.handleBlurHeight(); - if (this.props.onChangeQueryInputFocus) { - this.props.onChangeQueryInputFocus(false); - } if (isFunction(this.props.onBlur)) { this.props.onBlur(); } - if (this.props.submitOnBlur) { - // Input blur triggers when the user selects something from autocomplete, so wait a bit to ensure that - // the entire QueryStringInput component has actually blurred (e.g. from user clicking or tabbing away) - setTimeout(() => { - if (document.activeElement !== this.inputRef) { - this.onSubmit(this.props.query); - } - }, 200); - } }; private onClickSuggestion = (suggestion: QuerySuggestion, index: number) => { @@ -604,6 +617,7 @@ export default class QueryStringInputUI extends Component { handleAutoHeight = () => { if (this.inputRef !== null && document.activeElement === this.inputRef) { + this.inputRef.classList.add('kbnQueryBar__textarea--autoHeight'); this.inputRef.style.setProperty('height', `${this.inputRef.scrollHeight}px`, 'important'); } this.handleListUpdate(); @@ -612,6 +626,7 @@ export default class QueryStringInputUI extends Component { handleRemoveHeight = () => { if (this.inputRef !== null) { this.inputRef.style.removeProperty('height'); + this.inputRef.classList.remove('kbnQueryBar__textarea--autoHeight'); } }; @@ -648,7 +663,16 @@ export default class QueryStringInputUI extends Component { ); return ( -
+
{ + this.isFocusWithin = true; + }} + onBlur={(e) => { + this.isFocusWithin = false; + this.scheduleOnInputBlur(); + }} + > {this.props.prepend}
): 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 489a23eb83897..8e6d3afa18ed5 100644 --- a/src/plugins/data/server/autocomplete/value_suggestions_route.ts +++ b/src/plugins/data/server/autocomplete/value_suggestions_route.ts @@ -12,12 +12,12 @@ import { IRouter, SharedGlobalConfig } from 'kibana/server'; import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { IFieldType, Filter } from '../index'; -import { findIndexPatternById, getFieldByName } from '../index_patterns'; +import { IFieldType, Filter, ES_SEARCH_STRATEGY, IEsSearchRequest } from '../index'; import { getRequestAbortedSignal } from '../lib'; +import { DataRequestHandlerContext } from '../types'; export function registerValueSuggestionsRoute( - router: IRouter, + router: IRouter, config$: Observable ) { router.post( @@ -44,24 +44,40 @@ 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 indexPattern = await findIndexPatternById(context.core.savedObjects.client, index); - - const field = indexPattern && getFieldByName(fieldName, indexPattern); + const indexPatterns = await context.indexPatterns.find(index, 1); + if (!indexPatterns || indexPatterns.length === 0) { + return response.notFound(); + } + const field = indexPatterns[0].getFieldByName(fieldName); const body = await getBody(autocompleteSearchOptions, field || fieldName, query, filters); - const result = await client.callAsCurrentUser('search', { index, body }, { signal }); + const searchRequest: IEsSearchRequest = { + params: { + index, + body, + }, + }; + const { rawResponse } = await context.search + .search(searchRequest, { + strategy: ES_SEARCH_STRATEGY, + abortSignal: signal, + }) + .toPromise(); const buckets: any[] = - get(result, 'aggregations.suggestions.buckets') || - get(result, 'aggregations.nestedSuggestions.suggestions.buckets'); + get(rawResponse, 'aggregations.suggestions.buckets') || + get(rawResponse, 'aggregations.nestedSuggestions.suggestions.buckets'); return response.ok({ body: map(buckets || [], 'key') }); } diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 2a21b9e434596..c153c0efa8892 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -220,6 +220,7 @@ export { } from '../common'; export { + IScopedSearchClient, ISearchStrategy, ISearchSetup, ISearchStart, @@ -235,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/index.ts b/src/plugins/data/server/index_patterns/index.ts index 7226d6f015cf8..85610cd85a3ce 100644 --- a/src/plugins/data/server/index_patterns/index.ts +++ b/src/plugins/data/server/index_patterns/index.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { IndexPatternsService } from '../../common/index_patterns'; + export * from './utils'; export { IndexPatternsFetcher, @@ -15,3 +17,5 @@ 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 5d703021b94da..b489c29bc3b70 100644 --- a/src/plugins/data/server/index_patterns/index_patterns_service.ts +++ b/src/plugins/data/server/index_patterns/index_patterns_service.ts @@ -25,6 +25,7 @@ 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: ( @@ -35,6 +36,7 @@ export interface IndexPatternsServiceStart { export interface IndexPatternsServiceSetupDeps { expressions: ExpressionsServerSetup; + logger: Logger; } export interface IndexPatternsServiceStartDeps { @@ -45,11 +47,27 @@ export interface IndexPatternsServiceStartDeps { export class IndexPatternsServiceProvider implements Plugin { public setup( core: CoreSetup, - { expressions }: IndexPatternsServiceSetupDeps + { logger, 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 786dd30dbabd0..c82db7a141403 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 './search'; +import { DataRequestHandlerContext } from './types'; function createSetupContract() { return { diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index a7a7663d6981c..3408c39cbb8e2 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -82,7 +82,10 @@ export class DataServerPlugin this.queryService.setup(core); this.autocompleteService.setup(core); this.kqlTelemetryService.setup(core, { usageCollection }); - this.indexPatterns.setup(core, { expressions }); + this.indexPatterns.setup(core, { + expressions, + logger: this.logger.get('indexPatterns'), + }); core.uiSettings.register(getUiSettings()); diff --git a/src/plugins/data/server/search/routes/msearch.ts b/src/plugins/data/server/search/routes/msearch.ts index b578805d8c2df..b5f06c4b343e7 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 1680a9c4a7237..6690e2b81f3e4 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 6ece8ff945468..ab9fc84d51187 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -29,7 +29,6 @@ import type { ISearchStrategy, SearchEnhancements, SearchStrategyDependencies, - DataRequestHandlerContext, } from './types'; import { AggsService } from './aggs'; @@ -52,6 +51,9 @@ import { kibana, kibanaContext, kibanaContextFunction, + kibanaTimerangeFunction, + kqlFunction, + luceneFunction, SearchSourceDependencies, searchSourceRequiredUiSettings, SearchSourceService, @@ -66,6 +68,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'; type StrategyMap = Record>; @@ -142,6 +145,9 @@ export class SearchService implements Plugin { expressions.registerFunction(getEsaggs({ getStartServices: core.getStartServices })); expressions.registerFunction(kibana); + expressions.registerFunction(luceneFunction); + expressions.registerFunction(kqlFunction); + expressions.registerFunction(kibanaTimerangeFunction); expressions.registerFunction(kibanaContextFunction); expressions.registerType(kibanaContext); diff --git a/src/plugins/data/server/search/types.ts b/src/plugins/data/server/search/types.ts index e8548257c0167..d7aadcc348c87 100644 --- a/src/plugins/data/server/search/types.ts +++ b/src/plugins/data/server/search/types.ts @@ -8,12 +8,10 @@ import { Observable } from 'rxjs'; import type { - IRouter, IScopedClusterClient, IUiSettingsClient, SavedObjectsClientContract, KibanaRequest, - RequestHandlerContext, } from 'src/core/server'; import { ISearchOptions, @@ -116,12 +114,3 @@ 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 f755679405de0..83f7c67eba057 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -312,6 +312,12 @@ 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; } @@ -954,7 +960,7 @@ export class IndexPatternsServiceProvider implements Plugin_3, { expressions }: IndexPatternsServiceSetupDeps): void; + setup(core: CoreSetup_2, { logger, expressions }: IndexPatternsServiceSetupDeps): void; // Warning: (ae-forgotten-export) The symbol "IndexPatternsServiceStartDeps" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -963,6 +969,29 @@ export class IndexPatternsServiceProvider implements Plugin_3 { - // Warning: (ae-forgotten-export) The symbol "IScopedSearchSessionsClient" needs to be exported by the entry point index.d.ts - // // (undocumented) asScopedProvider: (core: CoreStart) => (request: KibanaRequest) => IScopedSearchSessionsClient; } @@ -1010,8 +1037,6 @@ export interface ISearchStart IScopedSearchClient; getSearchStrategy: (name?: string) => ISearchStrategy; @@ -1300,11 +1325,6 @@ 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(); @@ -1481,22 +1501,22 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "HistogramFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:128:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:128:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:245:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:246:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:255:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:256:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:257:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:261:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:262:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:266:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:269:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:270:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:244:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:244:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:244:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:244:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:246:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:247:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:256:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:257:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:258:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:262:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:263:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:267:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// 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:114: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:112: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 new file mode 100644 index 0000000000000..ea0fa49058d37 --- /dev/null +++ b/src/plugins/data/server/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not 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/discover/common/index.ts b/src/plugins/discover/common/index.ts index 5daab29348b9f..45cc95ee40804 100644 --- a/src/plugins/discover/common/index.ts +++ b/src/plugins/discover/common/index.ts @@ -8,7 +8,6 @@ export const DEFAULT_COLUMNS_SETTING = 'defaultColumns'; export const SAMPLE_SIZE_SETTING = 'discover:sampleSize'; -export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size'; export const SORT_DEFAULT_ORDER_SETTING = 'discover:sort:defaultOrder'; export const SEARCH_ON_PAGE_LOAD_SETTING = 'discover:searchOnPageLoad'; export const DOC_HIDE_TIME_COLUMN_SETTING = 'doc_table:hideTimeColumn'; diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 420e626031b7a..4a761f2fefa65 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -700,7 +700,13 @@ function discoverController($route, $scope, Promise) { async function setupVisualization() { // If no timefield has been specified we don't create a histogram of messages - if (!getTimeField() || $scope.state.hideChart) return; + 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 visStateAggs = [ @@ -723,11 +729,6 @@ function discoverController($route, $scope, Promise) { visStateAggs ); - $scope.volatileSearchSource.onRequestStart((searchSource, options) => { - if (!$scope.opts.chartAggConfigs) return; - return $scope.opts.chartAggConfigs.onSearchRequestStart(searchSource, options); - }); - $scope.volatileSearchSource.setField('aggs', function () { if (!$scope.opts.chartAggConfigs) return; return $scope.opts.chartAggConfigs.toDsl(); diff --git a/src/plugins/discover/public/application/components/discover_grid/constants.ts b/src/plugins/discover/public/application/components/discover_grid/constants.ts index 015d0b65246f2..de2781cf159c3 100644 --- a/src/plugins/discover/public/application/components/discover_grid/constants.ts +++ b/src/plugins/discover/public/application/components/discover_grid/constants.ts @@ -24,3 +24,5 @@ export const toolbarVisibility = { }, showStyleSelector: false, }; + +export const defaultMonacoEditorWidth = 370; 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 a0dcc2c2af466..380b4dc5e8e9a 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 @@ -22,7 +22,7 @@ import { } from '@elastic/eui'; import { IndexPattern } from '../../../kibana_services'; import { DocViewFilterFn, ElasticSearchHit } from '../../doc_views/doc_views_types'; -import { getPopoverContents, getSchemaDetectors } from './discover_grid_schema'; +import { getSchemaDetectors } from './discover_grid_schema'; import { DiscoverGridFlyout } from './discover_grid_flyout'; import { DiscoverGridContext } from './discover_grid_context'; import { getRenderCellValueFn } from './get_render_cell_value'; @@ -36,6 +36,7 @@ import { import { defaultPageSize, gridStyle, pageSizeArr, toolbarVisibility } from './constants'; import { DiscoverServices } from '../../../build_services'; import { getDisplayedColumns } from '../../helpers/columns'; +import { KibanaContextProvider } from '../../../../../kibana_react/public'; interface SortObj { id: string; @@ -219,7 +220,6 @@ export const DiscoverGrid = ({ [displayedColumns, indexPattern, showTimeCol, settings, defaultColumns] ); const schemaDetectors = useMemo(() => getSchemaDetectors(), []); - const popoverContents = useMemo(() => getPopoverContents(), []); const columnsVisibility = useMemo( () => ({ visibleColumns: getVisibleColumns(displayedColumns, indexPattern, showTimeCol) as string[], @@ -259,34 +259,35 @@ export const DiscoverGrid = ({ }} > <> - { - if (onResize) { - onResize(col); + + { + if (onResize) { + onResize(col); + } + }} + pagination={paginationObj} + renderCellValue={renderCellValue} + rowCount={rowCount} + schemaDetectors={schemaDetectors} + sorting={sorting as EuiDataGridSorting} + toolbarVisibility={ + defaultColumns + ? { + ...toolbarVisibility, + showColumnSelector: false, + } + : toolbarVisibility } - }} - pagination={paginationObj} - popoverContents={popoverContents} - renderCellValue={renderCellValue} - rowCount={rowCount} - schemaDetectors={schemaDetectors} - sorting={sorting as EuiDataGridSorting} - toolbarVisibility={ - defaultColumns - ? { - ...toolbarVisibility, - showColumnSelector: false, - } - : toolbarVisibility - } - /> + /> + {showDisclaimer && (

diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.ts similarity index 72% rename from src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx rename to src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.ts index ca5b2c9f19918..0aa6dadd633e0 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import React from 'react'; -import { EuiCodeBlock, EuiDataGridPopoverContents } from '@elastic/eui'; import { kibanaJSON } from './constants'; import { KBN_FIELD_TYPES } from '../../../../../data/common'; @@ -43,18 +41,3 @@ export function getSchemaDetectors() { }, ]; } - -/** - * Returns custom popover content for certain schemas - */ -export function getPopoverContents(): EuiDataGridPopoverContents { - return { - [kibanaJSON]: ({ children }) => { - return ( - - {children} - - ); - }, - }; -} 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 a1447a9a83672..f1025a0881d1f 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 @@ -7,10 +7,25 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { ReactWrapper, shallow } from 'enzyme'; import { getRenderCellValueFn } from './get_render_cell_value'; import { indexPatternMock } from '../../../__mocks__/index_pattern'; +jest.mock('../../../../../kibana_react/public', () => ({ + useUiSetting: () => true, + withKibana: (comp: ReactWrapper) => { + return comp; + }, +})); + +jest.mock('../../../kibana_services', () => ({ + getServices: () => ({ + uiSettings: { + get: jest.fn(), + }, + }), +})); + const rowsSource = [ { _id: '1', @@ -139,20 +154,25 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(` - "{ - "_id": "1", - "_index": "test", - "_type": "test", - "_score": 1, - "_source": { - "bytes": 100, - "extension": ".gz" - }, - "highlight": { - "extension": "@kibana-highlighted-field.gz@/kibana-highlighted-field" + expect(component).toMatchInlineSnapshot(` + " + width={370} + /> `); }); @@ -226,24 +246,30 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(` - "{ - "_id": "1", - "_index": "test", - "_type": "test", - "_score": 1, - "fields": { - "bytes": [ - 100 - ], - "extension": [ - ".gz" - ] - }, - "highlight": { - "extension": "@kibana-highlighted-field.gz@/kibana-highlighted-field" + expect(component).toMatchInlineSnapshot(` + " + width={370} + /> `); }); diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx index b1eb5eb9ada0e..dce0a82934c25 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx @@ -19,6 +19,8 @@ import { import { IndexPattern } from '../../../kibana_services'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; import { DiscoverGridContext } from './discover_grid_context'; +import { JsonCodeEditor } from '../json_code_editor/json_code_editor'; +import { defaultMonacoEditorWidth } from './constants'; export const getRenderCellValueFn = ( indexPattern: IndexPattern, @@ -26,7 +28,7 @@ export const getRenderCellValueFn = ( rowsFlattened: Array>, useNewFieldsApi: boolean ) => ({ rowIndex, columnId, isDetails, setCellProps }: EuiDataGridCellValueElementProps) => { - const row = rows ? (rows[rowIndex] as Record) : undefined; + const row = rows ? rows[rowIndex] : undefined; const rowFlattened = rowsFlattened ? (rowsFlattened[rowIndex] as Record) : undefined; @@ -106,10 +108,18 @@ export const getRenderCellValueFn = ( ); } + if (typeof rowFlattened[columnId] === 'object' && isDetails) { + return ( + } + width={defaultMonacoEditorWidth} + /> + ); + } + if (field && field.type === '_source') { if (isDetails) { - // nicely formatted JSON for the expanded view - return {JSON.stringify(row, null, 2)}; + return ; } const formatted = indexPattern.formatHit(row); diff --git a/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap index 4f27158eee04f..8f07614813495 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap +++ b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap @@ -6,9 +6,7 @@ exports[`returns the \`JsonCodeEditor\` component 1`] = ` direction="column" gutterSize="s" > - + @@ -31,9 +29,7 @@ exports[`returns the \`JsonCodeEditor\` component 1`] = `

- + { _score: 1, _source: { test: 123 }, }; - expect(shallow()).toMatchSnapshot(); + expect(shallow()).toMatchSnapshot(); }); diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx index 85d6aad755250..50a29dde85891 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx @@ -13,7 +13,6 @@ import { i18n } from '@kbn/i18n'; import { monaco, XJsonLang } from '@kbn/monaco'; import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { CodeEditor } from '../../../../../kibana_react/public'; -import { DocViewRenderProps } from '../../../application/doc_views/doc_views_types'; const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', { defaultMessage: 'Read only JSON view of an elasticsearch document', @@ -22,8 +21,14 @@ const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel' defaultMessage: 'Copy to clipboard', }); -export const JsonCodeEditor = ({ hit }: DocViewRenderProps) => { - const jsonValue = JSON.stringify(hit, null, 2); +interface JsonCodeEditorProps { + json: Record; + width?: string | number; + hasLineNumbers?: boolean; +} + +export const JsonCodeEditor = ({ json, width, hasLineNumbers }: JsonCodeEditorProps) => { + const jsonValue = JSON.stringify(json, null, 2); // setting editor height based on lines height and count to stretch and fit its content const setEditorCalculatedHeight = useCallback((editor) => { @@ -43,7 +48,7 @@ export const JsonCodeEditor = ({ hit }: DocViewRenderProps) => { return ( - +
@@ -55,9 +60,10 @@ export const JsonCodeEditor = ({ hit }: DocViewRenderProps) => {
- + {}} editorDidMount={setEditorCalculatedHeight} @@ -65,6 +71,7 @@ export const JsonCodeEditor = ({ hit }: DocViewRenderProps) => { options={{ automaticLayout: true, fontSize: 12, + lineNumbers: hasLineNumbers ? 'on' : 'off', minimap: { enabled: false, }, diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts index a1215836f9c5f..65fef2e4d030f 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts @@ -97,8 +97,7 @@ export const getTopNavLinks = ({ const sharingData = await getSharingData( searchSource, state.appStateContainer.getState(), - services.uiSettings, - getFieldCounts + services.uiSettings ); services.share.toggleShareContextMenu({ anchorElement, diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts index 5e0e48e619a27..ebb1946b524cd 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts @@ -11,59 +11,130 @@ import { getSharingData, showPublicUrlSwitch } from './get_sharing_data'; import { IUiSettingsClient } from 'kibana/public'; import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; import { indexPatternMock } from '../../__mocks__/index_pattern'; -import { SORT_DEFAULT_ORDER_SETTING } from '../../../common'; +import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; +import { IndexPattern } from 'src/plugins/data/public'; describe('getSharingData', () => { + let mockConfig: IUiSettingsClient; + + beforeEach(() => { + mockConfig = ({ + get: (key: string) => { + if (key === SORT_DEFAULT_ORDER_SETTING) { + return 'desc'; + } + if (key === DOC_HIDE_TIME_COLUMN_SETTING) { + return false; + } + return false; + }, + } as unknown) as IUiSettingsClient; + }); + test('returns valid data for sharing', async () => { const searchSourceMock = createSearchSourceMock({ index: indexPatternMock }); + const result = await getSharingData(searchSourceMock, { columns: [] }, mockConfig); + expect(result).toMatchInlineSnapshot(` + Object { + "searchSource": Object { + "index": "the-index-pattern-id", + "sort": Array [ + Object { + "_score": "desc", + }, + ], + }, + } + `); + }); + + test('fields have prepended timeField', async () => { + const index = { ...indexPatternMock } as IndexPattern; + index.timeFieldName = 'cool-timefield'; + + const searchSourceMock = createSearchSourceMock({ index }); const result = await getSharingData( searchSourceMock, - { columns: [] }, - ({ - get: (key: string) => { - if (key === SORT_DEFAULT_ORDER_SETTING) { - return 'desc'; - } - return false; - }, - } as unknown) as IUiSettingsClient, - () => Promise.resolve({}) + { + columns: [ + 'cool-field-1', + 'cool-field-2', + 'cool-field-3', + 'cool-field-4', + 'cool-field-5', + 'cool-field-6', + ], + }, + mockConfig ); expect(result).toMatchInlineSnapshot(` Object { - "conflictedTypesFields": Array [], - "fields": Array [], - "indexPatternId": "the-index-pattern-id", - "metaFields": Array [ - "_index", - "_score", + "searchSource": Object { + "fields": Array [ + "cool-timefield", + "cool-field-1", + "cool-field-2", + "cool-field-3", + "cool-field-4", + "cool-field-5", + "cool-field-6", + ], + "index": "the-index-pattern-id", + "sort": Array [ + Object { + "_doc": "desc", + }, + ], + }, + } + `); + }); + + test('fields conditionally do not have prepended timeField', async () => { + mockConfig = ({ + get: (key: string) => { + if (key === DOC_HIDE_TIME_COLUMN_SETTING) { + return true; + } + return false; + }, + } as unknown) as IUiSettingsClient; + + const index = { ...indexPatternMock } as IndexPattern; + index.timeFieldName = 'cool-timefield'; + + const searchSourceMock = createSearchSourceMock({ index }); + const result = await getSharingData( + searchSourceMock, + { + columns: [ + 'cool-field-1', + 'cool-field-2', + 'cool-field-3', + 'cool-field-4', + 'cool-field-5', + 'cool-field-6', ], - "searchRequest": Object { - "body": Object { - "_source": Object {}, - "fields": Array [], - "query": Object { - "bool": Object { - "filter": Array [], - "must": Array [], - "must_not": Array [], - "should": Array [], - }, + }, + mockConfig + ); + expect(result).toMatchInlineSnapshot(` + Object { + "searchSource": Object { + "fields": Array [ + "cool-field-1", + "cool-field-2", + "cool-field-3", + "cool-field-4", + "cool-field-5", + "cool-field-6", + ], + "index": "the-index-pattern-id", + "sort": Array [ + Object { + "_doc": false, }, - "runtime_mappings": Object {}, - "script_fields": Object {}, - "sort": Array [ - Object { - "_score": Object { - "order": "desc", - }, - }, - ], - "stored_fields": Array [ - "*", - ], - }, - "index": "the-index-pattern-title", + ], }, } `); diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.ts index 2455589cf69fc..f0e07ccc38deb 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.ts @@ -6,57 +6,28 @@ * Side Public License, v 1. */ -import { Capabilities, IUiSettingsClient } from 'kibana/public'; +import type { Capabilities, IUiSettingsClient } from 'kibana/public'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; import { getSortForSearchSource } from '../angular/doc_table'; import { ISearchSource } from '../../../../data/common'; import { AppState } from '../angular/discover_state'; -import { SortOrder } from '../../saved_searches/types'; - -const getSharingDataFields = async ( - getFieldCounts: () => Promise>, - selectedFields: string[], - timeFieldName: string, - hideTimeColumn: boolean -) => { - if ( - selectedFields.length === 0 || - (selectedFields.length === 1 && selectedFields[0] === '_source') - ) { - const fieldCounts = await getFieldCounts(); - return { - searchFields: undefined, - selectFields: Object.keys(fieldCounts).sort(), - }; - } - - const fields = - timeFieldName && !hideTimeColumn ? [timeFieldName, ...selectedFields] : selectedFields; - return { - searchFields: fields, - selectFields: fields, - }; -}; +import type { SavedSearch, SortOrder } from '../../saved_searches/types'; /** * Preparing data to share the current state as link or CSV/Report */ export async function getSharingData( currentSearchSource: ISearchSource, - state: AppState, - config: IUiSettingsClient, - getFieldCounts: () => Promise> + state: AppState | SavedSearch, + config: IUiSettingsClient ) { const searchSource = currentSearchSource.createCopy(); const index = searchSource.getField('index')!; + const fields = { + fields: searchSource.getField('fields'), + fieldsFromSource: searchSource.getField('fieldsFromSource'), + }; - const { searchFields, selectFields } = await getSharingDataFields( - getFieldCounts, - state.columns || [], - index.timeFieldName || '', - config.get(DOC_HIDE_TIME_COLUMN_SETTING) - ); - searchSource.setField('fieldsFromSource', searchFields); searchSource.setField( 'sort', getSortForSearchSource(state.sort as SortOrder[], index, config.get(SORT_DEFAULT_ORDER_SETTING)) @@ -66,17 +37,27 @@ export async function getSharingData( searchSource.removeField('aggs'); searchSource.removeField('size'); - const body = await searchSource.getSearchRequestBody(); + // fields get re-set to match the saved search columns + let columns = state.columns || []; + + if (columns && columns.length > 0) { + // conditionally add the time field column: + let timeFieldName: string | undefined; + const hideTimeColumn = config.get(DOC_HIDE_TIME_COLUMN_SETTING); + if (!hideTimeColumn && index && index.timeFieldName) { + timeFieldName = index.timeFieldName; + } + if (timeFieldName && !columns.includes(timeFieldName)) { + columns = [timeFieldName, ...columns]; + } + + // if columns were selected in the saved search, use them for the searchSource's fields + const fieldsKey = fields.fieldsFromSource ? 'fieldsFromSource' : 'fields'; + searchSource.setField(fieldsKey, columns); + } return { - searchRequest: { - index: index.title, - body, - }, - fields: selectFields, - metaFields: index.metaFields, - conflictedTypesFields: index.fields.filter((f) => f.type === 'conflict').map((f) => f.name), - indexPatternId: index.id, + searchSource: searchSource.getSerializedFields(true), }; } diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index de76c65ccdc98..fbe853ec6deb5 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -16,4 +16,5 @@ export function plugin(initializerContext: PluginInitializerContext) { export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; +export { loadSharingDataHelpers } from './shared'; export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator'; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.tsx similarity index 99% rename from src/plugins/discover/public/plugin.ts rename to src/plugins/discover/public/plugin.tsx index 47161c2b8298e..0e0836e3d9573 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.tsx @@ -7,6 +7,7 @@ */ import { i18n } from '@kbn/i18n'; +import React from 'react'; import angular, { auto } from 'angular'; import { BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; @@ -187,7 +188,7 @@ export class DiscoverPlugin defaultMessage: 'JSON', }), order: 20, - component: JsonCodeEditor, + component: ({ hit }) => , }); const { diff --git a/src/plugins/discover/public/shared/index.ts b/src/plugins/discover/public/shared/index.ts new file mode 100644 index 0000000000000..b1e4d9d87000e --- /dev/null +++ b/src/plugins/discover/public/shared/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* + * Allows the getSharingData function to be lazy loadable + */ +export async function loadSharingDataHelpers() { + return await import('../application/helpers/get_sharing_data'); +} diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index e528e9708bf0d..cedc713b44f63 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -14,7 +14,6 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { DEFAULT_COLUMNS_SETTING, SAMPLE_SIZE_SETTING, - AGGS_TERMS_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING, SEARCH_ON_PAGE_LOAD_SETTING, DOC_HIDE_TIME_COLUMN_SETTING, @@ -50,20 +49,6 @@ export const uiSettings: Record = { category: ['discover'], schema: schema.number(), }, - [AGGS_TERMS_SIZE_SETTING]: { - name: i18n.translate('discover.advancedSettings.aggsTermsSizeTitle', { - defaultMessage: 'Number of terms', - }), - value: 20, - type: 'number', - description: i18n.translate('discover.advancedSettings.aggsTermsSizeText', { - defaultMessage: - 'Determines how many terms will be visualized when clicking the "visualize" ' + - 'button, in the field drop downs, in the discover sidebar.', - }), - category: ['discover'], - schema: schema.number(), - }, [SORT_DEFAULT_ORDER_SETTING]: { name: i18n.translate('discover.advancedSettings.sortDefaultOrderTitle', { defaultMessage: 'Default sort direction', diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_modal.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_modal.tsx index 411da6c037900..6d5c2224c0635 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_modal.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_modal.tsx @@ -71,7 +71,7 @@ export class CustomizePanelModal extends Component { return ( -
+

Customize panel

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 f166e4fcebfa3..b8100c048d512 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -196,10 +196,6 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'long', _meta: { description: 'Non-default value of setting.' }, }, - 'discover:aggs:terms:size': { - type: 'long', - _meta: { description: 'Non-default value of setting.' }, - }, 'context:tieBreakerFields': { type: 'array', items: { 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 8bbc14e0678d3..15d78e3e79b0e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -68,7 +68,6 @@ export interface UsageStats { 'discover:sampleSize': number; defaultColumns: string[]; 'context:defaultSize': number; - 'discover:aggs:terms:size': number; 'context:tieBreakerFields': string[]; 'discover:sort:defaultOrder': string; 'context:step': number; diff --git a/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap b/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap index 1f05ed6b94405..91f99fd8e87dd 100644 --- a/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap +++ b/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap @@ -7,6 +7,7 @@ exports[`SavedObjectSaveModal should render matching snapshot 1`] = ` onClose={[Function]} >
@@ -103,6 +104,7 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali onClose={[Function]} > @@ -199,6 +201,7 @@ exports[`SavedObjectSaveModal should render matching snapshot when custom isVali onClose={[Function]} > @@ -295,6 +298,7 @@ exports[`SavedObjectSaveModal should render matching snapshot when given options onClose={[Function]} > diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 07f6336dac52c..c9e21d5204b01 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -128,7 +128,7 @@ export class SavedObjectSaveModal extends React.Component className={`kbnSavedObjectSaveModal${hasColumns ? ' kbnSavedObjectsSaveModal--wide' : ''}`} onClose={this.props.onClose} > - + { first.simulate('click'); const popover = wrapper.find('.visColorPicker').first(); - const firstColor = popover.find('.visColorPicker__valueDot').first(); - firstColor.simulate('click'); + const firstColor = popover.find('.visColorPicker__colorBtn input').first(); + firstColor.simulate('change'); const colors = mockState.get('vis.colors'); diff --git a/src/plugins/vis_type_vislib/public/vislib/components/legend/legend.tsx b/src/plugins/vis_type_vislib/public/vislib/components/legend/legend.tsx index d99f3953ee105..9ce5a5339c04f 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/legend/legend.tsx +++ b/src/plugins/vis_type_vislib/public/vislib/components/legend/legend.tsx @@ -233,7 +233,6 @@ export class VisLegend extends PureComponent { canFilter={this.state.filterableLabels.has(item.label)} onFilter={this.filter} onSelect={this.toggleDetails} - legendId={this.legendId} setColor={this.setColor} getColor={this.getColor} onHighlight={this.highlight} diff --git a/src/plugins/vis_type_vislib/public/vislib/components/legend/legend_item.tsx b/src/plugins/vis_type_vislib/public/vislib/components/legend/legend_item.tsx index 59f5a4f8a6c64..f4ca3eb5c40ae 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/legend/legend_item.tsx +++ b/src/plugins/vis_type_vislib/public/vislib/components/legend/legend_item.tsx @@ -25,7 +25,6 @@ import { ColorPicker } from '../../../../../charts/public'; interface Props { item: LegendItem; - legendId: string; selected: boolean; canFilter: boolean; anchorPosition: EuiPopoverProps['anchorPosition']; @@ -39,7 +38,6 @@ interface Props { const VisLegendItemComponent = ({ item, - legendId, selected, canFilter, anchorPosition, @@ -150,7 +148,6 @@ const VisLegendItemComponent = ({ {canFilter && renderFilterBar()} setColor(item.label, c, e)} diff --git a/src/plugins/vis_type_xy/public/utils/get_color_picker.test.tsx b/src/plugins/vis_type_xy/public/utils/get_color_picker.test.tsx new file mode 100644 index 0000000000000..c2377b42bb1c2 --- /dev/null +++ b/src/plugins/vis_type_xy/public/utils/get_color_picker.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { LegendColorPickerProps, XYChartSeriesIdentifier } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import { getColorPicker } from './get_color_picker'; +import { ColorPicker } from '../../../charts/public'; +import type { PersistedState } from '../../../visualizations/public'; + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + + return { + ...original, + getSpecId: jest.fn(() => {}), + }; +}); + +describe('getColorPicker', function () { + const mockState = new Map(); + const uiState = ({ + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + setSilent: jest.fn(), + } as unknown) as PersistedState; + + let wrapperProps: LegendColorPickerProps; + const Component: ComponentType = getColorPicker( + 'left', + jest.fn(), + jest.fn().mockImplementation((seriesIdentifier) => seriesIdentifier.seriesKeys[0]), + 'default', + uiState + ); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + onClose: jest.fn(), + onChange: jest.fn(), + anchor: document.createElement('div'), + seriesIdentifiers: [ + { + yAccessor: 'col-2-1', + splitAccessors: {}, + seriesKeys: ['Logstash Airways', 'col-2-1'], + specId: 'histogram-col-2-1', + key: + 'groupId{__pseudo_stacked_group-ValueAxis-1__}spec{histogram-col-2-1}yAccessor{col-2-1}splitAccessors{col-1-3-Logstash Airways}', + } as XYChartSeriesIdentifier, + ], + }; + }); + + it('renders the color picker', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).length).toBe(1); + }); + + it('renders the color picker with the colorIsOverwritten prop set to false if color is not overwritten for the specific series', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(false); + }); + + it('renders the color picker with the colorIsOverwritten prop set to true if color is overwritten for the specific series', () => { + uiState.set('vis.colors', { 'Logstash Airways': '#6092c0' }); + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(true); + }); + + it('renders the picker on the correct position', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).prop('anchorPosition')).toEqual('rightCenter'); + }); + + it('renders the picker for kibana palette with useLegacyColors set to true', () => { + const LegacyPaletteComponent: ComponentType = getColorPicker( + 'left', + jest.fn(), + jest.fn(), + 'kibana_palette', + uiState + ); + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('useLegacyColors')).toBe(true); + }); +}); diff --git a/src/plugins/vis_type_xy/public/utils/get_color_picker.tsx b/src/plugins/vis_type_xy/public/utils/get_color_picker.tsx new file mode 100644 index 0000000000000..4805d89068e86 --- /dev/null +++ b/src/plugins/vis_type_xy/public/utils/get_color_picker.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; + +import { LegendColorPicker, Position, XYChartSeriesIdentifier, SeriesName } from '@elastic/charts'; +import { PopoverAnchorPosition, EuiWrappingPopover, EuiOutsideClickDetector } from '@elastic/eui'; +import type { PersistedState } from '../../../visualizations/public'; +import { ColorPicker } from '../../../charts/public'; + +function getAnchorPosition(legendPosition: Position): PopoverAnchorPosition { + switch (legendPosition) { + case Position.Bottom: + return 'upCenter'; + case Position.Top: + return 'downCenter'; + case Position.Left: + return 'rightCenter'; + default: + return 'leftCenter'; + } +} + +const KEY_CODE_ENTER = 13; + +export const getColorPicker = ( + legendPosition: Position, + setColor: (newColor: string | null, seriesKey: string | number) => void, + getSeriesName: (series: XYChartSeriesIdentifier) => SeriesName, + paletteName: string, + uiState: PersistedState +): LegendColorPicker => ({ + anchor, + color, + onClose, + onChange, + seriesIdentifiers: [seriesIdentifier], +}) => { + const seriesName = getSeriesName(seriesIdentifier as XYChartSeriesIdentifier); + const overwriteColors: Record = uiState?.get('vis.colors', {}); + const colorIsOverwritten = Object.keys(overwriteColors).includes(seriesName as string); + let keyDownEventOn = false; + + const handleChange = (newColor: string | null) => { + if (!seriesName) { + return; + } + if (newColor) { + onChange(newColor); + } + setColor(newColor, seriesName); + // close the popover if no color is applied or the user has clicked a color + if (!newColor || !keyDownEventOn) { + onClose(); + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === KEY_CODE_ENTER) { + onClose?.(); + } + keyDownEventOn = true; + }; + + const handleOutsideClick = useCallback(() => { + onClose?.(); + }, [onClose]); + + return ( + + + + + + ); +}; diff --git a/src/plugins/vis_type_xy/public/utils/index.tsx b/src/plugins/vis_type_xy/public/utils/index.tsx index 82e16a639daeb..d68a6e8068fa8 100644 --- a/src/plugins/vis_type_xy/public/utils/index.tsx +++ b/src/plugins/vis_type_xy/public/utils/index.tsx @@ -11,6 +11,6 @@ export { getTimeZone } from './get_time_zone'; export { getLegendActions } from './get_legend_actions'; export { getSeriesNameFn } from './get_series_name_fn'; export { getXDomain, getAdjustedDomain } from './domain'; -export { useColorPicker } from './use_color_picker'; +export { getColorPicker } from './get_color_picker'; export { getXAccessor } from './accessors'; export { getAllSeries } from './get_all_series'; diff --git a/src/plugins/vis_type_xy/public/utils/use_color_picker.tsx b/src/plugins/vis_type_xy/public/utils/use_color_picker.tsx deleted file mode 100644 index 5028bc379c375..0000000000000 --- a/src/plugins/vis_type_xy/public/utils/use_color_picker.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not 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, { BaseSyntheticEvent, useCallback, useMemo } from 'react'; - -import { LegendColorPicker, Position, XYChartSeriesIdentifier, SeriesName } from '@elastic/charts'; -import { PopoverAnchorPosition, EuiWrappingPopover, EuiOutsideClickDetector } from '@elastic/eui'; - -import { ColorPicker } from '../../../charts/public'; - -function getAnchorPosition(legendPosition: Position): PopoverAnchorPosition { - switch (legendPosition) { - case Position.Bottom: - return 'upCenter'; - case Position.Top: - return 'downCenter'; - case Position.Left: - return 'rightCenter'; - default: - return 'leftCenter'; - } -} - -export const useColorPicker = ( - legendPosition: Position, - setColor: ( - newColor: string | null, - seriesKey: string | number, - event: BaseSyntheticEvent - ) => void, - getSeriesName: (series: XYChartSeriesIdentifier) => SeriesName -): LegendColorPicker => - useMemo( - () => ({ anchor, color, onClose, onChange, seriesIdentifiers: [seriesIdentifier] }) => { - const seriesName = getSeriesName(seriesIdentifier as XYChartSeriesIdentifier); - const handlChange = (newColor: string | null, event: BaseSyntheticEvent) => { - if (!seriesName) { - return; - } - if (newColor) { - onChange(newColor); - } - setColor(newColor, seriesName, event); - // must be called after onChange - onClose(); - }; - - // rule doesn't know this is inside a functional component - // eslint-disable-next-line react-hooks/rules-of-hooks - const handleOutsideClick = useCallback(() => { - onClose?.(); - }, [onClose]); - - return ( - - - - - - ); - }, - [getSeriesName, legendPosition, setColor] - ); diff --git a/src/plugins/vis_type_xy/public/vis_component.tsx b/src/plugins/vis_type_xy/public/vis_component.tsx index ab398101bac9d..5da5ffcc637c6 100644 --- a/src/plugins/vis_type_xy/public/vis_component.tsx +++ b/src/plugins/vis_type_xy/public/vis_component.tsx @@ -6,15 +6,7 @@ * Side Public License, v 1. */ -import React, { - BaseSyntheticEvent, - KeyboardEvent, - memo, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { Chart, @@ -28,7 +20,6 @@ import { AccessorFn, Accessor, } from '@elastic/charts'; -import { keys } from '@elastic/eui'; import { compact } from 'lodash'; import { @@ -50,7 +41,7 @@ import { renderAllSeries, getSeriesNameFn, getLegendActions, - useColorPicker, + getColorPicker, getXAccessor, getAllSeries, } from './utils'; @@ -86,16 +77,6 @@ const VisComponent = (props: VisComponentProps) => { return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean; }); const [palettesRegistry, setPalettesRegistry] = useState(null); - useEffect(() => { - const fn = () => { - props?.uiState?.emit?.('reload'); - }; - props?.uiState?.on?.('change', fn); - - return () => { - props?.uiState?.off?.('change', fn); - }; - }, [props?.uiState]); const onRenderChange = useCallback( (isRendered) => { @@ -203,11 +184,7 @@ const VisComponent = (props: VisComponentProps) => { }, [props.uiState]); const setColor = useCallback( - (newColor: string | null, seriesLabel: string | number, event: BaseSyntheticEvent) => { - if ((event as KeyboardEvent).key && (event as KeyboardEvent).key !== keys.ENTER) { - return; - } - + (newColor: string | null, seriesLabel: string | number) => { const colors = props.uiState?.get('vis.colors') || {}; if (colors[seriesLabel] === newColor || !newColor) { delete colors[seriesLabel]; @@ -337,6 +314,18 @@ const VisComponent = (props: VisComponentProps) => { xAccessor, ] ); + + const legendColorPicker = useMemo( + () => + getColorPicker( + legendPosition, + setColor, + getSeriesName, + visParams.palette.name, + props.uiState + ), + [getSeriesName, legendPosition, props.uiState, setColor, visParams.palette.name] + ); return (
{ legendPosition={legendPosition} xDomain={xDomain} adjustedXDomain={adjustedXDomain} - legendColorPicker={useColorPicker(legendPosition, setColor, getSeriesName)} + legendColorPicker={legendColorPicker} onElementClick={handleFilterClick( visData, xAccessor, diff --git a/src/plugins/visualizations/public/embeddable/to_ast.ts b/src/plugins/visualizations/public/embeddable/to_ast.ts index 5436b78c1b71f..7ccff9394943a 100644 --- a/src/plugins/visualizations/public/embeddable/to_ast.ts +++ b/src/plugins/visualizations/public/embeddable/to_ast.ts @@ -10,6 +10,7 @@ import { ExpressionFunctionKibana, ExpressionFunctionKibanaContext } from '../.. import { buildExpression, buildExpressionFunction } from '../../../expressions/public'; import { VisToExpressionAst } from '../types'; +import { queryToAst } from '../../../data/common'; /** * Creates an ast expression for a visualization based on kibana context (query, filters, timerange) @@ -25,7 +26,7 @@ export const toExpressionAst: VisToExpressionAst = async (vis, params) => { const kibana = buildExpressionFunction('kibana', {}); const kibanaContext = buildExpressionFunction('kibana_context', { - q: query && JSON.stringify(query), + q: query && queryToAst(query), filters: filters && JSON.stringify(filters), savedSearchId, }); diff --git a/src/plugins/visualize/common/constants.ts b/src/plugins/visualize/common/constants.ts index fcdc7c1cbc9a2..5fe8ed7e095a2 100644 --- a/src/plugins/visualize/common/constants.ts +++ b/src/plugins/visualize/common/constants.ts @@ -6,6 +6,5 @@ * Side Public License, v 1. */ -export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size'; export const STATE_STORAGE_KEY = '_a'; export const GLOBAL_STATE_STORAGE_KEY = '_g'; diff --git a/test/api_integration/apis/telemetry/utils/schema_to_config_schema.test.js b/test/api_integration/apis/telemetry/utils/schema_to_config_schema.test.js index f568a4338ebe5..2ae4e1723cc25 100644 --- a/test/api_integration/apis/telemetry/utils/schema_to_config_schema.test.js +++ b/test/api_integration/apis/telemetry/utils/schema_to_config_schema.test.js @@ -151,5 +151,23 @@ describe(`assertTelemetryPayload`, () => { { im_only_passing_through_data: [{ docs: { field: 1 } }] } ) ).not.toThrow(); + + // Even when properties exist + expect(() => + assertTelemetryPayload( + { + root: { + properties: { + im_only_passing_through_data: { + type: 'pass_through', + properties: {}, + }, + }, + }, + plugins: { properties: {} }, + }, + { im_only_passing_through_data: [{ docs: { field: 1 } }] } + ) + ).not.toThrow(); }); }); diff --git a/test/api_integration/apis/telemetry/utils/schema_to_config_schema.ts b/test/api_integration/apis/telemetry/utils/schema_to_config_schema.ts index d5b18eb4bd202..b45930682e3aa 100644 --- a/test/api_integration/apis/telemetry/utils/schema_to_config_schema.ts +++ b/test/api_integration/apis/telemetry/utils/schema_to_config_schema.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { schema, ObjectType, Type } from '@kbn/config-schema'; +import type { ObjectType, Type } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { get } from 'lodash'; import { set } from '@elastic/safer-lodash-set'; import type { AllowedSchemaTypes } from 'src/plugins/usage_collection/server'; @@ -38,6 +39,11 @@ function isOneOfCandidate( * @param value */ function valueSchemaToConfigSchema(value: TelemetrySchemaValue): Type { + // We need to check the pass_through type on top of everything + if ((value as { type: 'pass_through' }).type === 'pass_through') { + return schema.any(); + } + if ('properties' in value) { const { DYNAMIC_KEY, ...properties } = value.properties; const schemas: Array> = [objectSchemaToConfigSchema({ properties })]; @@ -48,8 +54,6 @@ function valueSchemaToConfigSchema(value: TelemetrySchemaValue): Type { } else { const valueType = value.type; // Copied in here because of TS reasons, it's not available in the `default` case switch (value.type) { - case 'pass_through': - return schema.any(); case 'boolean': return schema.boolean(); case 'keyword': @@ -77,9 +81,11 @@ function valueSchemaToConfigSchema(value: TelemetrySchemaValue): Type { } function objectSchemaToConfigSchema(objectSchema: TelemetrySchemaObject): ObjectType { + const objectEntries = Object.entries(objectSchema.properties); + return schema.object( Object.fromEntries( - Object.entries(objectSchema.properties).map(([key, value]) => { + objectEntries.map(([key, value]) => { try { return [key, schema.maybe(valueSchemaToConfigSchema(value))]; } catch (err) { diff --git a/test/functional/apps/dashboard/dashboard_saved_query.ts b/test/functional/apps/dashboard/dashboard_saved_query.ts index 307c34d3f3c43..bdf97e8ced140 100644 --- a/test/functional/apps/dashboard/dashboard_saved_query.ts +++ b/test/functional/apps/dashboard/dashboard_saved_query.ts @@ -38,7 +38,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await savedQueryManagementComponent.openSavedQueryManagementComponent(); const descriptionText = await testSubjects.getVisibleText('saved-query-management-popover'); expect(descriptionText).to.eql( - 'SAVED QUERIES\nThere are no saved queries. Save query text and filters that you want to use again.\nSave current query' + 'Saved Queries\nThere are no saved queries. Save query text and filters that you want to use again.\nSave current query' ); }); diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index 9b2be2e6b5a00..acb2bd869819d 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -76,7 +76,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await queryBar.clickQuerySubmitButton(); await PageObjects.visChart.openLegendOptionColors('Count', `[data-title="${visName}"]`); - await PageObjects.visChart.selectNewLegendColorChoice('#EA6460'); + const overwriteColor = isNewChartsLibraryEnabled ? '#d36086' : '#EA6460'; + await PageObjects.visChart.selectNewLegendColorChoice(overwriteColor); await PageObjects.dashboard.saveDashboard(dashboarName); @@ -89,7 +90,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } const colorChoiceRetained = await PageObjects.visChart.doesSelectedLegendColorExist( - '#EA6460' + overwriteColor ); expect(colorChoiceRetained).to.be(true); diff --git a/test/functional/apps/discover/_data_grid_doc_table.ts b/test/functional/apps/discover/_data_grid_doc_table.ts index 5eeafc4d78f67..fb19111d92c68 100644 --- a/test/functional/apps/discover/_data_grid_doc_table.ts +++ b/test/functional/apps/discover/_data_grid_doc_table.ts @@ -10,11 +10,13 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { + const find = getService('find'); const dataGrid = getService('dataGrid'); const log = getService('log'); const retry = getService('retry'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); + const monacoEditor = getService('monacoEditor'); const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); const defaultSettings = { defaultIndex: 'logstash-*', @@ -56,6 +58,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); + it('should show popover with expanded cell content by click on expand button', async () => { + log.debug('open popover with expanded cell content to get json from the editor'); + const documentCell = await dataGrid.getCellElement(1, 3); + await documentCell.click(); + const expandCellContentButton = await documentCell.findByClassName( + 'euiDataGridRowCell__expandButtonIcon' + ); + await expandCellContentButton.click(); + const popoverJson = await monacoEditor.getCodeEditorValue(); + + log.debug('open expanded document flyout to get json'); + await dataGrid.clickRowToggle(); + await find.clickByCssSelectorWhenNotDisabled('#kbn_doc_viewer_tab_1'); + const flyoutJson = await monacoEditor.getCodeEditorValue(); + + expect(popoverJson).to.be(flyoutJson); + }); + describe('expand a document row', function () { const rowToInspect = 1; diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index ddd1c4648a0b2..23f3af37bbdf6 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -55,7 +55,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await savedQueryManagementComponent.openSavedQueryManagementComponent(); const descriptionText = await testSubjects.getVisibleText('saved-query-management-popover'); expect(descriptionText).to.eql( - 'SAVED QUERIES\nThere are no saved queries. Save query text and filters that you want to use again.\nSave current query' + 'Saved Queries\nThere are no saved queries. Save query text and filters that you want to use again.\nSave current query' ); }); diff --git a/test/functional/apps/visualize/_inspector.ts b/test/functional/apps/visualize/_inspector.ts index 47fcc6e64d0b1..edb2f87aab13e 100644 --- a/test/functional/apps/visualize/_inspector.ts +++ b/test/functional/apps/visualize/_inspector.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const inspector = getService('inspector'); const filterBar = getService('filterBar'); + const monacoEditor = getService('monacoEditor'); const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); @@ -42,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await inspector.openInspectorRequestsView(); const requestTab = await inspector.getOpenRequestDetailRequestButton(); await requestTab.click(); - const requestJSON = JSON.parse(await inspector.getCodeEditorValue()); + const requestJSON = JSON.parse(await monacoEditor.getCodeEditorValue()); expect(requestJSON.aggs['2'].max).property('missing', 10); }); diff --git a/test/functional/page_objects/tile_map_page.ts b/test/functional/page_objects/tile_map_page.ts index c11e4f1558bee..db17268f20a15 100644 --- a/test/functional/page_objects/tile_map_page.ts +++ b/test/functional/page_objects/tile_map_page.ts @@ -14,6 +14,7 @@ export function TileMapPageProvider({ getService, getPageObjects }: FtrProviderC const retry = getService('retry'); const log = getService('log'); const inspector = getService('inspector'); + const monacoEditor = getService('monacoEditor'); const { header } = getPageObjects(['header']); class TileMapPage { @@ -40,7 +41,7 @@ export function TileMapPageProvider({ getService, getPageObjects }: FtrProviderC await testSubjects.click('inspectorViewChooserRequests'); await testSubjects.click('inspectorRequestDetailRequest'); - return await inspector.getCodeEditorValue(); + return await monacoEditor.getCodeEditorValue(); } public async getMapBounds(): Promise { diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index 963a6bff0cd0b..d7bb84394ae3c 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -605,7 +605,7 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro await comboBox.setElement(groupBy, 'Terms', { clickWithMouse: true }); await PageObjects.common.sleep(1000); const byField = await testSubjects.find('groupByField'); - await comboBox.setElement(byField, field, { clickWithMouse: true }); + await comboBox.setElement(byField, field); } public async checkSelectedMetricsGroupByValue(value: string) { diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index abd5975b95d0a..cd1c5cf318e63 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -408,7 +408,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr await this.waitForVisualizationRenderingStabilized(); // arbitrary color chosen, any available would do - const isOpen = await this.doesLegendColorChoiceExist('#EF843C'); + const arbitraryColor = (await this.isVisTypeXYChart()) ? '#d36086' : '#EF843C'; + const isOpen = await this.doesLegendColorChoiceExist(arbitraryColor); if (!isOpen) { throw new Error('legend color selector not open'); } diff --git a/test/functional/screenshots/baseline/area_chart.png b/test/functional/screenshots/baseline/area_chart.png index 8ff0df0563eda..e32dbbaf8d1af 100644 Binary files a/test/functional/screenshots/baseline/area_chart.png and b/test/functional/screenshots/baseline/area_chart.png differ diff --git a/test/functional/screenshots/baseline/tsvb_dashboard.png b/test/functional/screenshots/baseline/tsvb_dashboard.png index b0264ce8d4592..e0d79c7234f6a 100644 Binary files a/test/functional/screenshots/baseline/tsvb_dashboard.png and b/test/functional/screenshots/baseline/tsvb_dashboard.png differ diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index 94d7b71c640c3..07d5ef950d21e 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -46,6 +46,7 @@ import { ListingTableProvider } from './listing_table'; import { SavedQueryManagementComponentProvider } from './saved_query_management_component'; import { KibanaSupertestProvider } from './supertest'; import { MenuToggleProvider } from './menu_toggle'; +import { MonacoEditorProvider } from './monaco_editor'; export const services = { ...commonServiceProviders, @@ -81,5 +82,6 @@ export const services = { elasticChart: ElasticChartProvider, supertest: KibanaSupertestProvider, managementMenu: ManagementMenuProvider, + monacoEditor: MonacoEditorProvider, MenuToggle: MenuToggleProvider, }; diff --git a/test/functional/services/inspector.ts b/test/functional/services/inspector.ts index 4dc248116ccfd..c9cf159d0d38e 100644 --- a/test/functional/services/inspector.ts +++ b/test/functional/services/inspector.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function InspectorProvider({ getService }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); - const browser = getService('browser'); const renderable = getService('renderable'); const flyout = getService('flyout'); const testSubjects = getService('testSubjects'); @@ -235,18 +234,6 @@ export function InspectorProvider({ getService }: FtrProviderContext) { public getOpenRequestDetailResponseButton() { return testSubjects.find('inspectorRequestDetailResponse'); } - - public async getCodeEditorValue() { - let request: string = ''; - - await retry.try(async () => { - request = await browser.execute( - () => (window as any).MonacoEnvironment.monaco.editor.getModels()[0].getValue() as string - ); - }); - - return request; - } } return new Inspector(); diff --git a/test/functional/services/monaco_editor.ts b/test/functional/services/monaco_editor.ts new file mode 100644 index 0000000000000..e0763659be9c5 --- /dev/null +++ b/test/functional/services/monaco_editor.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 { FtrProviderContext } from '../ftr_provider_context'; + +export function MonacoEditorProvider({ getService }: FtrProviderContext) { + const retry = getService('retry'); + const browser = getService('browser'); + + return new (class MonacoEditor { + public async getCodeEditorValue() { + let request: string = ''; + + await retry.try(async () => { + request = await browser.execute( + () => (window as any).MonacoEnvironment.monaco.editor.getModels()[0].getValue() as string + ); + }); + + return request; + } + })(); +} diff --git a/test/functional/services/query_bar.ts b/test/functional/services/query_bar.ts index 5718835c0b5e4..2c4cd3b8db131 100644 --- a/test/functional/services/query_bar.ts +++ b/test/functional/services/query_bar.ts @@ -45,7 +45,8 @@ export function QueryBarProvider({ getService, getPageObjects }: FtrProviderCont public async clearQuery(): Promise { await this.setQuery(''); - await PageObjects.common.pressTabKey(); + await PageObjects.common.pressTabKey(); // move outside of input into language switcher + await PageObjects.common.pressTabKey(); // move outside of language switcher so time picker appears } public async submitQuery(): Promise { diff --git a/test/interpreter_functional/screenshots/baseline/combined_test.png b/test/interpreter_functional/screenshots/baseline/combined_test.png index b828012f39307..9cb5e255ec99b 100644 Binary files a/test/interpreter_functional/screenshots/baseline/combined_test.png and b/test/interpreter_functional/screenshots/baseline/combined_test.png differ diff --git a/test/interpreter_functional/screenshots/baseline/final_screenshot_test.png b/test/interpreter_functional/screenshots/baseline/final_screenshot_test.png index 4f728f5111748..9cb5e255ec99b 100644 Binary files a/test/interpreter_functional/screenshots/baseline/final_screenshot_test.png and b/test/interpreter_functional/screenshots/baseline/final_screenshot_test.png differ diff --git a/test/interpreter_functional/screenshots/baseline/metric_all_data.png b/test/interpreter_functional/screenshots/baseline/metric_all_data.png index 0a9475fc710d1..54ee1f4da6684 100644 Binary files a/test/interpreter_functional/screenshots/baseline/metric_all_data.png and b/test/interpreter_functional/screenshots/baseline/metric_all_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png b/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png index aab2905cee19b..b1448cd7cb2ef 100644 Binary files a/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png and b/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/metric_multi_metric_data.png b/test/interpreter_functional/screenshots/baseline/metric_multi_metric_data.png index 2ae380df282ba..3cf9d89c37620 100644 Binary files a/test/interpreter_functional/screenshots/baseline/metric_multi_metric_data.png and b/test/interpreter_functional/screenshots/baseline/metric_multi_metric_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/metric_percentage_mode.png b/test/interpreter_functional/screenshots/baseline/metric_percentage_mode.png index 03cc2e4d77d37..8a5fd9d7a7285 100644 Binary files a/test/interpreter_functional/screenshots/baseline/metric_percentage_mode.png and b/test/interpreter_functional/screenshots/baseline/metric_percentage_mode.png differ diff --git a/test/interpreter_functional/screenshots/baseline/metric_single_metric_data.png b/test/interpreter_functional/screenshots/baseline/metric_single_metric_data.png index 2cf25aff54a73..315653ee2b940 100644 Binary files a/test/interpreter_functional/screenshots/baseline/metric_single_metric_data.png and b/test/interpreter_functional/screenshots/baseline/metric_single_metric_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/partial_test_1.png b/test/interpreter_functional/screenshots/baseline/partial_test_1.png index 9d52fb30b7d65..5e43b52099d15 100644 Binary files a/test/interpreter_functional/screenshots/baseline/partial_test_1.png and b/test/interpreter_functional/screenshots/baseline/partial_test_1.png differ diff --git a/test/interpreter_functional/screenshots/baseline/partial_test_2.png b/test/interpreter_functional/screenshots/baseline/partial_test_2.png index b828012f39307..9cb5e255ec99b 100644 Binary files a/test/interpreter_functional/screenshots/baseline/partial_test_2.png and b/test/interpreter_functional/screenshots/baseline/partial_test_2.png differ diff --git a/test/interpreter_functional/screenshots/baseline/partial_test_3.png b/test/interpreter_functional/screenshots/baseline/partial_test_3.png index 93a8e53540744..b0edb637e0047 100644 Binary files a/test/interpreter_functional/screenshots/baseline/partial_test_3.png and b/test/interpreter_functional/screenshots/baseline/partial_test_3.png differ diff --git a/test/interpreter_functional/screenshots/baseline/tagcloud_all_data.png b/test/interpreter_functional/screenshots/baseline/tagcloud_all_data.png index 4938d13fcb41d..d195403bb26d3 100644 Binary files a/test/interpreter_functional/screenshots/baseline/tagcloud_all_data.png and b/test/interpreter_functional/screenshots/baseline/tagcloud_all_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/tagcloud_fontsize.png b/test/interpreter_functional/screenshots/baseline/tagcloud_fontsize.png index b3703ecc7a330..29a0ace5905dd 100644 Binary files a/test/interpreter_functional/screenshots/baseline/tagcloud_fontsize.png and b/test/interpreter_functional/screenshots/baseline/tagcloud_fontsize.png differ diff --git a/test/interpreter_functional/screenshots/baseline/tagcloud_invalid_data.png b/test/interpreter_functional/screenshots/baseline/tagcloud_invalid_data.png index c43169bfb7101..b8ffa6e8576fe 100644 Binary files a/test/interpreter_functional/screenshots/baseline/tagcloud_invalid_data.png and b/test/interpreter_functional/screenshots/baseline/tagcloud_invalid_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/tagcloud_metric_data.png b/test/interpreter_functional/screenshots/baseline/tagcloud_metric_data.png index f8de00f81926d..f1ea0471c3651 100644 Binary files a/test/interpreter_functional/screenshots/baseline/tagcloud_metric_data.png and b/test/interpreter_functional/screenshots/baseline/tagcloud_metric_data.png differ diff --git a/test/interpreter_functional/screenshots/baseline/tagcloud_options.png b/test/interpreter_functional/screenshots/baseline/tagcloud_options.png index f862a9cd46c66..4b5e445d2d55d 100644 Binary files a/test/interpreter_functional/screenshots/baseline/tagcloud_options.png and b/test/interpreter_functional/screenshots/baseline/tagcloud_options.png differ diff --git a/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts b/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts index 7f6e9a6439165..f71fa58cd7cc5 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts @@ -49,7 +49,7 @@ export default function ({ to: '2015-09-22T00:00:00Z', }; const expression = ` - kibana_context timeRange='${JSON.stringify(timeRange)}' + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} | esaggs index={indexPatternLoad id='logstash-*'} aggs={aggCount id="1" enabled=true schema="metric"} `; @@ -63,7 +63,7 @@ export default function ({ to: '2015-09-22T00:00:00Z', }; const expression = ` - kibana_context timeRange='${JSON.stringify(timeRange)}' + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} | esaggs index={indexPatternLoad id='logstash-*'} timeFields='relatedContent.article:published_time' aggs={aggCount id="1" enabled=true schema="metric"} @@ -78,7 +78,7 @@ export default function ({ to: '2015-09-22T00:00:00Z', }; const expression = ` - kibana_context timeRange='${JSON.stringify(timeRange)}' + kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'} | esaggs index={indexPatternLoad id='logstash-*'} timeFields='relatedContent.article:published_time' timeFields='@timestamp' diff --git a/test/scripts/jenkins_storybook.sh b/test/scripts/jenkins_storybook.sh index abddedf95a0a6..5c99654f16cbe 100755 --- a/test/scripts/jenkins_storybook.sh +++ b/test/scripts/jenkins_storybook.sh @@ -21,3 +21,4 @@ yarn storybook --site security_solution yarn storybook --site ui_actions_enhanced yarn storybook --site observability yarn storybook --site presentation +yarn storybook --site lists diff --git a/tsconfig.base.json b/tsconfig.base.json index 5220601e794b0..865806cffe5bb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -36,8 +36,6 @@ // Resolve modules in the same way as Node.js. Aka make `require` works the // same in TypeScript as it does in Node.js. "moduleResolution": "node", - // Do not resolve the real path of symlinks - "preserveSymlinks": true, // "resolveJsonModule" allows for importing, extracting types from and generating .json files. "resolveJsonModule": true, // Disallow inconsistently-cased references to the same file. diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 14579a6461bb7..663ae32f9128a 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -33,6 +33,7 @@ "xpack.lens": "plugins/lens", "xpack.licenseMgmt": "plugins/license_management", "xpack.licensing": "plugins/licensing", + "xpack.lists": "plugins/lists", "xpack.logstash": ["plugins/logstash"], "xpack.main": "legacy/plugins/xpack_main", "xpack.maps": ["plugins/maps"], diff --git a/x-pack/.telemetryrc.json b/x-pack/.telemetryrc.json index ae85efcda32d5..b0a8b45c02de9 100644 --- a/x-pack/.telemetryrc.json +++ b/x-pack/.telemetryrc.json @@ -1,5 +1,14 @@ -{ - "output": "plugins/telemetry_collection_xpack/schema/xpack_plugins.json", - "root": "plugins/", - "exclude": [] -} +[ + { + "output": "plugins/telemetry_collection_xpack/schema/xpack_plugins.json", + "root": "plugins/", + "exclude": [ + "plugins/monitoring/server/telemetry_collection/" + ] + }, + { + "output": "plugins/telemetry_collection_xpack/schema/xpack_monitoring.json", + "root": "plugins/monitoring/server/telemetry_collection/", + "exclude": [] + } +] diff --git a/x-pack/plugins/actions/common/index.ts b/x-pack/plugins/actions/common/index.ts index f0e1439bce3e3..184ae9c226b8f 100644 --- a/x-pack/plugins/actions/common/index.ts +++ b/x-pack/plugins/actions/common/index.ts @@ -8,3 +8,5 @@ export * from './types'; export const BASE_ACTION_API_PATH = '/api/actions'; + +export * from './rewrite_request_case'; diff --git a/x-pack/plugins/actions/server/routes/rewrite_request_case.ts b/x-pack/plugins/actions/common/rewrite_request_case.ts similarity index 100% rename from x-pack/plugins/actions/server/routes/rewrite_request_case.ts rename to x-pack/plugins/actions/common/rewrite_request_case.ts diff --git a/x-pack/plugins/actions/server/routes/connector_types.ts b/x-pack/plugins/actions/server/routes/connector_types.ts index d686ddbdaee70..9f9ad5b2aea68 100644 --- a/x-pack/plugins/actions/server/routes/connector_types.ts +++ b/x-pack/plugins/actions/server/routes/connector_types.ts @@ -7,10 +7,9 @@ import { IRouter } from 'kibana/server'; import { ILicenseState } from '../lib'; -import { ActionType, BASE_ACTION_API_PATH } from '../../common'; +import { ActionType, BASE_ACTION_API_PATH, RewriteResponseCase } from '../../common'; import { ActionsRequestHandlerContext } from '../types'; import { verifyAccessAndContext } from './verify_access_and_context'; -import { RewriteResponseCase } from './rewrite_request_case'; const rewriteBodyRes: RewriteResponseCase = (results) => { return results.map(({ enabledInConfig, enabledInLicense, minimumLicenseRequired, ...res }) => ({ diff --git a/x-pack/plugins/actions/server/routes/create.ts b/x-pack/plugins/actions/server/routes/create.ts index e1717891231db..c05f2180bd62b 100644 --- a/x-pack/plugins/actions/server/routes/create.ts +++ b/x-pack/plugins/actions/server/routes/create.ts @@ -9,9 +9,8 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'kibana/server'; import { ActionResult, ActionsRequestHandlerContext } from '../types'; import { ILicenseState } from '../lib'; -import { BASE_ACTION_API_PATH } from '../../common'; +import { BASE_ACTION_API_PATH, RewriteRequestCase, RewriteResponseCase } from '../../common'; import { verifyAccessAndContext } from './verify_access_and_context'; -import { RewriteRequestCase, RewriteResponseCase } from './rewrite_request_case'; import { CreateOptions } from '../actions_client'; export const bodySchema = schema.object({ diff --git a/x-pack/plugins/actions/server/routes/execute.ts b/x-pack/plugins/actions/server/routes/execute.ts index 0d1bee83ed047..377fe1215b3fb 100644 --- a/x-pack/plugins/actions/server/routes/execute.ts +++ b/x-pack/plugins/actions/server/routes/execute.ts @@ -10,10 +10,9 @@ import { IRouter } from 'kibana/server'; import { ILicenseState } from '../lib'; import { ActionTypeExecutorResult, ActionsRequestHandlerContext } from '../types'; -import { BASE_ACTION_API_PATH } from '../../common'; +import { BASE_ACTION_API_PATH, RewriteResponseCase } from '../../common'; import { asHttpRequestExecutionSource } from '../lib/action_execution_source'; import { verifyAccessAndContext } from './verify_access_and_context'; -import { RewriteResponseCase } from './rewrite_request_case'; const paramSchema = schema.object({ id: schema.string(), diff --git a/x-pack/plugins/actions/server/routes/get.ts b/x-pack/plugins/actions/server/routes/get.ts index 63f89d6b3ca49..59766fc133ba6 100644 --- a/x-pack/plugins/actions/server/routes/get.ts +++ b/x-pack/plugins/actions/server/routes/get.ts @@ -8,10 +8,9 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'kibana/server'; import { ILicenseState } from '../lib'; -import { BASE_ACTION_API_PATH } from '../../common'; +import { BASE_ACTION_API_PATH, RewriteResponseCase } from '../../common'; import { ActionResult, ActionsRequestHandlerContext } from '../types'; import { verifyAccessAndContext } from './verify_access_and_context'; -import { RewriteResponseCase } from './rewrite_request_case'; const paramSchema = schema.object({ id: schema.string(), diff --git a/x-pack/plugins/actions/server/routes/get_all.ts b/x-pack/plugins/actions/server/routes/get_all.ts index 32f48e32ab278..831722fd36eed 100644 --- a/x-pack/plugins/actions/server/routes/get_all.ts +++ b/x-pack/plugins/actions/server/routes/get_all.ts @@ -7,10 +7,9 @@ import { IRouter } from 'kibana/server'; import { ILicenseState } from '../lib'; -import { BASE_ACTION_API_PATH } from '../../common'; +import { BASE_ACTION_API_PATH, RewriteResponseCase } from '../../common'; import { ActionsRequestHandlerContext, FindActionResult } from '../types'; import { verifyAccessAndContext } from './verify_access_and_context'; -import { RewriteResponseCase } from './rewrite_request_case'; const rewriteBodyRes: RewriteResponseCase = (results) => { return results.map(({ actionTypeId, isPreconfigured, referencedByCount, ...res }) => ({ diff --git a/x-pack/plugins/actions/server/routes/update.ts b/x-pack/plugins/actions/server/routes/update.ts index af55fa32b76ca..d1758717e80f9 100644 --- a/x-pack/plugins/actions/server/routes/update.ts +++ b/x-pack/plugins/actions/server/routes/update.ts @@ -8,10 +8,9 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'kibana/server'; import { ILicenseState } from '../lib'; -import { BASE_ACTION_API_PATH } from '../../common'; +import { BASE_ACTION_API_PATH, RewriteResponseCase } from '../../common'; import { ActionResult, ActionsRequestHandlerContext } from '../types'; import { verifyAccessAndContext } from './verify_access_and_context'; -import { RewriteResponseCase } from './rewrite_request_case'; const paramSchema = schema.object({ id: schema.string(), diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 6332985112c4c..19322fed7363e 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -116,9 +116,8 @@ This is the primary function for an alert type. Whenever the alert needs to exec |Property|Description| |---|---| -|services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana but in the context of the user who created the alert when security is enabled.| +|services.scopedClusterClient|This is an instance of the Elasticsearch client. Use this to do Elasticsearch queries in the context of the user who created the alert when security is enabled.| |services.savedObjectsClient|This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user who created the alert (only when security isenabled).| -|services.getLegacyScopedClusterClient|This function returns an instance of the LegacyScopedClusterClient scoped to the user who created the alert when security is enabled.| |services.alertInstanceFactory(id)|This [alert instance factory](#alert-instance-factory) creates instances of alerts and must be used in order to execute actions. The id you give to the alert instance factory is a unique identifier to the alert instance.| |services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| |startedAt|The date and time the alert type started execution.| diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index f3c40a6788967..df9a3c5ddf169 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -70,10 +70,8 @@ const createAlertServicesMock = < alertInstanceFactory: jest .fn>, [string]>() .mockReturnValue(alertInstanceFactoryMock), - callCluster: elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser, - getLegacyScopedClusterClient: jest.fn(), savedObjectsClient: savedObjectsClientMock.create(), - scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), }; }; export type AlertServicesMock = ReturnType; diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index d85622f301171..ff36ebcd84ba5 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -31,7 +31,6 @@ import { SavedObjectsServiceStart, IContextProvider, ElasticsearchServiceStart, - ILegacyClusterClient, StatusServiceSetup, ServiceStatus, SavedObjectsBulkGetObject, @@ -420,12 +419,8 @@ export class AlertingPlugin { elasticsearch: ElasticsearchServiceStart ): (request: KibanaRequest) => Services { return (request) => ({ - callCluster: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, savedObjectsClient: this.getScopedClientWithAlertSavedObjectType(savedObjects, request), - scopedClusterClient: elasticsearch.client.asScoped(request).asCurrentUser, - getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient) { - return clusterClient.asScoped(request); - }, + scopedClusterClient: elasticsearch.client.asScoped(request), }); } diff --git a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts index a5cc192a337b7..cd1c32a9b2d8f 100644 --- a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts @@ -5,9 +5,11 @@ * 2.0. */ -import { KibanaRequest, KibanaResponseFactory, ILegacyClusterClient } from 'kibana/server'; +import { KibanaRequest, KibanaResponseFactory } from 'kibana/server'; import { identity } from 'lodash'; import type { MethodKeysOf } from '@kbn/utility-types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ScopedClusterClientMock } from '../../../../../src/core/server/elasticsearch/client/mocks'; import { httpServerMock } from '../../../../../src/core/server/mocks'; import { alertsClientMock, AlertsClientMock } from '../alerts_client.mock'; import { AlertsHealth, AlertType } from '../../common'; @@ -18,12 +20,12 @@ export function mockHandlerArguments( { alertsClient = alertsClientMock.create(), listTypes: listTypesRes = [], - esClient = elasticsearchServiceMock.createLegacyClusterClient(), + esClient = elasticsearchServiceMock.createScopedClusterClient(), getFrameworkHealth, }: { alertsClient?: AlertsClientMock; listTypes?: AlertType[]; - esClient?: jest.Mocked; + esClient?: jest.Mocked; getFrameworkHealth?: jest.MockInstance, []> & (() => Promise); }, @@ -37,7 +39,7 @@ export function mockHandlerArguments( const listTypes = jest.fn(() => listTypesRes); return [ ({ - core: { elasticsearch: { legacy: { client: esClient } } }, + core: { elasticsearch: { client: esClient } }, alerting: { listTypes, getAlertsClient() { diff --git a/x-pack/plugins/alerting/server/routes/health.test.ts b/x-pack/plugins/alerting/server/routes/health.test.ts index 22df0e6a00046..75c621e4a0abf 100644 --- a/x-pack/plugins/alerting/server/routes/health.test.ts +++ b/x-pack/plugins/alerting/server/routes/health.test.ts @@ -15,6 +15,8 @@ import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/serv import { alertsClientMock } from '../alerts_client.mock'; import { HealthStatus } from '../types'; import { alertsMock } from '../mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from '../../../../../src/core/server/elasticsearch/client/mocks'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -63,8 +65,10 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createLegacyClusterClient(); - esClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.asInternalUser.transport.request.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}) + ); const [context, req, res] = mockHandlerArguments({ esClient, alertsClient }, {}, ['ok']); @@ -72,9 +76,8 @@ describe('healthRoute', () => { expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); - expect(esClient.callAsInternalUser.mock.calls[0]).toMatchInlineSnapshot(` + expect(esClient.asInternalUser.transport.request.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "transport.request", Object { "method": "GET", "path": "/_xpack/usage", @@ -91,8 +94,10 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createLegacyClusterClient(); - esClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.asInternalUser.transport.request.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}) + ); const [context, req, res] = mockHandlerArguments( { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, @@ -130,8 +135,10 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createLegacyClusterClient(); - esClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.asInternalUser.transport.request.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}) + ); const [context, req, res] = mockHandlerArguments( { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, @@ -169,8 +176,10 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createLegacyClusterClient(); - esClient.callAsInternalUser.mockReturnValue(Promise.resolve({ security: {} })); + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.asInternalUser.transport.request.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ security: {} }) + ); const [context, req, res] = mockHandlerArguments( { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, @@ -208,8 +217,10 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createLegacyClusterClient(); - esClient.callAsInternalUser.mockReturnValue(Promise.resolve({ security: { enabled: true } })); + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.asInternalUser.transport.request.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ security: { enabled: true } }) + ); const [context, req, res] = mockHandlerArguments( { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, @@ -247,9 +258,11 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createLegacyClusterClient(); - esClient.callAsInternalUser.mockReturnValue( - Promise.resolve({ security: { enabled: true, ssl: {} } }) + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.asInternalUser.transport.request.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + security: { enabled: true, ssl: {} }, + }) ); const [context, req, res] = mockHandlerArguments( @@ -288,9 +301,11 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const esClient = elasticsearchServiceMock.createLegacyClusterClient(); - esClient.callAsInternalUser.mockReturnValue( - Promise.resolve({ security: { enabled: true, ssl: { http: { enabled: true } } } }) + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.asInternalUser.transport.request.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + security: { enabled: true, ssl: { http: { enabled: true } } }, + }) ); const [context, req, res] = mockHandlerArguments( diff --git a/x-pack/plugins/alerting/server/routes/health.ts b/x-pack/plugins/alerting/server/routes/health.ts index 9e1f01041e091..de0b14465c5ac 100644 --- a/x-pack/plugins/alerting/server/routes/health.ts +++ b/x-pack/plugins/alerting/server/routes/health.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ApiResponse } from '@elastic/elasticsearch'; import type { AlertingRouter } from '../types'; import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; @@ -39,14 +40,14 @@ export function healthRoute( } try { const { - security: { - enabled: isSecurityEnabled = false, - ssl: { http: { enabled: isTLSEnabled = false } = {} } = {}, - } = {}, - }: XPackUsageSecurity = await context.core.elasticsearch.legacy.client - // `transport.request` is potentially unsafe when combined with untrusted user input. - // Do not augment with such input. - .callAsInternalUser('transport.request', { + body: { + security: { + enabled: isSecurityEnabled = false, + ssl: { http: { enabled: isTLSEnabled = false } = {} } = {}, + } = {}, + }, + }: ApiResponse = await context.core.elasticsearch.client.asInternalUser.transport // Do not augment with such input. // `transport.request` is potentially unsafe when combined with untrusted user input. + .request({ method: 'GET', path: '/_xpack/usage', }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index bb5e0e5830159..a3a7e9bbd9da5 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -206,7 +206,7 @@ describe('Task Runner', () => { expect(call.createdBy).toBe('alert-creator'); expect(call.updatedBy).toBe('alert-updater'); expect(call.services.alertInstanceFactory).toBeTruthy(); - expect(call.services.callCluster).toBeTruthy(); + expect(call.services.scopedClusterClient).toBeTruthy(); expect(call.services).toBeTruthy(); const logger = taskRunnerFactoryInitializerParams.logger; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 2b749b866d3a0..23aed1070a31a 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -13,9 +13,7 @@ import { PluginSetupContract, PluginStartContract } from './plugin'; import { AlertsClient } from './alerts_client'; export * from '../common'; import { - ElasticsearchClient, - ILegacyClusterClient, - ILegacyScopedClusterClient, + IScopedClusterClient, KibanaRequest, SavedObjectAttributes, SavedObjectsClientContract, @@ -63,13 +61,8 @@ export interface AlertingRequestHandlerContext extends RequestHandlerContext { export type AlertingRouter = IRouter; export interface Services { - /** - * @deprecated Use `scopedClusterClient` instead. - */ - callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; savedObjectsClient: SavedObjectsClientContract; - scopedClusterClient: ElasticsearchClient; - getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient): ILegacyScopedClusterClient; + scopedClusterClient: IScopedClusterClient; } export interface AlertServices< 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 8a3870c894e4e..3c9decdf7ba96 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts @@ -5,27 +5,31 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from '../../../../../src/core/server/elasticsearch/client/mocks'; import { getTotalCountInUse } from './alerts_telemetry'; describe('alerts telemetry', () => { test('getTotalCountInUse should replace first "." symbol to "__" in alert types names', async () => { - const mockEsClient = jest.fn(); - mockEsClient.mockReturnValue({ - aggregations: { - byAlertTypeId: { - value: { - types: { '.index-threshold': 2, 'logs.alert.document.count': 1, 'document.test.': 1 }, + const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; + mockEsClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + aggregations: { + byAlertTypeId: { + value: { + types: { '.index-threshold': 2, 'logs.alert.document.count': 1, 'document.test.': 1 }, + }, }, }, - }, - hits: { - hits: [], - }, - }); + hits: { + hits: [], + }, + }) + ); const telemetry = await getTotalCountInUse(mockEsClient, 'test'); - expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); expect(telemetry).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts b/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts index c66110f2647c6..93bed31ce7d50 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; +import { ElasticsearchClient } from 'kibana/server'; import { AlertsUsage } from './types'; const alertTypeMetric = { @@ -36,7 +35,7 @@ const alertTypeMetric = { }; export async function getTotalCountAggregations( - callCluster: LegacyAPICaller, + esClient: ElasticsearchClient, kibanaInex: string ): Promise< Pick< @@ -223,7 +222,7 @@ export async function getTotalCountAggregations( }, }; - const results = await callCluster('search', { + const { body: results } = await esClient.search({ index: kibanaInex, body: { query: { @@ -256,7 +255,7 @@ export async function getTotalCountAggregations( return { count_total: totalAlertsCount, count_by_type: Object.keys(results.aggregations.byAlertTypeId.value.types).reduce( - // ES DSL aggregations are returned as `any` by callCluster + // ES DSL aggregations are returned as `any` by esClient.search // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, @@ -295,8 +294,8 @@ export async function getTotalCountAggregations( }; } -export async function getTotalCountInUse(callCluster: LegacyAPICaller, kibanaInex: string) { - const searchResult: SearchResponse = await callCluster('search', { +export async function getTotalCountInUse(esClient: ElasticsearchClient, kibanaInex: string) { + const { body: searchResult } = await esClient.search({ index: kibanaInex, body: { query: { @@ -316,7 +315,7 @@ export async function getTotalCountInUse(callCluster: LegacyAPICaller, kibanaIne 0 ), countByType: Object.keys(searchResult.aggregations.byAlertTypeId.value.types).reduce( - // ES DSL aggregations are returned as `any` by callCluster + // ES DSL aggregations are returned as `any` by esClient.search // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, diff --git a/x-pack/plugins/alerting/server/usage/task.ts b/x-pack/plugins/alerting/server/usage/task.ts index d03697f2bb11b..043d970ddd231 100644 --- a/x-pack/plugins/alerting/server/usage/task.ts +++ b/x-pack/plugins/alerting/server/usage/task.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Logger, CoreSetup, LegacyAPICaller } from 'kibana/server'; +import { Logger, CoreSetup } from 'kibana/server'; import moment from 'moment'; import { RunContext, @@ -65,17 +65,21 @@ async function scheduleTasks(logger: Logger, taskManager: TaskManagerStartContra export function telemetryTaskRunner(logger: Logger, core: CoreSetup, kibanaIndex: string) { return ({ taskInstance }: RunContext) => { const { state } = taskInstance; - const callCluster = (...args: Parameters) => { - return core.getStartServices().then(([{ elasticsearch: { legacy: { client } } }]) => - client.callAsInternalUser(...args) + const getEsClient = () => + core.getStartServices().then( + ([ + { + elasticsearch: { client }, + }, + ]) => client.asInternalUser ); - }; return { async run() { + const esClient = await getEsClient(); return Promise.all([ - getTotalCountAggregations(callCluster, kibanaIndex), - getTotalCountInUse(callCluster, kibanaIndex), + getTotalCountAggregations(esClient, kibanaIndex), + getTotalCountInUse(esClient, kibanaIndex), ]) .then(([totalCountAggregations, totalInUse]) => { return { diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx index 734eb7c236fdf..07afb2fece283 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx @@ -123,9 +123,7 @@ export function AgentConfigurationCreateEdit({ {i18n.translate('xpack.apm.agentConfig.newConfig.description', { - defaultMessage: `This allows you to fine-tune your agent configuration directly in - Kibana. Best of all, changes are automatically propagated to your APM - agents so there’s no need to redeploy.`, + defaultMessage: `Fine-tune your agent configuration from within the APM app. Changes are automatically propagated to your APM agents, so there’s no need to redeploy.`, })} 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 d0b8e6fd8fba2..bef0dfc22280c 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 @@ -61,14 +61,6 @@ export function AgentConfigurationList({ status, data, refetch }: Props) { )} } - body={ -

- {i18n.translate('xpack.apm.agentConfig.configTable.emptyPromptText', { - defaultMessage: - "Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration.", - })} -

- } actions={

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

+ + + {i18n.translate('xpack.apm.settings.agentConfig.descriptionText', { + defaultMessage: `Fine-tune your agent configuration from within the APM app. Changes are automatically propagated to your APM agents, so there’s no need to redeploy.`, + })} + - +

{i18n.translate( 'xpack.apm.agentConfig.configurationsPanelTitle', 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 e93aced10a744..9722c99990e3f 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 @@ -178,8 +178,8 @@ export function ApmIndices() { })}

- - + + {i18n.translate('xpack.apm.settings.apmIndices.description', { defaultMessage: `The APM UI uses index patterns to query your APM indices. If you've customized the index names that APM Server writes events to, you may need to update these patterns for the APM UI to work. Settings here take precedence over those set in kibana.yml.`, })} 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 90bc83eeffde9..4b4bc2e8feeab 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -9,9 +9,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, - EuiSpacer, EuiTitle, EuiText, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; @@ -82,14 +82,14 @@ export function CustomLinkOverview() { /> )} - + - + - + @@ -117,11 +117,11 @@ export function CustomLinkOverview() { )} - - + + {i18n.translate('xpack.apm.settings.customizeUI.customLink.info', { defaultMessage: - 'These links will be shown in the Actions context menu for transactions.', + 'These links will be shown in the Actions context menu in selected areas of the app, e.g. by the transactions detail.', })} {hasValidLicense ? ( diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx index a408fbe6c09b4..fabd70cec6647 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiTitle, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { CustomLinkOverview } from './CustomLink'; @@ -15,11 +15,17 @@ export function CustomizeUI() { <>

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

+ + + {i18n.translate('xpack.apm.settings.customizeApp.description', { + defaultMessage: `Extend the APM app experience with the following settings.`, + })} + diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx index 0c93c7e3a7aba..72f0249f07bf6 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx @@ -73,8 +73,8 @@ export function AnomalyDetection() { })}
- - + + {i18n.translate('xpack.apm.settings.anomalyDetection.descriptionText', { defaultMessage: `Machine Learning's anomaly detection integration enables application health status indicators for services in each configured environment by identifying anomalies in latency.`, })} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index 6be4a5889211e..9c69d692876b0 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -69,7 +69,7 @@ export function JobsList({ data, status, onAddEnvironments }: Props) { - +

{i18n.translate( 'xpack.apm.settings.anomalyDetection.jobList.environments', @@ -91,8 +91,7 @@ export function JobsList({ data, status, onAddEnvironments }: Props) { - - + {i18n.translate( 'xpack.apm.correlations.customize.fieldHelpTextDocsLink', diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 97be35ec6f5b9..0814c6d95b96a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -83,7 +83,6 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { return ( <> - diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index 1769119593c0e..84a2dad278a9b 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React from 'react'; import { useHistory } from 'react-router-dom'; +import { useUiTracker } from '../../../../../observability/public'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { getDateDifference } from '../../../../common/utils/formatters'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -132,6 +133,7 @@ function getSelectOptions({ } export function TimeComparison() { + const trackApmEvent = useUiTracker({ app: 'apm' }); const history = useHistory(); const { isMedium, isLarge } = useBreakPoints(); const { @@ -181,9 +183,17 @@ export function TimeComparison() { })} checked={comparisonEnabled} onChange={() => { + const nextComparisonEnabledValue = !comparisonEnabled; + if (nextComparisonEnabledValue === false) { + trackApmEvent({ + metric: 'time_comparison_disabled', + }); + } urlHelpers.push(history, { query: { - comparisonEnabled: Boolean(!comparisonEnabled).toString(), + comparisonEnabled: Boolean( + nextComparisonEnabledValue + ).toString(), }, }); }} @@ -191,6 +201,9 @@ export function TimeComparison() { } onChange={(e) => { + trackApmEvent({ + metric: `time_comparison_type_change_${e.target.value}`, + }); urlHelpers.push(history, { query: { comparisonType: e.target.value, 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 d5706ac9063ed..c4fef64f515d1 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { ApiResponse } from '@elastic/elasticsearch'; import { ThresholdMetActionGroupId } from '../../../common/alert_types'; import { ESSearchRequest, @@ -23,8 +24,8 @@ export function alertingEsClient( ThresholdMetActionGroupId >, params: TParams -): Promise> { - return services.callCluster('search', { +): Promise>> { + return services.scopedClusterClient.asCurrentUser.search({ ...params, ignore_unavailable: true, }); 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 4d403be84a2b2..167cb133102f2 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 @@ -13,6 +13,9 @@ import { AlertingPlugin } from '../../../../alerting/server'; import { APMConfig } from '../..'; import { registerErrorCountAlertType } from './register_error_count_alert_type'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; type Operator = (source: Rx.Observable) => Rx.Observable; const pipeClosure = (fn: Operator): Operator => { @@ -43,16 +46,20 @@ describe('Error count alert', () => { expect(alertExecutor).toBeDefined(); const services = { - callCluster: jest.fn(() => ({ + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn(), + }; + const params = { threshold: 1 }; + + services.scopedClusterClient.asCurrentUser.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { total: { value: 0, }, }, - })), - alertInstanceFactory: jest.fn(), - }; - const params = { threshold: 1 }; + }) + ); await alertExecutor!({ services, params }); expect(services.alertInstanceFactory).not.toBeCalled(); @@ -74,7 +81,13 @@ describe('Error count alert', () => { const scheduleActions = jest.fn(); const services = { - callCluster: jest.fn(() => ({ + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 1, windowSize: 5, windowUnit: 'm' }; + + services.scopedClusterClient.asCurrentUser.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { total: { value: 2, @@ -98,10 +111,8 @@ describe('Error count alert', () => { ], }, }, - })), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; - const params = { threshold: 1, windowSize: 5, windowUnit: 'm' }; + }) + ); await alertExecutor!({ services, params }); [ @@ -158,7 +169,13 @@ describe('Error count alert', () => { const scheduleActions = jest.fn(); const services = { - callCluster: jest.fn(() => ({ + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 1, windowSize: 5, windowUnit: 'm' }; + + services.scopedClusterClient.asCurrentUser.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { total: { value: 2, @@ -176,10 +193,8 @@ describe('Error count alert', () => { ], }, }, - })), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; - const params = { threshold: 1, windowSize: 5, windowUnit: 'm' }; + }) + ); await alertExecutor!({ services, params }); ['apm.error_rate_foo', 'apm.error_rate_bar'].forEach((instanceName) => diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 70b41da6917ef..0120891a8f868 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -127,7 +127,7 @@ export function registerErrorCountAlertType({ }, }; - const response = await alertingEsClient(services, searchParams); + const { body: response } = await alertingEsClient(services, searchParams); const errorCount = response.hits.total.value; if (errorCount > alertParams.threshold) { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index bb8e67574e9ad..500e0744d5638 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -122,7 +122,7 @@ export function registerTransactionDurationAlertType({ }, }; - const response = await alertingEsClient(services, searchParams); + const { body: response } = await alertingEsClient(services, searchParams); if (!response.aggregations) { return; 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 068eb9b1ccef4..c18f29b6267e0 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 @@ -11,6 +11,9 @@ import { toArray, map } from 'rxjs/operators'; import { AlertingPlugin } from '../../../../alerting/server'; import { APMConfig } from '../..'; import { registerTransactionErrorRateAlertType } from './register_transaction_error_rate_alert_type'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; type Operator = (source: Rx.Observable) => Rx.Observable; const pipeClosure = (fn: Operator): Operator => { @@ -41,16 +44,20 @@ describe('Transaction error rate alert', () => { expect(alertExecutor).toBeDefined(); const services = { - callCluster: jest.fn(() => ({ + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn(), + }; + const params = { threshold: 1 }; + + services.scopedClusterClient.asCurrentUser.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { total: { value: 0, }, }, - })), - alertInstanceFactory: jest.fn(), - }; - const params = { threshold: 1 }; + }) + ); await alertExecutor!({ services, params }); expect(services.alertInstanceFactory).not.toBeCalled(); @@ -72,7 +79,13 @@ describe('Transaction error rate alert', () => { const scheduleActions = jest.fn(); const services = { - callCluster: jest.fn(() => ({ + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; + + services.scopedClusterClient.asCurrentUser.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { total: { value: 4, @@ -113,10 +126,8 @@ describe('Transaction error rate alert', () => { ], }, }, - })), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; - const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; + }) + ); await alertExecutor!({ services, params }); [ @@ -177,7 +188,13 @@ describe('Transaction error rate alert', () => { const scheduleActions = jest.fn(); const services = { - callCluster: jest.fn(() => ({ + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; + + services.scopedClusterClient.asCurrentUser.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { total: { value: 4, @@ -204,10 +221,8 @@ describe('Transaction error rate alert', () => { ], }, }, - })), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; - const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; + }) + ); await alertExecutor!({ services, params }); [ @@ -251,7 +266,13 @@ describe('Transaction error rate alert', () => { const scheduleActions = jest.fn(); const services = { - callCluster: jest.fn(() => ({ + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn(() => ({ scheduleActions })), + }; + const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; + + services.scopedClusterClient.asCurrentUser.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { total: { value: 4, @@ -265,10 +286,8 @@ describe('Transaction error rate alert', () => { buckets: [{ key: 'foo' }, { key: 'bar' }], }, }, - })), - alertInstanceFactory: jest.fn(() => ({ scheduleActions })), - }; - const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; + }) + ); await alertExecutor!({ services, params }); [ diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index ef5407500349d..0b2684cdaf083 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -134,7 +134,7 @@ export function registerTransactionErrorRateAlertType({ }, }; - const response = await alertingEsClient(services, searchParams); + const { body: response } = await alertingEsClient(services, searchParams); if (!response.aggregations) { return; } 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 cb9d37d56b867..e41a88649c5ff 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 @@ -71,12 +71,9 @@ export const getDestinationMap = ({ }, aggs: { sample: { - top_metrics: { - metrics: [ - { field: SPAN_TYPE }, - { field: SPAN_SUBTYPE }, - { field: SPAN_ID }, - ] as const, + top_hits: { + size: 1, + _source: [SPAN_TYPE, SPAN_SUBTYPE, SPAN_ID], sort: { '@timestamp': 'desc', }, @@ -91,15 +88,15 @@ export const getDestinationMap = ({ const outgoingConnections = response.aggregations?.connections.buckets.map((bucket) => { - const fieldValues = bucket.sample.top[0].metrics; + const sample = bucket.sample.hits.hits[0]._source; return { [SPAN_DESTINATION_SERVICE_RESOURCE]: String( bucket.key[SPAN_DESTINATION_SERVICE_RESOURCE] ), - [SPAN_ID]: (fieldValues[SPAN_ID] ?? '') as string, - [SPAN_TYPE]: (fieldValues[SPAN_TYPE] ?? '') as string, - [SPAN_SUBTYPE]: (fieldValues[SPAN_SUBTYPE] ?? '') as string, + [SPAN_ID]: sample.span.id, + [SPAN_TYPE]: sample.span.type, + [SPAN_SUBTYPE]: sample.span.subtype, }; }) ?? []; 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 fb7544e5fcb8d..1771b5ead68a7 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 @@ -118,11 +118,8 @@ export async function getBuckets({ }), aggs: { samples: { - top_metrics: { - metrics: [ - { field: TRANSACTION_ID }, - { field: TRACE_ID }, - ] as const, + top_hits: { + _source: [TRANSACTION_ID, TRACE_ID], size: 10, sort: { _score: 'desc', @@ -138,11 +135,12 @@ export async function getBuckets({ return ( response.aggregations?.distribution.buckets.map((bucket) => { + const samples = bucket.samples.hits.hits; return { key: bucket.key, - samples: bucket.samples.top.map((sample) => ({ - traceId: sample.metrics[TRACE_ID] as string, - transactionId: sample.metrics[TRANSACTION_ID] as string, + samples: samples.map(({ _source: sample }) => ({ + traceId: sample.trace.id, + transactionId: sample.transaction.id, })), }; }) ?? [] diff --git a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts index 5bd80f500fd2b..468585ddd23cb 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts @@ -183,6 +183,7 @@ export async function getLatencyPeriods({ latencyAggregationType, comparisonStart, comparisonEnd, + kuery, }: { serviceName: string; transactionType: string | undefined; @@ -192,6 +193,7 @@ export async function getLatencyPeriods({ latencyAggregationType: LatencyAggregationType; comparisonStart?: number; comparisonEnd?: number; + kuery?: string; }) { const { start, end } = setup; const options = { @@ -200,6 +202,7 @@ export async function getLatencyPeriods({ transactionName, setup, searchAggregatedTransactions, + kuery, }; const currentPeriodPromise = getLatencyTimeseries({ diff --git a/x-pack/plugins/cases/common/api/index.ts b/x-pack/plugins/cases/common/api/index.ts index 7780564089d3d..2ef03dd96e315 100644 --- a/x-pack/plugins/cases/common/api/index.ts +++ b/x-pack/plugins/cases/common/api/index.ts @@ -7,6 +7,7 @@ export * from './cases'; export * from './connectors'; +export * from './helpers'; export * from './runtime_types'; export * from './saved_object'; export * from './user'; diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 1e7cff99a00bd..d779ccd0b7ab0 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { DEFAULT_MAX_SIGNALS } from '../../security_solution/common/constants'; - +// The DEFAULT_MAX_SIGNALS value should match the one in `x-pack/plugins/security_solution/common/constants.ts` +// If either changes, engineer should ensure both values are updated +const DEFAULT_MAX_SIGNALS = 100; export const APP_ID = 'cases'; /** diff --git a/x-pack/plugins/cases/common/index.ts b/x-pack/plugins/cases/common/index.ts new file mode 100644 index 0000000000000..37c11172b50b2 --- /dev/null +++ b/x-pack/plugins/cases/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './constants'; +export * from './api'; diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index 1aaf84decbe36..27b36d7e86e1f 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -2,12 +2,13 @@ "configPath": ["xpack", "cases"], "id": "cases", "kibanaVersion": "kibana", - "requiredPlugins": ["actions", "securitySolution"], + "extraPublicDirs": ["common"], + "requiredPlugins": ["actions", "esUiShared", "kibanaReact", "triggersActionsUi"], "optionalPlugins": [ "spaces", "security" ], "server": true, - "ui": false, + "ui": true, "version": "8.0.0" } diff --git a/x-pack/plugins/cases/public/common/errors.ts b/x-pack/plugins/cases/public/common/errors.ts new file mode 100644 index 0000000000000..6edef08c1f4b1 --- /dev/null +++ b/x-pack/plugins/cases/public/common/errors.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { has } from 'lodash/fp'; + +export interface AppError { + name: string; + message: string; + body: { + message: string; + }; +} + +export interface KibanaError extends AppError { + body: { + message: string; + statusCode: number; + }; +} + +export interface CasesAppError extends AppError { + body: { + message: string; + status_code: number; + }; +} + +export const isKibanaError = (error: unknown): error is KibanaError => + has('message', error) && has('body.message', error) && has('body.statusCode', error); + +export const isCasesAppError = (error: unknown): error is CasesAppError => + has('message', error) && has('body.message', error) && has('body.status_code', error); + +export const isAppError = (error: unknown): error is AppError => + isKibanaError(error) || isCasesAppError(error); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts new file mode 100644 index 0000000000000..392b71befe2b4 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { notificationServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { + createKibanaContextProviderMock, + createStartServicesMock, + createWithKibanaMock, +} from '../kibana_react.mock'; + +export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() => '8.0.0') }; +export const useKibana = jest.fn().mockReturnValue({ + services: createStartServicesMock(), +}); + +export const useHttp = jest.fn().mockReturnValue(createStartServicesMock().http); +export const useTimeZone = jest.fn(); +export const useDateFormat = jest.fn(); +export const useBasePath = jest.fn(() => '/test/base/path'); +export const useToasts = jest + .fn() + .mockReturnValue(notificationServiceMock.createStartContract().toasts); +export const useCurrentUser = jest.fn(); +export const withKibana = jest.fn(createWithKibanaMock()); +export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock()); +export const useGetUserSavedObjectPermissions = jest.fn(); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/index.ts new file mode 100644 index 0000000000000..a7f3c1e70ced5 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './kibana_react'; +export * from './services'; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts new file mode 100644 index 0000000000000..326163f6cdc03 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { RecursivePartial } from '@elastic/eui/src/components/common'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { StartServices } from '../../../types'; +import { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common'; + +export const createStartServicesMock = (): StartServices => + (coreMock.createStart() as unknown) as StartServices; + +export const createWithKibanaMock = () => { + const services = createStartServicesMock(); + + return (Component: unknown) => (props: unknown) => { + return React.createElement(Component as string, { ...(props as object), kibana: { services } }); + }; +}; + +export const createKibanaContextProviderMock = () => { + const services = createStartServicesMock(); + + return ({ children }: { children: React.ReactNode }) => + React.createElement(KibanaContextProvider, { services }, children); +}; + +export const getMockTheme = (partialTheme: RecursivePartial): EuiTheme => + partialTheme as EuiTheme; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.ts new file mode 100644 index 0000000000000..e23fad392040c --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.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 { + KibanaContextProvider, + useKibana, +} from '../../../../../../../src/plugins/kibana_react/public'; +import { StartServices } from '../../../types'; + +const useTypedKibana = () => useKibana(); + +export { KibanaContextProvider, useTypedKibana as useKibana }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/services.ts b/x-pack/plugins/cases/public/common/lib/kibana/services.ts new file mode 100644 index 0000000000000..94487bd3ca5e9 --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/services.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from 'kibana/public'; + +type GlobalServices = Pick; + +export class KibanaServices { + private static kibanaVersion?: string; + private static services?: GlobalServices; + + public static init({ http, kibanaVersion }: GlobalServices & { kibanaVersion: string }) { + this.services = { http }; + this.kibanaVersion = kibanaVersion; + } + + public static get(): GlobalServices { + if (!this.services) { + this.throwUninitializedError(); + } + + return this.services; + } + + public static getKibanaVersion(): string { + if (!this.kibanaVersion) { + this.throwUninitializedError(); + } + + return this.kibanaVersion; + } + + private static throwUninitializedError(): never { + throw new Error( + 'Kibana services not initialized - are you trying to import this module from outside of the Cases app?' + ); + } +} diff --git a/x-pack/plugins/cases/public/common/mock/index.ts b/x-pack/plugins/cases/public/common/mock/index.ts new file mode 100644 index 0000000000000..add4c1c206dd4 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './test_providers'; diff --git a/x-pack/plugins/cases/public/common/mock/kibana_react.mock.ts b/x-pack/plugins/cases/public/common/mock/kibana_react.mock.ts new file mode 100644 index 0000000000000..274462aec575d --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/kibana_react.mock.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 { CoreStart } from 'kibana/public'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import React from 'react'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public/context'; + +export const createStartServicesMock = (): CoreStart => { + const core = coreMock.createStart(); + return (core as unknown) as CoreStart; +}; +export const createKibanaContextProviderMock = () => { + const services = coreMock.createStart(); + + return ({ children }: { children: React.ReactNode }) => + React.createElement(KibanaContextProvider, { services }, children); +}; diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx new file mode 100644 index 0000000000000..4e40f3b3cb745 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { I18nProvider } from '@kbn/i18n/react'; +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { ThemeProvider } from 'styled-components'; +import { createKibanaContextProviderMock, createStartServicesMock } from './kibana_react.mock'; +import { FieldHook } from '../shared_imports'; + +interface Props { + children: React.ReactNode; +} + +export const kibanaObservable = new BehaviorSubject(createStartServicesMock()); + +window.scrollTo = jest.fn(); +const MockKibanaContextProvider = createKibanaContextProviderMock(); + +/** A utility for wrapping children in the providers required to run most tests */ +const TestProvidersComponent: React.FC = ({ children }) => ( + + + ({ eui: euiDarkVars, darkMode: true })}>{children} + + +); + +export const TestProviders = React.memo(TestProvidersComponent); + +export const useFormFieldMock = (options?: Partial>): FieldHook => { + return { + path: 'path', + type: 'type', + value: ('mockedValue' as unknown) as T, + isPristine: false, + isValidating: false, + isValidated: false, + isChangingValue: false, + errors: [], + isValid: true, + getErrorsMessages: jest.fn(), + onChange: jest.fn(), + setValue: jest.fn(), + setErrors: jest.fn(), + clearErrors: jest.fn(), + validate: jest.fn(), + reset: jest.fn(), + __isIncludedInOutput: true, + __serializeValue: jest.fn(), + ...options, + }; +}; diff --git a/x-pack/plugins/cases/public/common/shared_imports.ts b/x-pack/plugins/cases/public/common/shared_imports.ts new file mode 100644 index 0000000000000..675204076b02a --- /dev/null +++ b/x-pack/plugins/cases/public/common/shared_imports.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. + */ + +export { + getUseField, + getFieldValidityAndErrorMessage, + FieldHook, + FieldValidateResponse, + FIELD_TYPES, + Form, + FormData, + FormDataProvider, + FormHook, + FormSchema, + UseField, + UseMultiFields, + useForm, + useFormContext, + useFormData, + ValidationError, + ValidationFunc, + VALIDATION_TYPES, +} from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; +export { + Field, + SelectField, +} from '../../../../../src/plugins/es_ui_shared/static/forms/components'; +export { fieldValidators } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers'; +export { ERROR_CODE } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/plugins/cases/public/common/test_utils.ts b/x-pack/plugins/cases/public/common/test_utils.ts new file mode 100644 index 0000000000000..f6ccf28bcb643 --- /dev/null +++ b/x-pack/plugins/cases/public/common/test_utils.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. + */ + +/** + * Convenience utility to remove text appended to links by EUI + */ +export const removeExternalLinkText = (str: string) => + str.replace(/\(opens in a new tab or window\)/g, ''); diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts new file mode 100644 index 0000000000000..881acb9d4c90e --- /dev/null +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -0,0 +1,252 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 SAVED_OBJECT_NO_PERMISSIONS_TITLE = i18n.translate( + 'xpack.cases.caseSavedObjectNoPermissionsTitle', + { + defaultMessage: 'Kibana feature privileges required', + } +); + +export const SAVED_OBJECT_NO_PERMISSIONS_MSG = i18n.translate( + 'xpack.cases.caseSavedObjectNoPermissionsMessage', + { + defaultMessage: + 'To view cases, you must have privileges for the Saved Object Management feature in the Kibana space. For more information, contact your Kibana administrator.', + } +); + +export const BACK_TO_ALL = i18n.translate('xpack.cases.caseView.backLabel', { + defaultMessage: 'Back to cases', +}); + +export const CANCEL = i18n.translate('xpack.cases.caseView.cancel', { + defaultMessage: 'Cancel', +}); + +export const DELETE_CASE = i18n.translate('xpack.cases.confirmDeleteCase.deleteCase', { + defaultMessage: 'Delete case', +}); + +export const DELETE_CASES = i18n.translate('xpack.cases.confirmDeleteCase.deleteCases', { + defaultMessage: 'Delete cases', +}); + +export const NAME = i18n.translate('xpack.cases.caseView.name', { + defaultMessage: 'Name', +}); + +export const OPENED_ON = i18n.translate('xpack.cases.caseView.openedOn', { + defaultMessage: 'Opened on', +}); + +export const CLOSED_ON = i18n.translate('xpack.cases.caseView.closedOn', { + defaultMessage: 'Closed on', +}); + +export const REPORTER = i18n.translate('xpack.cases.caseView.reporterLabel', { + defaultMessage: 'Reporter', +}); + +export const PARTICIPANTS = i18n.translate('xpack.cases.caseView.particpantsLabel', { + defaultMessage: 'Participants', +}); + +export const CREATE_BC_TITLE = i18n.translate('xpack.cases.caseView.breadcrumb', { + defaultMessage: 'Create', +}); + +export const CREATE_TITLE = i18n.translate('xpack.cases.caseView.create', { + defaultMessage: 'Create new case', +}); + +export const DESCRIPTION = i18n.translate('xpack.cases.caseView.description', { + defaultMessage: 'Description', +}); + +export const DESCRIPTION_REQUIRED = i18n.translate( + 'xpack.cases.createCase.descriptionFieldRequiredError', + { + defaultMessage: 'A description is required.', + } +); + +export const COMMENT_REQUIRED = i18n.translate('xpack.cases.caseView.commentFieldRequiredError', { + defaultMessage: 'A comment is required.', +}); + +export const REQUIRED_FIELD = i18n.translate('xpack.cases.caseView.fieldRequiredError', { + defaultMessage: 'Required field', +}); + +export const EDIT = i18n.translate('xpack.cases.caseView.edit', { + defaultMessage: 'Edit', +}); + +export const OPTIONAL = i18n.translate('xpack.cases.caseView.optional', { + defaultMessage: 'Optional', +}); + +export const PAGE_TITLE = i18n.translate('xpack.cases.pageTitle', { + defaultMessage: 'Cases', +}); + +export const CREATE_CASE = i18n.translate('xpack.cases.caseView.createCase', { + defaultMessage: 'Create case', +}); + +export const CLOSE_CASE = i18n.translate('xpack.cases.caseView.closeCase', { + defaultMessage: 'Close case', +}); + +export const MARK_CASE_IN_PROGRESS = i18n.translate('xpack.cases.caseView.markInProgress', { + defaultMessage: 'Mark in progress', +}); + +export const REOPEN_CASE = i18n.translate('xpack.cases.caseView.reopenCase', { + defaultMessage: 'Reopen case', +}); + +export const OPEN_CASE = i18n.translate('xpack.cases.caseView.openCase', { + defaultMessage: 'Open case', +}); + +export const CASE_NAME = i18n.translate('xpack.cases.caseView.caseName', { + defaultMessage: 'Case name', +}); + +export const TO = i18n.translate('xpack.cases.caseView.to', { + defaultMessage: 'to', +}); + +export const TAGS = i18n.translate('xpack.cases.caseView.tags', { + defaultMessage: 'Tags', +}); + +export const ACTIONS = i18n.translate('xpack.cases.allCases.actions', { + defaultMessage: 'Actions', +}); + +export const NO_TAGS_AVAILABLE = i18n.translate('xpack.cases.allCases.noTagsAvailable', { + defaultMessage: 'No tags available', +}); + +export const NO_REPORTERS_AVAILABLE = i18n.translate('xpack.cases.caseView.noReportersAvailable', { + defaultMessage: 'No reporters available.', +}); + +export const COMMENTS = i18n.translate('xpack.cases.allCases.comments', { + defaultMessage: 'Comments', +}); + +export const TAGS_HELP = i18n.translate('xpack.cases.createCase.fieldTagsHelpText', { + defaultMessage: + 'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.', +}); + +export const NO_TAGS = i18n.translate('xpack.cases.caseView.noTags', { + defaultMessage: 'No tags are currently assigned to this case.', +}); + +export const TITLE_REQUIRED = i18n.translate('xpack.cases.createCase.titleFieldRequiredError', { + defaultMessage: 'A title is required.', +}); + +export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', { + defaultMessage: 'Configure cases', +}); + +export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.cases.configureCasesButton', { + defaultMessage: 'Edit external connection', +}); + +export const ADD_COMMENT = i18n.translate('xpack.cases.caseView.comment.addComment', { + defaultMessage: 'Add comment', +}); + +export const ADD_COMMENT_HELP_TEXT = i18n.translate( + 'xpack.cases.caseView.comment.addCommentHelpText', + { + defaultMessage: 'Add a new comment...', + } +); + +export const SAVE = i18n.translate('xpack.cases.caseView.description.save', { + defaultMessage: 'Save', +}); + +export const GO_TO_DOCUMENTATION = i18n.translate('xpack.cases.caseView.goToDocumentationButton', { + defaultMessage: 'View documentation', +}); + +export const CONNECTORS = i18n.translate('xpack.cases.caseView.connectors', { + defaultMessage: 'External Incident Management System', +}); + +export const EDIT_CONNECTOR = i18n.translate('xpack.cases.caseView.editConnector', { + defaultMessage: 'Change external incident management system', +}); + +export const NO_CONNECTOR = i18n.translate('xpack.cases.common.noConnector', { + defaultMessage: 'No connector selected', +}); + +export const UNKNOWN = i18n.translate('xpack.cases.caseView.unknown', { + defaultMessage: 'Unknown', +}); + +export const MARKED_CASE_AS = i18n.translate('xpack.cases.caseView.markedCaseAs', { + defaultMessage: 'marked case as', +}); + +export const OPEN_CASES = i18n.translate('xpack.cases.caseTable.openCases', { + defaultMessage: 'Open cases', +}); + +export const CLOSED_CASES = i18n.translate('xpack.cases.caseTable.closedCases', { + defaultMessage: 'Closed cases', +}); + +export const IN_PROGRESS_CASES = i18n.translate('xpack.cases.caseTable.inProgressCases', { + defaultMessage: 'In progress cases', +}); + +export const SYNC_ALERTS_SWITCH_LABEL_ON = i18n.translate( + 'xpack.cases.settings.syncAlertsSwitchLabelOn', + { + defaultMessage: 'On', + } +); + +export const SYNC_ALERTS_SWITCH_LABEL_OFF = i18n.translate( + 'xpack.cases.settings.syncAlertsSwitchLabelOff', + { + defaultMessage: 'Off', + } +); + +export const SYNC_ALERTS_HELP = i18n.translate('xpack.cases.components.create.syncAlertHelpText', { + defaultMessage: + 'Enabling this option will sync the status of alerts in this case with the case status.', +}); + +export const ALERT = i18n.translate('xpack.cases.common.alertLabel', { + defaultMessage: 'Alert', +}); + +export const ALERT_ADDED_TO_CASE = i18n.translate('xpack.cases.common.alertAddedToCase', { + defaultMessage: 'added to case', +}); + +export const SELECTABLE_MESSAGE_COLLECTIONS = i18n.translate( + 'xpack.cases.common.allCases.table.selectableMessageCollections', + { + defaultMessage: 'Cases with sub-cases cannot be selected', + } +); diff --git a/x-pack/plugins/cases/public/components/__mock__/form.ts b/x-pack/plugins/cases/public/components/__mock__/form.ts new file mode 100644 index 0000000000000..6d3e8353e630a --- /dev/null +++ b/x-pack/plugins/cases/public/components/__mock__/form.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useForm } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; +import { useFormData } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data'; + +jest.mock('../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'); +jest.mock( + '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data' +); + +export const mockFormHook = { + isSubmitted: false, + isSubmitting: false, + isValid: true, + submit: jest.fn(), + subscribe: jest.fn(), + setFieldValue: jest.fn(), + setFieldErrors: jest.fn(), + getFields: jest.fn(), + getFormData: jest.fn(), + /* Returns a list of all errors in the form */ + getErrors: jest.fn(), + reset: jest.fn(), + __options: {}, + __formData$: {}, + __addField: jest.fn(), + __removeField: jest.fn(), + __validateFields: jest.fn(), + __updateFormDataAt: jest.fn(), + __readFieldConfigFromSchema: jest.fn(), + __getFieldDefaultValue: jest.fn(), +}; + +export const getFormMock = (sampleData: any) => ({ + ...mockFormHook, + submit: () => + Promise.resolve({ + data: sampleData, + isValid: true, + }), + getFormData: () => sampleData, +}); + +export const useFormMock = useForm as jest.Mock; +export const useFormDataMock = useFormData as jest.Mock; diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx new file mode 100644 index 0000000000000..e3abbeadd2d3c --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.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 { ConnectorTypes } from '../../../../common'; +import { ActionConnector } from '../../../containers/configure/types'; +import { UseConnectorsResponse } from '../../../containers/configure/use_connectors'; +import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; +import { UseActionTypesResponse } from '../../../containers/configure/use_action_types'; +import { connectorsMock, actionTypesMock } from '../../../containers/configure/mock'; +export { mappings } from '../../../containers/configure/mock'; +export const connectors: ActionConnector[] = connectorsMock; + +export const searchURL = + '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; + +export const useCaseConfigureResponse: ReturnUseCaseConfigure = { + closureType: 'close-by-user', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + currentConfiguration: { + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + }, + firstLoad: false, + loading: false, + mappings: [], + persistCaseConfigure: jest.fn(), + persistLoading: false, + refetchCaseConfigure: jest.fn(), + setClosureType: jest.fn(), + setConnector: jest.fn(), + setCurrentConfiguration: jest.fn(), + setMappings: jest.fn(), + version: '', +}; + +export const useConnectorsResponse: UseConnectorsResponse = { + loading: false, + connectors, + refetchConnectors: jest.fn(), +}; + +export const useActionTypesResponse: UseActionTypesResponse = { + loading: false, + actionTypes: actionTypesMock, + refetchActionTypes: jest.fn(), +}; diff --git a/x-pack/plugins/cases/public/components/configure_cases/closure_options.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/closure_options.test.tsx new file mode 100644 index 0000000000000..56123a934d51f --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/closure_options.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; + +import { ClosureOptions, ClosureOptionsProps } from './closure_options'; +import { TestProviders } from '../../common/mock'; +import { ClosureOptionsRadio } from './closure_options_radio'; + +describe('ClosureOptions', () => { + let wrapper: ReactWrapper; + const onChangeClosureType = jest.fn(); + const props: ClosureOptionsProps = { + disabled: false, + closureTypeSelected: 'close-by-user', + onChangeClosureType, + }; + + beforeAll(() => { + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it shows the closure options form group', () => { + expect( + wrapper.find('[data-test-subj="case-closure-options-form-group"]').first().exists() + ).toBe(true); + }); + + test('it shows the closure options form row', () => { + expect(wrapper.find('[data-test-subj="case-closure-options-form-row"]').first().exists()).toBe( + true + ); + }); + + test('it shows closure options', () => { + expect(wrapper.find('[data-test-subj="case-closure-options-radio"]').first().exists()).toBe( + true + ); + }); + + test('it pass the correct props to child', () => { + const closureOptionsRadioComponent = wrapper.find(ClosureOptionsRadio); + expect(closureOptionsRadioComponent.props().disabled).toEqual(false); + expect(closureOptionsRadioComponent.props().closureTypeSelected).toEqual('close-by-user'); + expect(closureOptionsRadioComponent.props().onChangeClosureType).toEqual(onChangeClosureType); + }); + + test('the closure type is changed successfully', () => { + wrapper.find('input[id="close-by-pushing"]').simulate('change'); + + expect(onChangeClosureType).toHaveBeenCalledWith('close-by-pushing'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/closure_options.tsx b/x-pack/plugins/cases/public/components/configure_cases/closure_options.tsx new file mode 100644 index 0000000000000..ba892116320ce --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/closure_options.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +import { ClosureType } from '../../containers/configure/types'; +import { ClosureOptionsRadio } from './closure_options_radio'; +import * as i18n from './translations'; + +export interface ClosureOptionsProps { + closureTypeSelected: ClosureType; + disabled: boolean; + onChangeClosureType: (newClosureType: ClosureType) => void; +} + +const ClosureOptionsComponent: React.FC = ({ + closureTypeSelected, + disabled, + onChangeClosureType, +}) => { + return ( + {i18n.CASE_CLOSURE_OPTIONS_TITLE}

} + description={ + <> +

{i18n.CASE_CLOSURE_OPTIONS_DESC}

+

{i18n.CASE_COLSURE_OPTIONS_SUB_CASES}

+ + } + data-test-subj="case-closure-options-form-group" + > + + + + + ); +}; + +export const ClosureOptions = React.memo(ClosureOptionsComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.test.tsx new file mode 100644 index 0000000000000..b9885b4e07d48 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ReactWrapper, mount } from 'enzyme'; + +import { ClosureOptionsRadio, ClosureOptionsRadioComponentProps } from './closure_options_radio'; +import { TestProviders } from '../../common/mock'; + +describe('ClosureOptionsRadio', () => { + let wrapper: ReactWrapper; + const onChangeClosureType = jest.fn(); + const props: ClosureOptionsRadioComponentProps = { + disabled: false, + closureTypeSelected: 'close-by-user', + onChangeClosureType, + }; + + beforeAll(() => { + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it renders', () => { + expect(wrapper.find('[data-test-subj="closure-options-radio-group"]').first().exists()).toBe( + true + ); + }); + + test('it shows the correct number of radio buttons', () => { + expect(wrapper.find('input[name="closure_options"]')).toHaveLength(2); + }); + + test('it renders close by user radio button', () => { + expect(wrapper.find('input[id="close-by-user"]').exists()).toBeTruthy(); + }); + + test('it renders close by pushing radio button', () => { + expect(wrapper.find('input[id="close-by-pushing"]').exists()).toBeTruthy(); + }); + + test('it disables the close by user radio button', () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(newWrapper.find('input[id="close-by-user"]').prop('disabled')).toEqual(true); + }); + + test('it disables correctly the close by pushing radio button', () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(newWrapper.find('input[id="close-by-pushing"]').prop('disabled')).toEqual(true); + }); + + test('it selects the correct radio button', () => { + const newWrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + expect(newWrapper.find('input[id="close-by-pushing"]').prop('checked')).toEqual(true); + }); + + test('it calls the onChangeClosureType function', () => { + wrapper.find('input[id="close-by-pushing"]').simulate('change'); + wrapper.update(); + expect(onChangeClosureType).toHaveBeenCalled(); + expect(onChangeClosureType).toHaveBeenCalledWith('close-by-pushing'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.tsx b/x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.tsx new file mode 100644 index 0000000000000..cb6fa0953a796 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/closure_options_radio.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode, useCallback } from 'react'; +import { EuiRadioGroup } from '@elastic/eui'; + +import { ClosureType } from '../../containers/configure/types'; +import * as i18n from './translations'; + +interface ClosureRadios { + id: ClosureType; + label: ReactNode; +} + +const radios: ClosureRadios[] = [ + { + id: 'close-by-user', + label: i18n.CASE_CLOSURE_OPTIONS_MANUAL, + }, + { + id: 'close-by-pushing', + label: i18n.CASE_CLOSURE_OPTIONS_NEW_INCIDENT, + }, +]; + +export interface ClosureOptionsRadioComponentProps { + closureTypeSelected: ClosureType; + disabled: boolean; + onChangeClosureType: (newClosureType: ClosureType) => void; +} + +const ClosureOptionsRadioComponent: React.FC = ({ + closureTypeSelected, + disabled, + onChangeClosureType, +}) => { + const onChangeLocal = useCallback( + (id: string) => { + onChangeClosureType(id as ClosureType); + }, + [onChangeClosureType] + ); + + return ( + + ); +}; + +export const ClosureOptionsRadio = React.memo(ClosureOptionsRadioComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx new file mode 100644 index 0000000000000..d5b9a885f2c6d --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; + +import { Connectors, Props } from './connectors'; +import { TestProviders } from '../../common/mock'; +import { ConnectorsDropdown } from './connectors_dropdown'; +import { connectors } from './__mock__'; +import { ConnectorTypes } from '../../../common'; + +describe('Connectors', () => { + let wrapper: ReactWrapper; + const onChangeConnector = jest.fn(); + const handleShowEditFlyout = jest.fn(); + + const props: Props = { + connectors, + disabled: false, + handleShowEditFlyout, + isLoading: false, + mappings: [], + onChangeConnector, + selectedConnector: { id: 'none', type: ConnectorTypes.none }, + updateConnectorDisabled: false, + }; + + beforeAll(() => { + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it shows the connectors from group', () => { + expect(wrapper.find('[data-test-subj="case-connectors-form-group"]').first().exists()).toBe( + true + ); + }); + + test('it shows the connectors form row', () => { + expect(wrapper.find('[data-test-subj="case-connectors-form-row"]').first().exists()).toBe(true); + }); + + test('it shows the connectors dropdown', () => { + expect(wrapper.find('[data-test-subj="case-connectors-dropdown"]').first().exists()).toBe(true); + }); + + test('it pass the correct props to child', () => { + const connectorsDropdownProps = wrapper.find(ConnectorsDropdown).props(); + expect(connectorsDropdownProps).toMatchObject({ + disabled: false, + isLoading: false, + connectors, + selectedConnector: 'none', + onChange: props.onChangeConnector, + }); + }); + + test('the connector is changed successfully', () => { + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); + + expect(onChangeConnector).toHaveBeenCalled(); + expect(onChangeConnector).toHaveBeenCalledWith('resilient-2'); + }); + + test('the connector is changed successfully to none', () => { + onChangeConnector.mockClear(); + const newWrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + newWrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + newWrapper.find('button[data-test-subj="dropdown-connector-no-connector"]').simulate('click'); + + expect(onChangeConnector).toHaveBeenCalled(); + expect(onChangeConnector).toHaveBeenCalledWith('none'); + }); + + test('it shows the add connector button', () => { + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + + expect( + wrapper.find('button[data-test-subj="dropdown-connector-add-connector"]').exists() + ).toBeTruthy(); + }); + + test('the text of the update button is shown correctly', () => { + const newWrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + expect( + newWrapper + .find('button[data-test-subj="case-configure-update-selected-connector-button"]') + .text() + ).toBe('Update My Connector'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx new file mode 100644 index 0000000000000..45be02e05e1f0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { + EuiDescribedFormGroup, + EuiFormRow, + EuiFlexGroup, + EuiFlexItem, + EuiLink, +} from '@elastic/eui'; + +import styled from 'styled-components'; + +import { ConnectorsDropdown } from './connectors_dropdown'; +import * as i18n from './translations'; + +import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types'; +import { Mapping } from './mapping'; +import { ConnectorTypes } from '../../../common'; + +const EuiFormRowExtended = styled(EuiFormRow)` + .euiFormRow__labelWrapper { + .euiFormRow__label { + width: 100%; + } + } +`; + +export interface Props { + connectors: ActionConnector[]; + disabled: boolean; + handleShowEditFlyout: () => void; + isLoading: boolean; + mappings: CaseConnectorMapping[]; + onChangeConnector: (id: string) => void; + selectedConnector: { id: string; type: string }; + updateConnectorDisabled: boolean; +} +const ConnectorsComponent: React.FC = ({ + connectors, + disabled, + handleShowEditFlyout, + isLoading, + mappings, + onChangeConnector, + selectedConnector, + updateConnectorDisabled, +}) => { + const connectorsName = useMemo( + () => connectors.find((c) => c.id === selectedConnector.id)?.name ?? 'none', + [connectors, selectedConnector.id] + ); + + const dropDownLabel = useMemo( + () => ( + + {i18n.INCIDENT_MANAGEMENT_SYSTEM_LABEL} + + {connectorsName !== 'none' && ( + + {i18n.UPDATE_SELECTED_CONNECTOR(connectorsName)} + + )} + + + ), + [connectorsName, handleShowEditFlyout, updateConnectorDisabled] + ); + return ( + <> + {i18n.INCIDENT_MANAGEMENT_SYSTEM_TITLE}} + description={i18n.INCIDENT_MANAGEMENT_SYSTEM_DESC} + data-test-subj="case-connectors-form-group" + > + + + + + + {selectedConnector.type !== ConnectorTypes.none ? ( + + + + ) : null} + + + + + ); +}; + +export const Connectors = React.memo(ConnectorsComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx new file mode 100644 index 0000000000000..5149052d9a4bf --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, ReactWrapper } from 'enzyme'; +import { EuiSuperSelect } from '@elastic/eui'; + +import { ConnectorsDropdown, Props } from './connectors_dropdown'; +import { TestProviders } from '../../common/mock'; +import { connectors } from './__mock__'; + +describe('ConnectorsDropdown', () => { + let wrapper: ReactWrapper; + const props: Props = { + disabled: false, + connectors, + isLoading: false, + onChange: jest.fn(), + selectedConnector: 'none', + }; + + beforeAll(() => { + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it renders', () => { + expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().exists()).toBe(true); + }); + + test('it formats the connectors correctly', () => { + const selectProps = wrapper.find(EuiSuperSelect).props(); + + expect(selectProps.options).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "dropdown-connector-no-connector", + "inputDisplay": + + + + + + No connector selected + + + , + "value": "none", + }, + Object { + "data-test-subj": "dropdown-connector-servicenow-1", + "inputDisplay": + + + + + + My Connector + + + , + "value": "servicenow-1", + }, + Object { + "data-test-subj": "dropdown-connector-resilient-2", + "inputDisplay": + + + + + + My Connector 2 + + + , + "value": "resilient-2", + }, + Object { + "data-test-subj": "dropdown-connector-jira-1", + "inputDisplay": + + + + + + Jira + + + , + "value": "jira-1", + }, + Object { + "data-test-subj": "dropdown-connector-servicenow-sir", + "inputDisplay": + + + + + + My Connector SIR + + + , + "value": "servicenow-sir", + }, + ] + `); + }); + + test('it disables the dropdown', () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled') + ).toEqual(true); + }); + + test('it loading correctly', () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') + ).toEqual(true); + }); + + test('it selects the correct connector', () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(newWrapper.find('button span:not([data-euiicon-type])').text()).toEqual('My Connector'); + }); + + test('if the props hideConnectorServiceNowSir is true, the connector should not be part of the list of options ', () => { + const newWrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + const selectProps = newWrapper.find(EuiSuperSelect).props(); + const options = selectProps.options as Array<{ 'data-test-subj': string }>; + expect( + options.some((o) => o['data-test-subj'] === 'dropdown-connector-servicenow-1') + ).toBeTruthy(); + expect( + options.some((o) => o['data-test-subj'] === 'dropdown-connector-servicenow-sir') + ).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx new file mode 100644 index 0000000000000..21ef5c490b17a --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; +import styled from 'styled-components'; + +import { ConnectorTypes } from '../../../common'; +import { ActionConnector } from '../../containers/configure/types'; +import { connectorsConfiguration } from '../connectors'; +import * as i18n from './translations'; + +export interface Props { + connectors: ActionConnector[]; + disabled: boolean; + isLoading: boolean; + onChange: (id: string) => void; + selectedConnector: string; + appendAddConnectorButton?: boolean; + hideConnectorServiceNowSir?: boolean; +} + +const ICON_SIZE = 'm'; + +const EuiIconExtended = styled(EuiIcon)` + margin-right: 13px; + margin-bottom: 0 !important; +`; + +const noConnectorOption = { + value: 'none', + inputDisplay: ( + + + + + + {i18n.NO_CONNECTOR} + + + ), + 'data-test-subj': 'dropdown-connector-no-connector', +}; + +const addNewConnector = { + value: 'add-connector', + inputDisplay: ( + + {i18n.ADD_NEW_CONNECTOR} + + ), + 'data-test-subj': 'dropdown-connector-add-connector', +}; + +const ConnectorsDropdownComponent: React.FC = ({ + connectors, + disabled, + isLoading, + onChange, + selectedConnector, + appendAddConnectorButton = false, + hideConnectorServiceNowSir = false, +}) => { + const connectorsAsOptions = useMemo(() => { + const connectorsFormatted = connectors.reduce( + (acc, connector) => { + if (hideConnectorServiceNowSir && connector.actionTypeId === ConnectorTypes.serviceNowSIR) { + return acc; + } + + return [ + ...acc, + { + value: connector.id, + inputDisplay: ( + + + + + + {connector.name} + + + ), + 'data-test-subj': `dropdown-connector-${connector.id}`, + }, + ]; + }, + [noConnectorOption] + ); + + if (appendAddConnectorButton) { + return [...connectorsFormatted, addNewConnector]; + } + + return connectorsFormatted; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectors]); + + return ( + + ); +}; + +export const ConnectorsDropdown = React.memo(ConnectorsDropdownComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/field_mapping.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/field_mapping.test.tsx new file mode 100644 index 0000000000000..8c2a66ad7ee53 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/field_mapping.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; + +import { FieldMapping, FieldMappingProps } from './field_mapping'; +import { mappings } from './__mock__'; +import { TestProviders } from '../../common/mock'; +import { FieldMappingRowStatic } from './field_mapping_row_static'; + +describe('FieldMappingRow', () => { + let wrapper: ReactWrapper; + const props: FieldMappingProps = { + isLoading: false, + mappings, + connectorActionTypeId: '.servicenow', + }; + + beforeAll(() => { + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + test('it renders', () => { + expect( + wrapper.find('[data-test-subj="case-configure-field-mappings-row-wrapper"]').first().exists() + ).toBe(true); + + expect(wrapper.find(FieldMappingRowStatic).length).toEqual(3); + }); + + test('it does not render without mappings', () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect( + newWrapper + .find('[data-test-subj="case-configure-field-mappings-row-wrapper"]') + .first() + .exists() + ).toBe(false); + }); + + test('it pass the corrects props to mapping row', () => { + const rows = wrapper.find(FieldMappingRowStatic); + rows.forEach((row, index) => { + expect(row.prop('casesField')).toEqual(mappings[index].source); + expect(row.prop('selectedActionType')).toEqual(mappings[index].actionType); + expect(row.prop('selectedThirdParty')).toEqual(mappings[index].target); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/field_mapping.tsx b/x-pack/plugins/cases/public/components/configure_cases/field_mapping.tsx new file mode 100644 index 0000000000000..ef7e8ecda0c87 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/field_mapping.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import styled from 'styled-components'; + +import { FieldMappingRowStatic } from './field_mapping_row_static'; +import * as i18n from './translations'; + +import { CaseConnectorMapping } from '../../containers/configure/types'; +import { connectorsConfiguration } from '../connectors'; + +const FieldRowWrapper = styled.div` + margin: 10px 0; + font-size: 14px; +`; + +export interface FieldMappingProps { + connectorActionTypeId: string; + isLoading: boolean; + mappings: CaseConnectorMapping[]; +} + +const FieldMappingComponent: React.FC = ({ + connectorActionTypeId, + isLoading, + mappings, +}) => { + const selectedConnector = useMemo( + () => connectorsConfiguration[connectorActionTypeId] ?? { fields: {} }, + [connectorActionTypeId] + ); + return mappings.length ? ( + + + {' '} + + + {i18n.FIELD_MAPPING_FIRST_COL} + + + + {i18n.FIELD_MAPPING_SECOND_COL(selectedConnector.name)} + + + + {i18n.FIELD_MAPPING_THIRD_COL} + + + + + + {mappings.map((item) => ( + + ))} + + + + ) : null; +}; + +export const FieldMapping = React.memo(FieldMappingComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/field_mapping_row_static.tsx b/x-pack/plugins/cases/public/components/configure_cases/field_mapping_row_static.tsx new file mode 100644 index 0000000000000..52672197ecb55 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/field_mapping_row_static.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiCode, EuiFlexItem, EuiFlexGroup, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; + +import { capitalize } from 'lodash/fp'; +import { CaseField, ActionType, ThirdPartyField } from '../../containers/configure/types'; + +export interface RowProps { + isLoading: boolean; + casesField: CaseField; + selectedActionType: ActionType; + selectedThirdParty: ThirdPartyField; +} + +const FieldMappingRowComponent: React.FC = ({ + isLoading, + casesField, + selectedActionType, + selectedThirdParty, +}) => { + const selectedActionTypeCapitalized = useMemo(() => capitalize(selectedActionType), [ + selectedActionType, + ]); + return ( + + + + + {casesField} + + + + + + + + + + {isLoading ? ( + + ) : ( + {selectedThirdParty} + )} + + + + + {isLoading ? : selectedActionTypeCapitalized} + + + ); +}; + +export const FieldMappingRowStatic = React.memo(FieldMappingRowComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx new file mode 100644 index 0000000000000..898d6cde19a77 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -0,0 +1,591 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { ConfigureCases } from '.'; +import { TestProviders } from '../../common/mock'; +import { Connectors } from './connectors'; +import { ClosureOptions } from './closure_options'; +import { + ActionConnector, + ConnectorAddFlyout, + ConnectorEditFlyout, + TriggersAndActionsUIPublicPluginStart, +} from '../../../../triggers_actions_ui/public'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; + +import { useKibana } from '../../common/lib/kibana'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useActionTypes } from '../../containers/configure/use_action_types'; + +import { + connectors, + searchURL, + useCaseConfigureResponse, + useConnectorsResponse, + useActionTypesResponse, +} from './__mock__'; +import { ConnectorTypes } from '../../../common'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../containers/configure/use_connectors'); +jest.mock('../../containers/configure/use_configure'); +jest.mock('../../containers/configure/use_action_types'); + +const useKibanaMock = useKibana as jest.Mocked; +const useConnectorsMock = useConnectors as jest.Mock; +const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useGetUrlSearchMock = jest.fn(); +const useActionTypesMock = useActionTypes as jest.Mock; + +describe('ConfigureCases', () => { + beforeEach(() => { + useKibanaMock().services.triggersActionsUi = ({ + actionTypeRegistry: actionTypeRegistryMock.create(), + getAddConnectorFlyout: jest.fn().mockImplementation(() => ( + {}} + actionTypeRegistry={actionTypeRegistryMock.create()} + actionTypes={[ + { + id: '.servicenow', + name: 'servicenow', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + { + id: '.jira', + name: 'jira', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + { + id: '.resilient', + name: 'resilient', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + ]} + /> + )), + getEditConnectorFlyout: jest + .fn() + .mockImplementation(() => ( + {}} + actionTypeRegistry={actionTypeRegistryMock.create()} + initialConnector={connectors[1] as ActionConnector} + /> + )), + } as unknown) as TriggersAndActionsUIPublicPluginStart; + + useActionTypesMock.mockImplementation(() => useActionTypesResponse); + }); + + describe('rendering', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); + useGetUrlSearchMock.mockImplementation(() => searchURL); + + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it renders the Connectors', () => { + expect(wrapper.find('[data-test-subj="dropdown-connectors"]').exists()).toBeTruthy(); + }); + + test('it renders the ClosureType', () => { + expect(wrapper.find('[data-test-subj="closure-options-radio-group"]').exists()).toBeTruthy(); + }); + + test('it does NOT render the ConnectorAddFlyout', () => { + // Components from triggersActionsUi do not have a data-test-subj + expect(wrapper.find(ConnectorAddFlyout).exists()).toBeFalsy(); + }); + + test('it does NOT render the ConnectorEditFlyout', () => { + // Components from triggersActionsUi do not have a data-test-subj + expect(wrapper.find(ConnectorEditFlyout).exists()).toBeFalsy(); + }); + + test('it does NOT render the EuiCallOut', () => { + expect( + wrapper.find('[data-test-subj="configure-cases-warning-callout"]').exists() + ).toBeFalsy(); + }); + }); + + describe('Unhappy path', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + closureType: 'close-by-user', + connector: { + id: 'not-id', + name: 'unchanged', + type: ConnectorTypes.none, + fields: null, + }, + currentConfiguration: { + connector: { + id: 'not-id', + name: 'unchanged', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + }, + })); + useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); + useGetUrlSearchMock.mockImplementation(() => searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it shows the warning callout when configuration is invalid', () => { + expect( + wrapper.find('[data-test-subj="configure-cases-warning-callout"]').exists() + ).toBeTruthy(); + }); + + test('it hides the update connector button when the connectorId is invalid', () => { + expect( + wrapper + .find('button[data-test-subj="case-configure-update-selected-connector-button"]') + .exists() + ).toBeFalsy(); + }); + }); + + describe('Happy path', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mappings: [], + closureType: 'close-by-user', + connector: { + id: 'servicenow-1', + name: 'unchanged', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + currentConfiguration: { + connector: { + id: 'servicenow-1', + name: 'unchanged', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + closureType: 'close-by-user', + }, + })); + useConnectorsMock.mockImplementation(() => useConnectorsResponse); + useGetUrlSearchMock.mockImplementation(() => searchURL); + + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it renders with correct props', () => { + // Connector + expect(wrapper.find(Connectors).prop('connectors')).toEqual(connectors); + expect(wrapper.find(Connectors).prop('disabled')).toBe(false); + expect(wrapper.find(Connectors).prop('isLoading')).toBe(false); + expect(wrapper.find(Connectors).prop('selectedConnector').id).toBe('servicenow-1'); + + // ClosureOptions + expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(false); + expect(wrapper.find(ClosureOptions).prop('closureTypeSelected')).toBe('close-by-user'); + + // Flyouts + expect(wrapper.find(ConnectorAddFlyout).exists()).toBe(false); + expect(wrapper.find(ConnectorEditFlyout).exists()).toBe(false); + }); + + test('it disables correctly when the user cannot crud', () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(newWrapper.find('button[data-test-subj="dropdown-connectors"]').prop('disabled')).toBe( + true + ); + + expect( + newWrapper + .find('button[data-test-subj="case-configure-update-selected-connector-button"]') + .prop('disabled') + ).toBe(true); + + // Two closure options + expect( + newWrapper + .find('[data-test-subj="closure-options-radio-group"] input') + .first() + .prop('disabled') + ).toBe(true); + + expect( + newWrapper + .find('[data-test-subj="closure-options-radio-group"] input') + .at(1) + .prop('disabled') + ).toBe(true); + }); + }); + + describe('loading connectors', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping: null, + closureType: 'close-by-user', + connector: { + id: 'resilient-2', + name: 'unchanged', + type: ConnectorTypes.resilient, + fields: null, + }, + currentConfiguration: { + connector: { + id: 'servicenow-1', + name: 'unchanged', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + closureType: 'close-by-user', + }, + })); + + useConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + loading: true, + })); + + useGetUrlSearchMock.mockImplementation(() => searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it disables correctly Connector when loading connectors', () => { + expect( + wrapper.find('button[data-test-subj="dropdown-connectors"]').prop('disabled') + ).toBeTruthy(); + }); + + test('it pass the correct value to isLoading attribute on Connector', () => { + expect(wrapper.find(Connectors).prop('isLoading')).toBe(true); + }); + + test('it disables correctly ClosureOptions when loading connectors', () => { + expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(true); + }); + + test('it hides the update connector button when loading the connectors', () => { + expect( + wrapper + .find('button[data-test-subj="case-configure-update-selected-connector-button"]') + .prop('disabled') + ).toBe(true); + }); + + test('it shows isLoading when loading action types', () => { + useConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + loading: false, + })); + + useActionTypesMock.mockImplementation(() => ({ ...useActionTypesResponse, loading: true })); + + wrapper = mount(, { wrappingComponent: TestProviders }); + expect(wrapper.find(Connectors).prop('isLoading')).toBe(true); + }); + }); + + describe('saving configuration', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + persistLoading: true, + })); + + useConnectorsMock.mockImplementation(() => useConnectorsResponse); + useGetUrlSearchMock.mockImplementation(() => searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it disables correctly Connector when saving configuration', () => { + expect(wrapper.find(Connectors).prop('disabled')).toBe(true); + }); + + test('it disables correctly ClosureOptions when saving configuration', () => { + expect( + wrapper + .find('[data-test-subj="closure-options-radio-group"] input') + .first() + .prop('disabled') + ).toBe(true); + + expect( + wrapper.find('[data-test-subj="closure-options-radio-group"] input').at(1).prop('disabled') + ).toBe(true); + }); + + test('it disables the update connector button when saving the configuration', () => { + expect( + wrapper + .find('button[data-test-subj="case-configure-update-selected-connector-button"]') + .prop('disabled') + ).toBe(true); + }); + }); + + describe('loading configuration', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + loading: true, + })); + useConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + })); + useGetUrlSearchMock.mockImplementation(() => searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it hides the update connector button when loading the configuration', () => { + expect( + wrapper + .find('button[data-test-subj="case-configure-update-selected-connector-button"]') + .exists() + ).toBeFalsy(); + }); + }); + + describe('connectors', () => { + let wrapper: ReactWrapper; + let persistCaseConfigure: jest.Mock; + + beforeEach(() => { + persistCaseConfigure = jest.fn(); + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping: null, + closureType: 'close-by-user', + connector: { + id: 'resilient-2', + name: 'My connector', + type: ConnectorTypes.resilient, + fields: null, + }, + currentConfiguration: { + connector: { + id: 'My connector', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, + closureType: 'close-by-user', + }, + persistCaseConfigure, + })); + useConnectorsMock.mockImplementation(() => useConnectorsResponse); + useGetUrlSearchMock.mockImplementation(() => searchURL); + + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it submits the configuration correctly when changing connector', () => { + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); + wrapper.update(); + + expect(persistCaseConfigure).toHaveBeenCalled(); + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'resilient-2', + name: 'My Connector 2', + type: ConnectorTypes.resilient, + fields: null, + }, + closureType: 'close-by-user', + }); + }); + + test('the text of the update button is changed successfully', () => { + useCaseConfigureMock + .mockImplementationOnce(() => ({ + ...useCaseConfigureResponse, + connector: { + id: 'servicenow-1', + name: 'My connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + })) + .mockImplementation(() => ({ + ...useCaseConfigureResponse, + connector: { + id: 'resilient-2', + name: 'My connector 2', + type: ConnectorTypes.resilient, + fields: null, + }, + })); + + wrapper = mount(, { wrappingComponent: TestProviders }); + + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('button[data-test-subj="case-configure-update-selected-connector-button"]') + .text() + ).toBe('Update My Connector 2'); + }); + }); +}); + +describe('closure options', () => { + let wrapper: ReactWrapper; + let persistCaseConfigure: jest.Mock; + + beforeEach(() => { + persistCaseConfigure = jest.fn(); + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping: null, + closureType: 'close-by-user', + connector: { + id: 'servicenow-1', + name: 'My connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + currentConfiguration: { + connector: { + id: 'My connector', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, + closureType: 'close-by-user', + }, + persistCaseConfigure, + })); + useConnectorsMock.mockImplementation(() => useConnectorsResponse); + useGetUrlSearchMock.mockImplementation(() => searchURL); + + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + test('it submits the configuration correctly when changing closure type', () => { + wrapper.find('input[id="close-by-pushing"]').simulate('change'); + wrapper.update(); + + expect(persistCaseConfigure).toHaveBeenCalled(); + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'servicenow-1', + name: 'My connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + closureType: 'close-by-pushing', + }); + }); +}); + +describe('user interactions', () => { + beforeEach(() => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping: null, + closureType: 'close-by-user', + connector: { + id: 'resilient-2', + name: 'unchanged', + type: ConnectorTypes.resilient, + fields: null, + }, + currentConfiguration: { + connector: { + id: 'resilient-2', + name: 'unchanged', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + closureType: 'close-by-user', + }, + })); + useConnectorsMock.mockImplementation(() => useConnectorsResponse); + useGetUrlSearchMock.mockImplementation(() => searchURL); + }); + + test('it show the add flyout when pressing the add connector button', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-add-connector"]').simulate('click'); + wrapper.update(); + + expect(wrapper.find(ConnectorAddFlyout).exists()).toBe(true); + expect(wrapper.find(ConnectorAddFlyout).prop('actionTypes')).toEqual([ + expect.objectContaining({ + id: '.servicenow', + }), + expect.objectContaining({ + id: '.jira', + }), + expect.objectContaining({ + id: '.resilient', + }), + ]); + }); + + test('it show the edit flyout when pressing the update connector button', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper + .find('button[data-test-subj="case-configure-update-selected-connector-button"]') + .simulate('click'); + wrapper.update(); + + expect(wrapper.find(ConnectorEditFlyout).exists()).toBe(true); + expect(wrapper.find(ConnectorEditFlyout).prop('initialConnector')).toEqual(connectors[1]); + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx new file mode 100644 index 0000000000000..3e352f119e840 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/index.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, { useCallback, useEffect, useMemo, useState } from 'react'; +import styled, { css } from 'styled-components'; + +import { EuiCallOut } from '@elastic/eui'; + +import { SUPPORTED_CONNECTORS } from '../../../common'; +import { useKibana } from '../../common/lib/kibana'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useActionTypes } from '../../containers/configure/use_action_types'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; + +import { ClosureType } from '../../containers/configure/types'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ActionConnectorTableItem } from '../../../../triggers_actions_ui/public/types'; + +import { SectionWrapper } from '../wrappers'; +import { Connectors } from './connectors'; +import { ClosureOptions } from './closure_options'; +import { + getConnectorById, + getNoneConnector, + normalizeActionConnector, + normalizeCaseConnector, +} from './utils'; +import * as i18n from './translations'; + +const FormWrapper = styled.div` + ${({ theme }) => css` + & > * { + margin-top 40px; + } + + & > :first-child { + margin-top: 0; + } + + padding-top: ${theme.eui.paddingSizes.xl}; + padding-bottom: ${theme.eui.paddingSizes.xl}; + .euiFlyout { + z-index: ${theme.eui.euiZNavigation + 1}; + } + `} +`; + +interface ConfigureCasesComponentProps { + userCanCrud: boolean; +} + +const ConfigureCasesComponent: React.FC = ({ userCanCrud }) => { + const { triggersActionsUi } = useKibana().services; + + const [connectorIsValid, setConnectorIsValid] = useState(true); + const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); + const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); + const [editedConnectorItem, setEditedConnectorItem] = useState( + null + ); + + const { + connector, + closureType, + loading: loadingCaseConfigure, + mappings, + persistLoading, + persistCaseConfigure, + refetchCaseConfigure, + setConnector, + setClosureType, + } = useCaseConfigure(); + + const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); + const { loading: isLoadingActionTypes, actionTypes, refetchActionTypes } = useActionTypes(); + const supportedActionTypes = useMemo( + () => actionTypes.filter((actionType) => SUPPORTED_CONNECTORS.includes(actionType.id)), + [actionTypes] + ); + + const onConnectorUpdate = useCallback(async () => { + refetchConnectors(); + refetchActionTypes(); + refetchCaseConfigure(); + }, [refetchActionTypes, refetchCaseConfigure, refetchConnectors]); + + const isLoadingAny = + isLoadingConnectors || persistLoading || loadingCaseConfigure || isLoadingActionTypes; + const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; + const onClickUpdateConnector = useCallback(() => { + setEditFlyoutVisibility(true); + }, []); + + const onCloseAddFlyout = useCallback(() => setAddFlyoutVisibility(false), [ + setAddFlyoutVisibility, + ]); + + const onCloseEditFlyout = useCallback(() => setEditFlyoutVisibility(false), []); + + const onChangeConnector = useCallback( + (id: string) => { + if (id === 'add-connector') { + setAddFlyoutVisibility(true); + return; + } + + const actionConnector = getConnectorById(id, connectors); + const caseConnector = + actionConnector != null ? normalizeActionConnector(actionConnector) : getNoneConnector(); + + setConnector(caseConnector); + persistCaseConfigure({ + connector: caseConnector, + closureType, + }); + }, + [connectors, closureType, persistCaseConfigure, setConnector] + ); + + const onChangeClosureType = useCallback( + (type: ClosureType) => { + setClosureType(type); + persistCaseConfigure({ + connector, + closureType: type, + }); + }, + [connector, persistCaseConfigure, setClosureType] + ); + + useEffect(() => { + if ( + !isLoadingConnectors && + connector.id !== 'none' && + !connectors.some((c) => c.id === connector.id) + ) { + setConnectorIsValid(false); + } else if ( + !isLoadingConnectors && + (connector.id === 'none' || connectors.some((c) => c.id === connector.id)) + ) { + setConnectorIsValid(true); + } + }, [connectors, connector, isLoadingConnectors]); + + useEffect(() => { + if (!isLoadingConnectors && connector.id !== 'none') { + setEditedConnectorItem( + normalizeCaseConnector(connectors, connector) as ActionConnectorTableItem + ); + } + }, [connectors, connector, isLoadingConnectors]); + + const ConnectorAddFlyout = useMemo( + () => + triggersActionsUi.getAddConnectorFlyout({ + consumer: 'case', + onClose: onCloseAddFlyout, + actionTypes: supportedActionTypes, + reloadConnectors: onConnectorUpdate, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [supportedActionTypes] + ); + + const ConnectorEditFlyout = useMemo( + () => + editedConnectorItem && editFlyoutVisible + ? triggersActionsUi.getEditConnectorFlyout({ + initialConnector: editedConnectorItem, + consumer: 'case', + onClose: onCloseEditFlyout, + reloadConnectors: onConnectorUpdate, + }) + : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [connector.id, editFlyoutVisible] + ); + + return ( + + {!connectorIsValid && ( + + + {i18n.WARNING_NO_CONNECTOR_MESSAGE} + + + )} + + + + + + + {addFlyoutVisible && ConnectorAddFlyout} + {ConnectorEditFlyout} + + ); +}; + +export const ConfigureCases = React.memo(ConfigureCasesComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/mapping.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/mapping.test.tsx new file mode 100644 index 0000000000000..75b2410dde957 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/mapping.test.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 { mount } from 'enzyme'; + +import { TestProviders } from '../../common/mock'; +import { Mapping, MappingProps } from './mapping'; +import { mappings } from './__mock__'; + +describe('Mapping', () => { + const props: MappingProps = { + connectorActionTypeId: '.servicenow', + isLoading: false, + mappings, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + test('it shows mapping form group', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + expect(wrapper.find('[data-test-subj="static-mappings"]').first().exists()).toBe(true); + }); + + test('correctly maps fields', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + expect(wrapper.find('[data-test-subj="field-mapping-source"] code').first().text()).toBe( + 'title' + ); + expect(wrapper.find('[data-test-subj="field-mapping-target"] code').first().text()).toBe( + 'short_description' + ); + }); + test('displays connection warning when isLoading: false and mappings: []', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="field-mapping-desc"]').first().text()).toBe( + 'Field mappings require an established connection to ServiceNow ITSM. Please check your connection credentials.' + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/mapping.tsx b/x-pack/plugins/cases/public/components/configure_cases/mapping.tsx new file mode 100644 index 0000000000000..5ec6a33f48b6a --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/mapping.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, { useMemo } from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTextColor } from '@elastic/eui'; + +import { TextColor } from '@elastic/eui/src/components/text/text_color'; +import * as i18n from './translations'; + +import { FieldMapping } from './field_mapping'; +import { CaseConnectorMapping } from '../../containers/configure/types'; +import { connectorsConfiguration } from '../connectors'; + +export interface MappingProps { + connectorActionTypeId: string; + isLoading: boolean; + mappings: CaseConnectorMapping[]; +} + +const MappingComponent: React.FC = ({ + connectorActionTypeId, + isLoading, + mappings, +}) => { + const selectedConnector = useMemo(() => connectorsConfiguration[connectorActionTypeId], [ + connectorActionTypeId, + ]); + const fieldMappingDesc: { desc: string; color: TextColor } = useMemo( + () => + mappings.length > 0 || isLoading + ? { desc: i18n.FIELD_MAPPING_DESC(selectedConnector.name), color: 'subdued' } + : { desc: i18n.FIELD_MAPPING_DESC_ERR(selectedConnector.name), color: 'danger' }, + [isLoading, mappings.length, selectedConnector.name] + ); + return ( + + + +

{i18n.FIELD_MAPPING_TITLE(selectedConnector.name)}

+ + {fieldMappingDesc.desc} + +
+
+ + + +
+ ); +}; + +export const Mapping = React.memo(MappingComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts new file mode 100644 index 0000000000000..2fb2133ba470c --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -0,0 +1,227 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const INCIDENT_MANAGEMENT_SYSTEM_TITLE = i18n.translate( + 'xpack.cases.configureCases.incidentManagementSystemTitle', + { + defaultMessage: 'Connect to external incident management system', + } +); + +export const INCIDENT_MANAGEMENT_SYSTEM_DESC = i18n.translate( + 'xpack.cases.configureCases.incidentManagementSystemDesc', + { + defaultMessage: + 'You may optionally connect cases to an external incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', + } +); + +export const INCIDENT_MANAGEMENT_SYSTEM_LABEL = i18n.translate( + 'xpack.cases.configureCases.incidentManagementSystemLabel', + { + defaultMessage: 'Incident management system', + } +); + +export const ADD_NEW_CONNECTOR = i18n.translate('xpack.cases.configureCases.addNewConnector', { + defaultMessage: 'Add new connector', +}); + +export const CASE_CLOSURE_OPTIONS_TITLE = i18n.translate( + 'xpack.cases.configureCases.caseClosureOptionsTitle', + { + defaultMessage: 'Case Closures', + } +); + +export const CASE_CLOSURE_OPTIONS_DESC = i18n.translate( + 'xpack.cases.configureCases.caseClosureOptionsDesc', + { + defaultMessage: + 'Define how you wish cases to be closed. Automated case closures require an established connection to an external incident management system.', + } +); + +export const CASE_COLSURE_OPTIONS_SUB_CASES = i18n.translate( + 'xpack.cases.configureCases.caseClosureOptionsSubCases', + { + defaultMessage: 'Automated closures of sub-cases is not currently supported.', + } +); + +export const CASE_CLOSURE_OPTIONS_LABEL = i18n.translate( + 'xpack.cases.configureCases.caseClosureOptionsLabel', + { + defaultMessage: 'Case closure options', + } +); + +export const CASE_CLOSURE_OPTIONS_MANUAL = i18n.translate( + 'xpack.cases.configureCases.caseClosureOptionsManual', + { + defaultMessage: 'Manually close cases', + } +); + +export const CASE_CLOSURE_OPTIONS_NEW_INCIDENT = i18n.translate( + 'xpack.cases.configureCases.caseClosureOptionsNewIncident', + { + defaultMessage: 'Automatically close cases when pushing new incident to external system', + } +); + +export const CASE_CLOSURE_OPTIONS_CLOSED_INCIDENT = i18n.translate( + 'xpack.cases.configureCases.caseClosureOptionsClosedIncident', + { + defaultMessage: 'Automatically close cases when incident is closed in external system', + } +); +export const FIELD_MAPPING_TITLE = (thirdPartyName: string): string => { + return i18n.translate('xpack.cases.configureCases.fieldMappingTitle', { + values: { thirdPartyName }, + defaultMessage: '{ thirdPartyName } field mappings', + }); +}; + +export const FIELD_MAPPING_DESC = (thirdPartyName: string): string => { + return i18n.translate('xpack.cases.configureCases.fieldMappingDesc', { + values: { thirdPartyName }, + defaultMessage: + 'Map Case fields to { thirdPartyName } fields when pushing data to { thirdPartyName }. Field mappings require an established connection to { thirdPartyName }.', + }); +}; + +export const FIELD_MAPPING_DESC_ERR = (thirdPartyName: string): string => { + return i18n.translate('xpack.cases.configureCases.fieldMappingDescErr', { + values: { thirdPartyName }, + defaultMessage: + 'Field mappings require an established connection to { thirdPartyName }. Please check your connection credentials.', + }); +}; +export const EDIT_FIELD_MAPPING_TITLE = (thirdPartyName: string): string => { + return i18n.translate('xpack.cases.configureCases.editFieldMappingTitle', { + values: { thirdPartyName }, + defaultMessage: 'Edit { thirdPartyName } field mappings', + }); +}; + +export const FIELD_MAPPING_FIRST_COL = i18n.translate( + 'xpack.cases.configureCases.fieldMappingFirstCol', + { + defaultMessage: 'Kibana case field', + } +); + +export const FIELD_MAPPING_SECOND_COL = (thirdPartyName: string): string => { + return i18n.translate('xpack.cases.configureCases.fieldMappingSecondCol', { + values: { thirdPartyName }, + defaultMessage: '{ thirdPartyName } field', + }); +}; + +export const FIELD_MAPPING_THIRD_COL = i18n.translate( + 'xpack.cases.configureCases.fieldMappingThirdCol', + { + defaultMessage: 'On edit and update', + } +); + +export const FIELD_MAPPING_EDIT_NOTHING = i18n.translate( + 'xpack.cases.configureCases.fieldMappingEditNothing', + { + defaultMessage: 'Nothing', + } +); + +export const FIELD_MAPPING_EDIT_OVERWRITE = i18n.translate( + 'xpack.cases.configureCases.fieldMappingEditOverwrite', + { + defaultMessage: 'Overwrite', + } +); + +export const FIELD_MAPPING_EDIT_APPEND = i18n.translate( + 'xpack.cases.configureCases.fieldMappingEditAppend', + { + defaultMessage: 'Append', + } +); + +export const CANCEL = i18n.translate('xpack.cases.configureCases.cancelButton', { + defaultMessage: 'Cancel', +}); + +export const SAVE = i18n.translate('xpack.cases.configureCases.saveButton', { + defaultMessage: 'Save', +}); + +export const SAVE_CLOSE = i18n.translate('xpack.cases.configureCases.saveAndCloseButton', { + defaultMessage: 'Save & close', +}); + +export const WARNING_NO_CONNECTOR_TITLE = i18n.translate( + 'xpack.cases.configureCases.warningTitle', + { + defaultMessage: 'Warning', + } +); + +export const WARNING_NO_CONNECTOR_MESSAGE = i18n.translate( + 'xpack.cases.configureCases.warningMessage', + { + defaultMessage: + 'The selected connector has been deleted. Either select a different connector or create a new one.', + } +); + +export const MAPPING_FIELD_NOT_MAPPED = i18n.translate( + 'xpack.cases.configureCases.mappingFieldNotMapped', + { + defaultMessage: 'Not mapped', + } +); + +export const COMMENT = i18n.translate('xpack.cases.configureCases.commentMapping', { + defaultMessage: 'Comments', +}); + +export const NO_FIELDS_ERROR = (connectorName: string): string => { + return i18n.translate('xpack.cases.configureCases.noFieldsError', { + values: { connectorName }, + defaultMessage: + 'No { connectorName } fields found. Please check your { connectorName } connector settings or your { connectorName } instance settings to resolve.', + }); +}; + +export const BLANK_MAPPINGS = (connectorName: string): string => { + return i18n.translate('xpack.cases.configureCases.blankMappings', { + values: { connectorName }, + defaultMessage: 'At least one field needs to be mapped to { connectorName }', + }); +}; + +export const REQUIRED_MAPPINGS = (connectorName: string, fields: string): string => { + return i18n.translate('xpack.cases.configureCases.requiredMappings', { + values: { connectorName, fields }, + defaultMessage: + 'At least one Case field needs to be mapped to the following required { connectorName } fields: { fields }', + }); +}; +export const UPDATE_FIELD_MAPPINGS = i18n.translate('xpack.cases.configureCases.updateConnector', { + defaultMessage: 'Update field mappings', +}); + +export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string => { + return i18n.translate('xpack.cases.configureCases.updateSelectedConnector', { + values: { connectorName }, + defaultMessage: 'Update { connectorName }', + }); +}; diff --git a/x-pack/plugins/cases/public/components/configure_cases/utils.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/utils.test.tsx new file mode 100644 index 0000000000000..45bb7f1f5136d --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/utils.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mappings } from './__mock__'; +import { setActionTypeToMapping, setThirdPartyToMapping } from './utils'; +import { CaseConnectorMapping } from '../../containers/configure/types'; + +describe('FieldMappingRow', () => { + test('it should change the action type', () => { + const newMapping = setActionTypeToMapping('title', 'nothing', mappings); + expect(newMapping[0].actionType).toBe('nothing'); + }); + + test('it should not change other fields', () => { + const [newTitle, description, comments] = setActionTypeToMapping('title', 'nothing', mappings); + expect(newTitle).not.toEqual(mappings[0]); + expect(description).toEqual(mappings[1]); + expect(comments).toEqual(mappings[2]); + }); + + test('it should return a new array when changing action type', () => { + const newMapping = setActionTypeToMapping('title', 'nothing', mappings); + expect(newMapping).not.toBe(mappings); + }); + + test('it should change the third party', () => { + const newMapping = setThirdPartyToMapping('title', 'description', mappings); + expect(newMapping[0].target).toBe('description'); + }); + + test('it should not change other fields when there is not a conflict', () => { + const tempMapping: CaseConnectorMapping[] = [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ]; + + const [newTitle, comments] = setThirdPartyToMapping('title', 'description', tempMapping); + + expect(newTitle).not.toEqual(mappings[0]); + expect(comments).toEqual(tempMapping[1]); + }); + + test('it should return a new array when changing third party', () => { + const newMapping = setThirdPartyToMapping('title', 'description', mappings); + expect(newMapping).not.toBe(mappings); + }); + + test('it should change the target of the conflicting third party field to not_mapped', () => { + const newMapping = setThirdPartyToMapping('title', 'description', mappings); + expect(newMapping[1].target).toBe('not_mapped'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/utils.ts b/x-pack/plugins/cases/public/components/configure_cases/utils.ts new file mode 100644 index 0000000000000..ade1a5e0c2bba --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConnectorTypeFields, ConnectorTypes } from '../../../common'; +import { + CaseField, + ActionType, + ThirdPartyField, + ActionConnector, + CaseConnector, + CaseConnectorMapping, +} from '../../containers/configure/types'; + +export const setActionTypeToMapping = ( + caseField: CaseField, + newActionType: ActionType, + mapping: CaseConnectorMapping[] +): CaseConnectorMapping[] => { + const findItemIndex = mapping.findIndex((item) => item.source === caseField); + + if (findItemIndex >= 0) { + return [ + ...mapping.slice(0, findItemIndex), + { ...mapping[findItemIndex], actionType: newActionType }, + ...mapping.slice(findItemIndex + 1), + ]; + } + + return [...mapping]; +}; + +export const setThirdPartyToMapping = ( + caseField: CaseField, + newThirdPartyField: ThirdPartyField, + mapping: CaseConnectorMapping[] +): CaseConnectorMapping[] => + mapping.map((item) => { + if (item.source !== caseField && item.target === newThirdPartyField) { + return { ...item, target: 'not_mapped' }; + } else if (item.source === caseField) { + return { ...item, target: newThirdPartyField }; + } + return item; + }); + +export const getNoneConnector = (): CaseConnector => ({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, +}); + +export const getConnectorById = ( + id: string, + connectors: ActionConnector[] +): ActionConnector | null => connectors.find((c) => c.id === id) ?? null; + +export const normalizeActionConnector = ( + actionConnector: ActionConnector, + fields: CaseConnector['fields'] = null +): CaseConnector => { + const caseConnectorFieldsType = { + type: actionConnector.actionTypeId, + fields, + } as ConnectorTypeFields; + return { + id: actionConnector.id, + name: actionConnector.name, + ...caseConnectorFieldsType, + }; +}; + +export const normalizeCaseConnector = ( + connectors: ActionConnector[], + caseConnector: CaseConnector +): ActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null; diff --git a/x-pack/plugins/cases/public/components/connector_selector/form.test.tsx b/x-pack/plugins/cases/public/components/connector_selector/form.test.tsx new file mode 100644 index 0000000000000..ec136989dd937 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connector_selector/form.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { UseField, Form, useForm, FormHook } from '../../common/shared_imports'; +import { ConnectorSelector } from './form'; +import { connectorsMock } from '../../containers/mock'; +import { getFormMock } from '../__mock__/form'; + +jest.mock('../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'); + +const useFormMock = useForm as jest.Mock; + +describe('ConnectorSelector', () => { + const formHookMock = getFormMock({ connectorId: connectorsMock[0].id }); + + beforeEach(() => { + jest.resetAllMocks(); + useFormMock.mockImplementation(() => ({ form: formHookMock })); + }); + + it('it should render', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); + }); + + it('it should not render when is not in edit mode', async () => { + const wrapper = mount( +
+ + + ); + + expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connector_selector/form.tsx b/x-pack/plugins/cases/public/components/connector_selector/form.tsx new file mode 100644 index 0000000000000..210334e93adb8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connector_selector/form.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { isEmpty } from 'lodash/fp'; +import { EuiFormRow } from '@elastic/eui'; + +import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports'; +import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown'; +import { ActionConnector } from '../../../common'; + +interface ConnectorSelectorProps { + connectors: ActionConnector[]; + dataTestSubj: string; + disabled: boolean; + field: FieldHook; + idAria: string; + isEdit: boolean; + isLoading: boolean; + handleChange?: (newValue: string) => void; + hideConnectorServiceNowSir?: boolean; +} +export const ConnectorSelector = ({ + connectors, + dataTestSubj, + disabled = false, + field, + idAria, + isEdit = true, + isLoading = false, + handleChange, + hideConnectorServiceNowSir = false, +}: ConnectorSelectorProps) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const onChange = useCallback( + (val: string) => { + if (handleChange) { + handleChange(val); + } + field.setValue(val); + }, + [handleChange, field] + ); + + return isEdit ? ( + + + + ) : null; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/card.tsx b/x-pack/plugins/cases/public/components/connectors/card.tsx new file mode 100644 index 0000000000000..82a508ccf3432 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/card.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; +import styled from 'styled-components'; + +import { connectorsConfiguration } from '.'; +import { ConnectorTypes } from '../../../common'; + +interface ConnectorCardProps { + connectorType: ConnectorTypes; + title: string; + listItems: Array<{ title: string; description: React.ReactNode }>; + isLoading: boolean; +} + +const StyledText = styled.span` + span { + display: block; + } +`; + +const ConnectorCardDisplay: React.FC = ({ + connectorType, + title, + listItems, + isLoading, +}) => { + const description = useMemo( + () => ( + + {listItems.length > 0 && + listItems.map((item, i) => ( + + {`${item.title}: `} + {item.description} + + ))} + + ), + [listItems] + ); + const icon = useMemo( + () => , + [connectorType] + ); + return ( + <> + {isLoading && } + {!isLoading && ( + + )} + + ); +}; + +export const ConnectorCard = memo(ConnectorCardDisplay); diff --git a/x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx b/x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx new file mode 100644 index 0000000000000..10955db69461c --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @kbn/eslint/no-restricted-paths */ + +import React, { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; + +import { ActionParamsProps } from '../../../../../triggers_actions_ui/public/types'; +import { CommentType } from '../../../../common'; + +import { CaseActionParams } from './types'; +import { ExistingCase } from './existing_case'; + +import * as i18n from './translations'; + +const Container = styled.div` + ${({ theme }) => ` + padding: ${theme.eui?.euiSizeS ?? '8px'} ${theme.eui?.euiSizeL ?? '24px'} ${ + theme.eui?.euiSizeL ?? '24px' + } ${theme.eui?.euiSizeL ?? '24px'}; + `} +`; + +const defaultAlertComment = { + type: CommentType.generatedAlert, + alerts: `[{{#context.alerts}}{"_id": "{{_id}}", "_index": "{{_index}}", "ruleId": "{{signal.rule.id}}", "ruleName": "{{signal.rule.name}}"}__SEPARATOR__{{/context.alerts}}]`, +}; + +const CaseParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + errors, + messageVariables, + actionConnector, +}) => { + const { caseId = null, comment = defaultAlertComment } = actionParams.subActionParams ?? {}; + + const [selectedCase, setSelectedCase] = useState(null); + + const editSubActionProperty = useCallback( + (key: string, value: unknown) => { + const newProps = { ...actionParams.subActionParams, [key]: value }; + editAction('subActionParams', newProps, index); + }, + // edit action causes re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps + [actionParams.subActionParams, index] + ); + + const onCaseChanged = useCallback( + (id: string) => { + setSelectedCase(id); + editSubActionProperty('caseId', id); + }, + [editSubActionProperty] + ); + + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', 'addComment', index); + } + + if (!actionParams.subActionParams?.caseId) { + editSubActionProperty('caseId', caseId); + } + + if (!actionParams.subActionParams?.comment) { + editSubActionProperty('comment', comment); + } + + if (caseId != null) { + setSelectedCase((prevCaseId) => (prevCaseId !== caseId ? caseId : prevCaseId)); + } + + // editAction creates an infinity loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + actionConnector, + index, + actionParams.subActionParams?.caseId, + actionParams.subActionParams?.comment, + caseId, + comment, + actionParams.subAction, + ]); + + return ( + + + + +

{i18n.CASE_CONNECTOR_CALL_OUT_MSG}

+
+
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export { CaseParamsFields as default }; diff --git a/x-pack/plugins/cases/public/components/connectors/case/cases_dropdown.tsx b/x-pack/plugins/cases/public/components/connectors/case/cases_dropdown.tsx new file mode 100644 index 0000000000000..3f3c7d4931192 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/case/cases_dropdown.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui'; +import React, { memo, useMemo, useCallback } from 'react'; +import { Case } from '../../../containers/types'; + +import * as i18n from './translations'; + +interface CaseDropdownProps { + isLoading: boolean; + cases: Case[]; + selectedCase?: string; + onCaseChanged: (id: string) => void; +} + +export const ADD_CASE_BUTTON_ID = 'add-case'; + +const addNewCase = { + value: ADD_CASE_BUTTON_ID, + inputDisplay: ( + + {i18n.CASE_CONNECTOR_ADD_NEW_CASE} + + ), + 'data-test-subj': 'dropdown-connector-add-connector', +}; + +const CasesDropdownComponent: React.FC = ({ + isLoading, + cases, + selectedCase, + onCaseChanged, +}) => { + const caseOptions: Array> = useMemo( + () => + cases.reduce>>( + (acc, theCase) => [ + ...acc, + { + value: theCase.id, + inputDisplay: {theCase.title}, + 'data-test-subj': `case-connector-cases-dropdown-${theCase.id}`, + }, + ], + [] + ), + [cases] + ); + + const options = useMemo(() => [...caseOptions, addNewCase], [caseOptions]); + const onChange = useCallback((id: string) => onCaseChanged(id), [onCaseChanged]); + + return ( + + + + ); +}; + +export const CasesDropdown = memo(CasesDropdownComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx b/x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx new file mode 100644 index 0000000000000..22798843dd856 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo, useCallback } from 'react'; +import { CaseType } from '../../../../common'; +import { + useGetCases, + DEFAULT_QUERY_PARAMS, + DEFAULT_FILTER_OPTIONS, +} from '../../../containers/use_get_cases'; +import { useCreateCaseModal } from '../../use_create_case_modal'; +import { CasesDropdown, ADD_CASE_BUTTON_ID } from './cases_dropdown'; + +interface ExistingCaseProps { + selectedCase: string | null; + onCaseChanged: (id: string) => void; +} + +const ExistingCaseComponent: React.FC = ({ onCaseChanged, selectedCase }) => { + const { data: cases, loading: isLoadingCases, refetchCases } = useGetCases(DEFAULT_QUERY_PARAMS, { + ...DEFAULT_FILTER_OPTIONS, + onlyCollectionType: true, + }); + + const onCaseCreated = useCallback( + (newCase) => { + refetchCases(); + onCaseChanged(newCase.id); + }, + [onCaseChanged, refetchCases] + ); + + const { modal, openModal } = useCreateCaseModal({ + onCaseCreated, + caseType: CaseType.collection, + // FUTURE DEVELOPER + // We are making the assumption that this component is only used in rules creation + // that's why we want to hide ServiceNow SIR + hideConnectorServiceNowSir: true, + }); + + const onChange = useCallback( + (id: string) => { + if (id === ADD_CASE_BUTTON_ID) { + openModal(); + return; + } + + onCaseChanged(id); + }, + [onCaseChanged, openModal] + ); + + const isCasesLoading = useMemo( + () => isLoadingCases.includes('cases') || isLoadingCases.includes('caseUpdate'), + [isLoadingCases] + ); + + return ( + <> + + {modal} + + ); +}; + +export const ExistingCase = memo(ExistingCaseComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/case/index.ts b/x-pack/plugins/cases/public/components/connectors/case/index.ts new file mode 100644 index 0000000000000..c2cf4980da7ec --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/case/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ActionTypeModel } from '../../../../../triggers_actions_ui/public/types'; +import { CaseActionParams } from './types'; +import * as i18n from './translations'; + +interface ValidationResult { + errors: { + caseId: string[]; + }; +} + +const validateParams = (actionParams: CaseActionParams) => { + const validationResult: ValidationResult = { errors: { caseId: [] } }; + + if (actionParams.subActionParams && !actionParams.subActionParams.caseId) { + validationResult.errors.caseId.push(i18n.CASE_CONNECTOR_CASE_REQUIRED); + } + + return validationResult; +}; + +export function getActionType(): ActionTypeModel { + return { + id: '.case', + iconClass: 'securityAnalyticsApp', + selectMessage: i18n.CASE_CONNECTOR_DESC, + actionTypeTitle: i18n.CASE_CONNECTOR_TITLE, + validateConnector: () => ({ config: { errors: {} }, secrets: { errors: {} } }), + validateParams, + actionConnectorFields: null, + actionParamsFields: lazy(() => import('./alert_fields')), + }; +} diff --git a/x-pack/plugins/cases/public/components/connectors/case/translations.ts b/x-pack/plugins/cases/public/components/connectors/case/translations.ts new file mode 100644 index 0000000000000..8304aaef5765c --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/case/translations.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'; + +export * from '../../../common/translations'; + +export const CASE_CONNECTOR_DESC = i18n.translate( + 'xpack.cases.components.connectors.cases.selectMessageText', + { + defaultMessage: 'Create or update a case.', + } +); + +export const CASE_CONNECTOR_TITLE = i18n.translate( + 'xpack.cases.components.connectors.cases.actionTypeTitle', + { + defaultMessage: 'Cases', + } +); + +export const CASE_CONNECTOR_COMMENT_LABEL = i18n.translate( + 'xpack.cases.components.connectors.cases.commentLabel', + { + defaultMessage: 'Comment', + } +); + +export const CASE_CONNECTOR_COMMENT_REQUIRED = i18n.translate( + 'xpack.cases.components.connectors.cases.commentRequired', + { + defaultMessage: 'Comment is required.', + } +); + +export const CASE_CONNECTOR_CASES_DROPDOWN_ROW_LABEL = i18n.translate( + 'xpack.cases.components.connectors.cases.casesDropdownRowLabel', + { + defaultMessage: 'Case allowing sub-cases', + } +); + +export const CASE_CONNECTOR_CASES_DROPDOWN_PLACEHOLDER = i18n.translate( + 'xpack.cases.components.connectors.cases.casesDropdownPlaceholder', + { + defaultMessage: 'Select case', + } +); + +export const CASE_CONNECTOR_CASES_OPTION_NEW_CASE = i18n.translate( + 'xpack.cases.components.connectors.cases.optionAddNewCase', + { + defaultMessage: 'Add to a new case', + } +); + +export const CASE_CONNECTOR_CASES_OPTION_EXISTING_CASE = i18n.translate( + 'xpack.cases.components.connectors.cases.optionAddToExistingCase', + { + defaultMessage: 'Add to existing case', + } +); + +export const CASE_CONNECTOR_CASE_REQUIRED = i18n.translate( + 'xpack.cases.components.connectors.cases.caseRequired', + { + defaultMessage: 'You must select a case.', + } +); + +export const CASE_CONNECTOR_CALL_OUT_TITLE = i18n.translate( + 'xpack.cases.components.connectors.cases.callOutTitle', + { + defaultMessage: 'Generated alerts will be attached to sub-cases', + } +); + +export const CASE_CONNECTOR_CALL_OUT_MSG = i18n.translate( + 'xpack.cases.components.connectors.cases.callOutMsg', + { + defaultMessage: + 'A case can contain multiple sub-cases to allow grouping of generated alerts. Sub-cases will give more granular control over the status of these generated alerts and prevents having too many alerts attached to one case.', + } +); + +export const CASE_CONNECTOR_ADD_NEW_CASE = i18n.translate( + 'xpack.cases.components.connectors.cases.addNewCaseOption', + { + defaultMessage: 'Add new case', + } +); + +export const CREATE_CASE = i18n.translate( + 'xpack.cases.components.connectors.cases.createCaseLabel', + { + defaultMessage: 'Create case', + } +); + +export const CONNECTED_CASE = i18n.translate( + 'xpack.cases.components.connectors.cases.connectedCaseLabel', + { + defaultMessage: 'Connected case', + } +); diff --git a/x-pack/plugins/cases/public/components/connectors/case/types.ts b/x-pack/plugins/cases/public/components/connectors/case/types.ts new file mode 100644 index 0000000000000..aec9e09ea198c --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/case/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface CaseActionParams { + subAction: string; + subActionParams: { + caseId: string; + comment: { + alertId: string; + index: string; + type: 'alert'; + }; + }; +} diff --git a/x-pack/plugins/cases/public/components/connectors/config.ts b/x-pack/plugins/cases/public/components/connectors/config.ts new file mode 100644 index 0000000000000..e8d87511c7e17 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/config.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getResilientActionType, + getServiceNowITSMActionType, + getServiceNowSIRActionType, + getJiraActionType, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../triggers_actions_ui/public/common'; +import { ConnectorConfiguration } from './types'; + +const resilient = getResilientActionType(); +const serviceNowITSM = getServiceNowITSMActionType(); +const serviceNowSIR = getServiceNowSIRActionType(); +const jira = getJiraActionType(); + +export const connectorsConfiguration: Record = { + '.servicenow': { + name: serviceNowITSM.actionTypeTitle ?? '', + logo: serviceNowITSM.iconClass, + }, + '.servicenow-sir': { + name: serviceNowSIR.actionTypeTitle ?? '', + logo: serviceNowSIR.iconClass, + }, + '.jira': { + name: jira.actionTypeTitle ?? '', + logo: jira.iconClass, + }, + '.resilient': { + name: resilient.actionTypeTitle ?? '', + logo: resilient.iconClass, + }, +}; diff --git a/x-pack/plugins/cases/public/components/connectors/connectors_registry.ts b/x-pack/plugins/cases/public/components/connectors/connectors_registry.ts new file mode 100644 index 0000000000000..2e02cb290c3c8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/connectors_registry.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { CaseConnector, CaseConnectorsRegistry } from './types'; + +export const createCaseConnectorsRegistry = (): CaseConnectorsRegistry => { + const connectors: Map> = new Map(); + + const registry: CaseConnectorsRegistry = { + has: (id: string) => connectors.has(id), + register: (connector: CaseConnector) => { + if (connectors.has(connector.id)) { + throw new Error( + i18n.translate('xpack.cases.connecors.register.duplicateCaseConnectorErrorMessage', { + defaultMessage: 'Object type "{id}" is already registered.', + values: { + id: connector.id, + }, + }) + ); + } + + connectors.set(connector.id, connector); + }, + get: (id: string): CaseConnector => { + if (!connectors.has(id)) { + throw new Error( + i18n.translate('xpack.cases.connecors.get.missingCaseConnectorErrorMessage', { + defaultMessage: 'Object type "{id}" is not registered.', + values: { + id, + }, + }) + ); + } + return connectors.get(id)!; + }, + list: () => { + return Array.from(connectors).map(([id, connector]) => connector); + }, + }; + + return registry; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/fields_form.tsx b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx new file mode 100644 index 0000000000000..d71da6f87689d --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, Suspense } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +import { CaseActionConnector, ConnectorFieldsProps } from './types'; +import { getCaseConnectors } from '.'; +import { ConnectorTypeFields } from '../../../common'; + +interface Props extends Omit, 'connector'> { + connector: CaseActionConnector | null; +} + +const ConnectorFieldsFormComponent: React.FC = ({ connector, isEdit, onChange, fields }) => { + const { caseConnectorsRegistry } = getCaseConnectors(); + + if (connector == null || connector.actionTypeId == null || connector.actionTypeId === '.none') { + return null; + } + + const { fieldsComponent: FieldsComponent } = caseConnectorsRegistry.get(connector.actionTypeId); + + return ( + <> + {FieldsComponent != null ? ( + + + + +
+ } + > +
+ +
+ + ) : null} + + ); +}; + +export const ConnectorFieldsForm = memo(ConnectorFieldsFormComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/index.ts b/x-pack/plugins/cases/public/components/connectors/index.ts new file mode 100644 index 0000000000000..7444c403a3b60 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseConnectorsRegistry } from './types'; +import { createCaseConnectorsRegistry } from './connectors_registry'; +import { getCaseConnector as getJiraCaseConnector } from './jira'; +import { getCaseConnector as getResilientCaseConnector } from './resilient'; +import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow'; +import { + JiraFieldsType, + ServiceNowITSMFieldsType, + ServiceNowSIRFieldsType, + ResilientFieldsType, +} from '../../../common'; + +export { getActionType as getCaseConnectorUI } from './case'; + +export * from './config'; +export * from './types'; + +interface GetCaseConnectorsReturn { + caseConnectorsRegistry: CaseConnectorsRegistry; +} + +class CaseConnectors { + private caseConnectorsRegistry: CaseConnectorsRegistry; + + constructor() { + this.caseConnectorsRegistry = createCaseConnectorsRegistry(); + this.init(); + } + + private init() { + this.caseConnectorsRegistry.register(getJiraCaseConnector()); + this.caseConnectorsRegistry.register(getResilientCaseConnector()); + this.caseConnectorsRegistry.register( + getServiceNowITSMCaseConnector() + ); + this.caseConnectorsRegistry.register(getServiceNowSIRCaseConnector()); + } + + registry(): CaseConnectorsRegistry { + return this.caseConnectorsRegistry; + } +} + +const caseConnectors = new CaseConnectors(); + +export const getCaseConnectors = (): GetCaseConnectorsReturn => { + return { + caseConnectorsRegistry: caseConnectors.registry(), + }; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/jira/__mocks__/api.ts b/x-pack/plugins/cases/public/components/connectors/jira/__mocks__/api.ts new file mode 100644 index 0000000000000..3a7b51545dfca --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/__mocks__/api.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 { GetIssueTypesProps, GetFieldsByIssueTypeProps, GetIssueTypeProps } from '../api'; +import { IssueTypes, Fields, Issues, Issue } from '../types'; +import { issues } from '../../mock'; + +const issueTypes = [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, +]; + +const fieldsByIssueType = { + summary: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, +}; + +export const getIssue = async (props: GetIssueTypeProps): Promise<{ data: Issue }> => + Promise.resolve({ data: issues[0] }); +export const getIssues = async (props: GetIssueTypesProps): Promise<{ data: Issues }> => + Promise.resolve({ data: issues }); +export const getIssueTypes = async (props: GetIssueTypesProps): Promise<{ data: IssueTypes }> => + Promise.resolve({ data: issueTypes }); + +export const getFieldsByIssueType = async ( + props: GetFieldsByIssueTypeProps +): Promise<{ data: Fields }> => Promise.resolve({ data: fieldsByIssueType }); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/api.test.ts b/x-pack/plugins/cases/public/components/connectors/jira/api.test.ts new file mode 100644 index 0000000000000..bbab8a14b5ed9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/api.test.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { getIssueTypes, getFieldsByIssueType, getIssues, getIssue } from './api'; + +const issueTypesResponse = { + data: { + projects: [ + { + issuetypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], + }, + ], + }, +}; + +const fieldsResponse = { + data: { + projects: [ + { + issuetypes: [ + { + id: '10006', + name: 'Task', + fields: { + summary: { fieldId: 'summary' }, + priority: { + fieldId: 'priority', + allowedValues: [ + { + name: 'Highest', + id: '1', + }, + { + name: 'High', + id: '2', + }, + { + name: 'Medium', + id: '3', + }, + { + name: 'Low', + id: '4', + }, + { + name: 'Lowest', + id: '5', + }, + ], + defaultValue: { + name: 'Medium', + id: '3', + }, + }, + }, + }, + ], + }, + ], + }, +}; + +const issueResponse = { + id: '10267', + key: 'RJ-107', + fields: { summary: 'Test title' }, +}; + +const issuesResponse = [issueResponse]; + +describe('Jira API', () => { + const http = httpServiceMock.createStartContract(); + + beforeEach(() => jest.resetAllMocks()); + + describe('getIssueTypes', () => { + test('should call get issue types API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(issueTypesResponse); + const res = await getIssueTypes({ http, signal: abortCtrl.signal, connectorId: 'test' }); + + expect(res).toEqual(issueTypesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"issueTypes","subActionParams":{}}}', + signal: abortCtrl.signal, + }); + }); + }); + + describe('getFieldsByIssueType', () => { + test('should call get fields API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(fieldsResponse); + const res = await getFieldsByIssueType({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + id: '10006', + }); + + expect(res).toEqual(fieldsResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"fieldsByIssueType","subActionParams":{"id":"10006"}}}', + signal: abortCtrl.signal, + }); + }); + }); + + describe('getIssues', () => { + test('should call get fields API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(issuesResponse); + const res = await getIssues({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + title: 'test issue', + }); + + expect(res).toEqual(issuesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"issues","subActionParams":{"title":"test issue"}}}', + signal: abortCtrl.signal, + }); + }); + }); + + describe('getIssue', () => { + test('should call get fields API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(issuesResponse); + const res = await getIssue({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + id: 'RJ-107', + }); + + expect(res).toEqual(issuesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"issue","subActionParams":{"id":"RJ-107"}}}', + signal: abortCtrl.signal, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/api.ts b/x-pack/plugins/cases/public/components/connectors/jira/api.ts new file mode 100644 index 0000000000000..dff3e3a5b41ab --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/api.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from 'kibana/public'; +import { ActionTypeExecutorResult } from '../../../../../actions/common'; +import { IssueTypes, Fields, Issues, Issue } from './types'; + +export const BASE_ACTION_API_PATH = '/api/actions'; + +export interface GetIssueTypesProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; +} + +export async function getIssueTypes({ http, signal, connectorId }: GetIssueTypesProps) { + return http.post>( + `${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'issueTypes', subActionParams: {} }, + }), + signal, + } + ); +} + +export interface GetFieldsByIssueTypeProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + id: string; +} + +export async function getFieldsByIssueType({ + http, + signal, + connectorId, + id, +}: GetFieldsByIssueTypeProps): Promise> { + return http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'fieldsByIssueType', subActionParams: { id } }, + }), + signal, + }); +} + +export interface GetIssuesTypeProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + title: string; +} + +export async function getIssues({ + http, + signal, + connectorId, + title, +}: GetIssuesTypeProps): Promise> { + return http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'issues', subActionParams: { title } }, + }), + signal, + }); +} + +export interface GetIssueTypeProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + id: string; +} + +export async function getIssue({ + http, + signal, + connectorId, + id, +}: GetIssueTypeProps): Promise> { + return http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'issue', subActionParams: { id } }, + }), + signal, + }); +} diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx new file mode 100644 index 0000000000000..38a1e30616200 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { omit } from 'lodash/fp'; + +import { connector, issues } from '../mock'; +import { useGetIssueTypes } from './use_get_issue_types'; +import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; +import Fields from './case_fields'; +import { waitFor } from '@testing-library/dom'; +import { useGetSingleIssue } from './use_get_single_issue'; +import { useGetIssues } from './use_get_issues'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +jest.mock('./use_get_issue_types'); +jest.mock('./use_get_fields_by_issue_type'); +jest.mock('./use_get_single_issue'); +jest.mock('./use_get_issues'); +jest.mock('../../../common/lib/kibana'); +const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; +const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; +const useGetSingleIssueMock = useGetSingleIssue as jest.Mock; +const useGetIssuesMock = useGetIssues as jest.Mock; + +describe('Jira Fields', () => { + const useGetIssueTypesResponse = { + isLoading: false, + issueTypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], + }; + + const useGetFieldsByIssueTypeResponse = { + isLoading: false, + fields: { + summary: { allowedValues: [], defaultValue: {} }, + labels: { allowedValues: [], defaultValue: {} }, + description: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + { + name: 'Low', + id: '2', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, + }, + }; + + const useGetSingleIssueResponse = { + isLoading: false, + issue: { title: 'Parent Task', key: 'parentId' }, + }; + + const fields = { + issueType: '10006', + priority: 'High', + parent: null, + }; + + const useGetIssuesResponse = { + isLoading: false, + issues, + }; + + const onChange = jest.fn(); + + beforeEach(() => { + useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); + useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); + useGetSingleIssueMock.mockReturnValue(useGetSingleIssueResponse); + jest.clearAllMocks(); + }); + + test('all params fields are rendered - isEdit: true', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('value')).toStrictEqual( + '10006' + ); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('value')).toStrictEqual( + 'High' + ); + expect(wrapper.find('[data-test-subj="search-parent-issues"]').first().exists()).toBeFalsy(); + }); + + test('all params fields are rendered - isEdit: false', () => { + const wrapper = mount( + + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( + 'Issue type: Task' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( + 'Parent issue: Parent Task' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( + 'Priority: High' + ); + }); + + test('it sets parent correctly', async () => { + useGetFieldsByIssueTypeMock.mockReturnValue({ + ...useGetFieldsByIssueTypeResponse, + fields: { + ...useGetFieldsByIssueTypeResponse.fields, + parent: {}, + }, + }); + useGetIssuesMock.mockReturnValue(useGetIssuesResponse); + const wrapper = mount(); + + await waitFor(() => + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'parentId', value: 'parentId' }]) + ); + wrapper.update(); + expect(onChange).toHaveBeenCalledWith({ + issueType: '10006', + parent: 'parentId', + priority: 'High', + }); + }); + test('it searches parent correctly', async () => { + useGetFieldsByIssueTypeMock.mockReturnValue({ + ...useGetFieldsByIssueTypeResponse, + fields: { + ...useGetFieldsByIssueTypeResponse.fields, + parent: {}, + }, + }); + useGetSingleIssueMock.mockReturnValue({ useGetSingleIssueResponse, issue: null }); + useGetIssuesMock.mockReturnValue(useGetIssuesResponse); + const wrapper = mount(); + + await waitFor(() => + ((wrapper.find(EuiComboBox).props() as unknown) as { + onSearchChange: (a: string) => void; + }).onSearchChange('womanId') + ); + wrapper.update(); + expect(useGetIssuesMock.mock.calls[2][0].query).toEqual('womanId'); + }); + + test('it disabled the fields when loading issue types', () => { + useGetIssueTypesMock.mockReturnValue({ ...useGetIssueTypesResponse, isLoading: true }); + + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('disabled') + ).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('disabled')).toBeTruthy(); + }); + + test('it disabled the fields when loading fields', () => { + useGetFieldsByIssueTypeMock.mockReturnValue({ + ...useGetFieldsByIssueTypeResponse, + isLoading: true, + }); + + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('disabled') + ).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('disabled')).toBeTruthy(); + }); + + test('it hides the priority if not supported', () => { + const response = omit('fields.priority', useGetFieldsByIssueTypeResponse); + + useGetFieldsByIssueTypeMock.mockReturnValue(response); + + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().exists()).toBeFalsy(); + }); + + test('it sets issue type correctly', () => { + const wrapper = mount(); + + wrapper + .find('select[data-test-subj="issueTypeSelect"]') + .first() + .simulate('change', { + target: { value: '10007' }, + }); + + expect(onChange).toHaveBeenCalledWith({ issueType: '10007', parent: null, priority: null }); + }); + + test('it sets issue type when it comes as null', () => { + const wrapper = mount( + + ); + expect(wrapper.find('select[data-test-subj="issueTypeSelect"]').first().props().value).toEqual( + '10006' + ); + }); + + test('it sets issue type when it comes as unknown value', () => { + const wrapper = mount( + + ); + expect(wrapper.find('select[data-test-subj="issueTypeSelect"]').first().props().value).toEqual( + '10006' + ); + }); + + test('it sets priority correctly', () => { + const wrapper = mount(); + + wrapper + .find('select[data-test-subj="prioritySelect"]') + .first() + .simulate('change', { + target: { value: '2' }, + }); + + expect(onChange).toHaveBeenCalledWith({ issueType: '10006', parent: null, priority: '2' }); + }); + + test('it resets priority when changing issue type', () => { + const wrapper = mount(); + wrapper + .find('select[data-test-subj="issueTypeSelect"]') + .first() + .simulate('change', { + target: { value: '10007' }, + }); + + expect(onChange).toBeCalledWith({ issueType: '10007', parent: null, priority: null }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx new file mode 100644 index 0000000000000..6aff81f380015 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, useEffect, useRef } from 'react'; +import { map } from 'lodash/fp'; +import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import * as i18n from './translations'; + +import { ConnectorTypes, JiraFieldsType } from '../../../../common'; +import { useKibana } from '../../../common/lib/kibana'; +import { ConnectorFieldsProps } from '../types'; +import { useGetIssueTypes } from './use_get_issue_types'; +import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; +import { SearchIssues } from './search_issues'; +import { ConnectorCard } from '../card'; + +const JiraFieldsComponent: React.FunctionComponent> = ({ + connector, + fields, + isEdit = true, + onChange, +}) => { + const init = useRef(true); + const { issueType = null, priority = null, parent = null } = fields ?? {}; + const { http, notifications } = useKibana().services; + + const handleIssueType = useCallback( + (issueTypeSelectOptions: Array<{ value: string; text: string }>) => { + if (issueType == null && issueTypeSelectOptions.length > 0) { + // if there is no issue type set in the edit view, set it to default + if (isEdit) { + onChange({ + issueType: issueTypeSelectOptions[0].value, + parent, + priority, + }); + } + } + }, + [isEdit, issueType, onChange, parent, priority] + ); + const { isLoading: isLoadingIssueTypes, issueTypes } = useGetIssueTypes({ + connector, + http, + toastNotifications: notifications.toasts, + handleIssueType, + }); + + const issueTypesSelectOptions = useMemo( + () => + issueTypes.map((type) => ({ + text: type.name ?? '', + value: type.id ?? '', + })), + [issueTypes] + ); + + const currentIssueType = useMemo(() => { + if (!issueType && issueTypesSelectOptions.length > 0) { + return issueTypesSelectOptions[0].value; + } else if ( + issueTypesSelectOptions.length > 0 && + !issueTypesSelectOptions.some(({ value }) => value === issueType) + ) { + return issueTypesSelectOptions[0].value; + } + return issueType; + }, [issueType, issueTypesSelectOptions]); + + const { isLoading: isLoadingFields, fields: fieldsByIssueType } = useGetFieldsByIssueType({ + connector, + http, + issueType: currentIssueType, + toastNotifications: notifications.toasts, + }); + + const hasPriority = useMemo(() => fieldsByIssueType.priority != null, [fieldsByIssueType]); + + const hasParent = useMemo(() => fieldsByIssueType.parent != null, [fieldsByIssueType]); + + const prioritiesSelectOptions = useMemo(() => { + const priorities = fieldsByIssueType.priority?.allowedValues ?? []; + return map( + (p) => ({ + text: p.name, + value: p.name, + }), + priorities + ); + }, [fieldsByIssueType]); + + const listItems = useMemo( + () => [ + ...(issueType != null && issueType.length > 0 + ? [ + { + title: i18n.ISSUE_TYPE, + description: issueTypes.find((issue) => issue.id === issueType)?.name ?? '', + }, + ] + : []), + ...(parent != null && parent.length > 0 + ? [ + { + title: i18n.PARENT_ISSUE, + description: parent, + }, + ] + : []), + ...(priority != null && priority.length > 0 + ? [ + { + title: i18n.PRIORITY, + description: priority, + }, + ] + : []), + ], + [issueType, issueTypes, parent, priority] + ); + + const onFieldChange = useCallback( + (key, value) => { + if (key === 'issueType') { + return onChange({ ...fields, issueType: value, priority: null, parent: null }); + } + return onChange({ + ...fields, + issueType: currentIssueType, + parent, + priority, + [key]: value, + }); + }, + [currentIssueType, fields, onChange, parent, priority] + ); + + // Set field at initialization + useEffect(() => { + if (init.current) { + init.current = false; + onChange({ issueType, priority, parent }); + } + }, [issueType, onChange, parent, priority]); + + return isEdit ? ( +
+ + onFieldChange('issueType', e.target.value)} + options={issueTypesSelectOptions} + value={currentIssueType ?? ''} + /> + + + <> + {hasParent && ( + <> + + + + onFieldChange('parent', parentIssueKey)} + selectedValue={parent} + /> + + + + + + )} + {hasPriority && ( + <> + + + + onFieldChange('priority', e.target.value)} + options={prioritiesSelectOptions} + value={priority ?? ''} + /> + + + + + )} + +
+ ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { JiraFieldsComponent as default }; diff --git a/x-pack/plugins/cases/public/components/connectors/jira/index.ts b/x-pack/plugins/cases/public/components/connectors/jira/index.ts new file mode 100644 index 0000000000000..ea408a1bd6664 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; + +import { CaseConnector } from '../types'; +import { JiraFieldsType } from '../../../../common'; +import * as i18n from './translations'; + +export * from './types'; + +export const getCaseConnector = (): CaseConnector => { + return { + id: '.jira', + fieldsComponent: lazy(() => import('./case_fields')), + }; +}; + +export const fieldLabels = { + issueType: i18n.ISSUE_TYPE, + priority: i18n.PRIORITY, + parent: i18n.PARENT_ISSUE, +}; diff --git a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx new file mode 100644 index 0000000000000..9270abed0881f --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useEffect, useCallback, useState, memo } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { useKibana } from '../../../common/lib/kibana'; +import { ActionConnector } from '../../../containers/types'; +import { useGetIssues } from './use_get_issues'; +import { useGetSingleIssue } from './use_get_single_issue'; +import * as i18n from './translations'; + +interface Props { + selectedValue: string | null; + actionConnector?: ActionConnector; + onChange: (parentIssueKey: string) => void; +} + +const SearchIssuesComponent: React.FC = ({ selectedValue, actionConnector, onChange }) => { + const [query, setQuery] = useState(null); + const [selectedOptions, setSelectedOptions] = useState>>( + [] + ); + const [options, setOptions] = useState>>([]); + const { http, notifications } = useKibana().services; + + const { isLoading: isLoadingIssues, issues } = useGetIssues({ + http, + toastNotifications: notifications.toasts, + actionConnector, + query, + }); + + const { isLoading: isLoadingSingleIssue, issue: singleIssue } = useGetSingleIssue({ + http, + toastNotifications: notifications.toasts, + actionConnector, + id: selectedValue, + }); + + useEffect(() => setOptions(issues.map((issue) => ({ label: issue.title, value: issue.key }))), [ + issues, + ]); + + useEffect(() => { + if (isLoadingSingleIssue || singleIssue == null) { + return; + } + + const singleIssueAsOptions = [{ label: singleIssue.title, value: singleIssue.key }]; + setOptions(singleIssueAsOptions); + setSelectedOptions(singleIssueAsOptions); + }, [singleIssue, isLoadingSingleIssue]); + + const onSearchChange = useCallback((searchVal: string) => { + setQuery(searchVal); + }, []); + + const onChangeComboBox = useCallback( + (changedOptions) => { + setSelectedOptions(changedOptions); + onChange(changedOptions[0].value); + }, + [onChange] + ); + + const inputPlaceholder = useMemo( + (): string => + isLoadingIssues || isLoadingSingleIssue + ? i18n.SEARCH_ISSUES_LOADING + : i18n.SEARCH_ISSUES_PLACEHOLDER, + [isLoadingIssues, isLoadingSingleIssue] + ); + + return ( + + ); +}; + +export const SearchIssues = memo(SearchIssuesComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/translations.ts b/x-pack/plugins/cases/public/components/connectors/jira/translations.ts new file mode 100644 index 0000000000000..88dd7d0c7c27b --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/translations.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ISSUE_TYPES_API_ERROR = i18n.translate( + 'xpack.cases.connectors.jira.unableToGetIssueTypesMessage', + { + defaultMessage: 'Unable to get issue types', + } +); + +export const FIELDS_API_ERROR = i18n.translate( + 'xpack.cases.connectors.jira.unableToGetFieldsMessage', + { + defaultMessage: 'Unable to get connectors', + } +); + +export const ISSUES_API_ERROR = i18n.translate( + 'xpack.cases.connectors.jira.unableToGetIssuesMessage', + { + defaultMessage: 'Unable to get issues', + } +); + +export const GET_ISSUE_API_ERROR = (id: string) => + i18n.translate('xpack.cases.connectors.jira.unableToGetIssueMessage', { + defaultMessage: 'Unable to get issue with id {id}', + values: { id }, + }); + +export const SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL = i18n.translate( + 'xpack.cases.connectors.jira.searchIssuesComboBoxAriaLabel', + { + defaultMessage: 'Type to search', + } +); + +export const SEARCH_ISSUES_PLACEHOLDER = i18n.translate( + 'xpack.cases.connectors.jira.searchIssuesComboBoxPlaceholder', + { + defaultMessage: 'Type to search', + } +); + +export const SEARCH_ISSUES_LOADING = i18n.translate( + 'xpack.cases.connectors.jira.searchIssuesLoading', + { + defaultMessage: 'Loading...', + } +); + +export const PRIORITY = i18n.translate('xpack.cases.connectors.jira.prioritySelectFieldLabel', { + defaultMessage: 'Priority', +}); + +export const ISSUE_TYPE = i18n.translate('xpack.cases.connectors.jira.issueTypesSelectFieldLabel', { + defaultMessage: 'Issue type', +}); + +export const PARENT_ISSUE = i18n.translate('xpack.cases.connectors.jira.parentIssueSearchLabel', { + defaultMessage: 'Parent issue', +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/types.ts b/x-pack/plugins/cases/public/components/connectors/jira/types.ts new file mode 100644 index 0000000000000..76c08a852c679 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type IssueTypes = Array<{ id: string; name: string }>; +export interface Fields { + [key: string]: { + allowedValues: Array<{ name: string; id: string }> | []; + defaultValue: { name: string; id: string } | {}; + }; +} + +export interface Issue { + id: string; + key: string; + title: string; +} + +export type Issues = Issue[]; diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.test.tsx new file mode 100644 index 0000000000000..b4c2c848d79ed --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.test.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 { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../common/lib/kibana'; +import { connector } from '../mock'; +import { useGetFieldsByIssueType, UseGetFieldsByIssueType } from './use_get_fields_by_issue_type'; +import * as api from './api'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetFieldsByIssueType', () => { + const { http, notifications } = useKibanaMock().services; + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetFieldsByIssueType({ http, toastNotifications: notifications.toasts, issueType: null }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: true, fields: {} }); + }); + }); + + test('does not fetch when issueType is not provided', async () => { + const spyOnGetFieldsByIssueType = jest.spyOn(api, 'getFieldsByIssueType'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetFieldsByIssueType({ + http, + toastNotifications: notifications.toasts, + connector, + issueType: null, + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetFieldsByIssueType).not.toHaveBeenCalled(); + expect(result.current).toEqual({ isLoading: false, fields: {} }); + }); + }); + + test('fetch fields', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetFieldsByIssueType({ + http, + toastNotifications: notifications.toasts, + connector, + issueType: 'Task', + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + fields: { + summary: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, + }, + }); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getFieldsByIssueType'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetFieldsByIssueType({ + http, + toastNotifications: notifications.toasts, + connector, + issueType: null, + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, fields: {} }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.tsx new file mode 100644 index 0000000000000..03000e8916617 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_fields_by_issue_type.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getFieldsByIssueType } from './api'; +import { Fields } from './types'; +import * as i18n from './translations'; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + issueType: string | null; + connector?: ActionConnector; +} + +export interface UseGetFieldsByIssueType { + fields: Fields; + isLoading: boolean; +} + +export const useGetFieldsByIssueType = ({ + http, + toastNotifications, + connector, + issueType, +}: Props): UseGetFieldsByIssueType => { + const [isLoading, setIsLoading] = useState(true); + const [fields, setFields] = useState({}); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + const fetchData = async () => { + if (!connector || !issueType) { + setIsLoading(false); + return; + } + + try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getFieldsByIssueType({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + id: issueType, + }); + + if (!didCancel.current) { + setIsLoading(false); + setFields(res.data ?? {}); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.FIELDS_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.FIELDS_API_ERROR, + text: error.message, + }); + } + } + } + }; + + didCancel.current = false; + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; + }, [http, connector, issueType, toastNotifications]); + + return { + isLoading, + fields, + }; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.test.tsx new file mode 100644 index 0000000000000..6c1a9b5fcab08 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../common/lib/kibana'; +import { connector } from '../mock'; +import { useGetIssueTypes, UseGetIssueTypes } from './use_get_issue_types'; +import * as api from './api'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetIssueTypes', () => { + const { http, notifications } = useKibanaMock().services; + const handleIssueType = jest.fn(); + + beforeEach(() => jest.clearAllMocks()); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIssueTypes({ http, toastNotifications: notifications.toasts, handleIssueType }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: true, issueTypes: [] }); + }); + }); + + test('fetch issue types', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIssueTypes({ + http, + toastNotifications: notifications.toasts, + connector, + handleIssueType, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + issueTypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], + }); + }); + }); + + test('handleIssueType is called', async () => { + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useGetIssueTypes({ + http, + toastNotifications: notifications.toasts, + connector, + handleIssueType, + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(handleIssueType).toHaveBeenCalledWith([ + { text: 'Task', value: '10006' }, + { text: 'Bug', value: '10007' }, + ]); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getIssueTypes'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIssueTypes({ + http, + toastNotifications: notifications.toasts, + connector, + handleIssueType, + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, issueTypes: [] }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.tsx new file mode 100644 index 0000000000000..3c35d315a2bcd --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue_types.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getIssueTypes } from './api'; +import { IssueTypes } from './types'; +import * as i18n from './translations'; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ActionConnector; + handleIssueType: (options: Array<{ value: string; text: string }>) => void; +} + +export interface UseGetIssueTypes { + issueTypes: IssueTypes; + isLoading: boolean; +} + +export const useGetIssueTypes = ({ + http, + connector, + toastNotifications, + handleIssueType, +}: Props): UseGetIssueTypes => { + const [isLoading, setIsLoading] = useState(true); + const [issueTypes, setIssueTypes] = useState([]); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + const fetchData = async () => { + if (!connector) { + setIsLoading(false); + return; + } + + try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getIssueTypes({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + }); + + if (!didCancel.current) { + setIsLoading(false); + const asOptions = (res.data ?? []).map((type) => ({ + text: type.name ?? '', + value: type.id ?? '', + })); + setIssueTypes(res.data ?? []); + handleIssueType(asOptions); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.ISSUE_TYPES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.ISSUE_TYPES_API_ERROR, + text: error.message, + }); + } + } + } + }; + + didCancel.current = false; + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; + // handleIssueType unmounts the component at init causing the request to be aborted + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [http, connector, toastNotifications]); + + return { + issueTypes, + isLoading, + }; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.test.tsx new file mode 100644 index 0000000000000..2308fe604e710 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../common/lib/kibana'; +import { connector as actionConnector, issues } from '../mock'; +import { useGetIssues, UseGetIssues } from './use_get_issues'; +import * as api from './api'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetIssues', () => { + const { http, notifications } = useKibanaMock().services; + beforeEach(() => jest.clearAllMocks()); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIssues({ + http, + toastNotifications: notifications.toasts, + actionConnector, + query: null, + }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: false, issues: [] }); + }); + }); + + test('fetch issues', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIssues({ + http, + toastNotifications: notifications.toasts, + actionConnector, + query: 'Task', + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + issues, + }); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getIssues'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIssues({ + http, + toastNotifications: notifications.toasts, + actionConnector, + query: 'oh no', + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, issues: [] }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx new file mode 100644 index 0000000000000..b44b0558f1536 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty, debounce } from 'lodash/fp'; +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getIssues } from './api'; +import { Issues } from './types'; +import * as i18n from './translations'; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionConnector?: ActionConnector; + query: string | null; +} + +export interface UseGetIssues { + issues: Issues; + isLoading: boolean; +} + +export const useGetIssues = ({ + http, + actionConnector, + toastNotifications, + query, +}: Props): UseGetIssues => { + const [isLoading, setIsLoading] = useState(false); + const [issues, setIssues] = useState([]); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + const fetchData = debounce(500, async () => { + if (!actionConnector || isEmpty(query)) { + setIsLoading(false); + return; + } + + try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getIssues({ + http, + signal: abortCtrl.current.signal, + connectorId: actionConnector.id, + title: query ?? '', + }); + + if (!didCancel.current) { + setIsLoading(false); + setIssues(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.ISSUES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.ISSUES_API_ERROR, + text: error.message, + }); + } + } + } + }); + + didCancel.current = false; + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; + }, [http, actionConnector, toastNotifications, query]); + + return { + issues, + isLoading, + }; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.test.tsx new file mode 100644 index 0000000000000..28949b456ecdd --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../common/lib/kibana'; +import { connector as actionConnector, issues } from '../mock'; +import { useGetSingleIssue, UseGetSingleIssue } from './use_get_single_issue'; +import * as api from './api'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetSingleIssue', () => { + const { http, notifications } = useKibanaMock().services; + beforeEach(() => jest.clearAllMocks()); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetSingleIssue({ + http, + toastNotifications: notifications.toasts, + actionConnector, + id: null, + }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: false, issue: null }); + }); + }); + + test('fetch issues', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetSingleIssue({ + http, + toastNotifications: notifications.toasts, + actionConnector, + id: '123', + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + issue: issues[0], + }); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getIssue'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetSingleIssue({ + http, + toastNotifications: notifications.toasts, + actionConnector, + id: '123', + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, issue: null }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.tsx new file mode 100644 index 0000000000000..6c70286426168 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_single_issue.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getIssue } from './api'; +import { Issue } from './types'; +import * as i18n from './translations'; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + id: string | null; + actionConnector?: ActionConnector; +} + +export interface UseGetSingleIssue { + issue: Issue | null; + isLoading: boolean; +} + +export const useGetSingleIssue = ({ + http, + toastNotifications, + actionConnector, + id, +}: Props): UseGetSingleIssue => { + const [isLoading, setIsLoading] = useState(false); + const [issue, setIssue] = useState(null); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + const fetchData = async () => { + if (!actionConnector || !id) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + try { + const res = await getIssue({ + http, + signal: abortCtrl.current.signal, + connectorId: actionConnector.id, + id, + }); + + if (!didCancel.current) { + setIsLoading(false); + setIssue(res.data ?? null); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.GET_ISSUE_API_ERROR(id), + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.GET_ISSUE_API_ERROR(id), + text: error.message, + }); + } + } + } + }; + + didCancel.current = false; + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; + }, [http, actionConnector, id, toastNotifications]); + + return { + isLoading, + issue, + }; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/mock.ts b/x-pack/plugins/cases/public/components/connectors/mock.ts new file mode 100644 index 0000000000000..f5429fa2396aa --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/mock.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const connector = { + id: '123', + name: 'My connector', + actionTypeId: '.jira', + config: {}, + isPreconfigured: false, +}; + +export const issues = [ + { id: 'personId', title: 'Person Task', key: 'personKey' }, + { id: 'womanId', title: 'Woman Task', key: 'womanKey' }, + { id: 'manId', title: 'Man Task', key: 'manKey' }, + { id: 'cameraId', title: 'Camera Task', key: 'cameraKey' }, + { id: 'tvId', title: 'TV Task', key: 'tvKey' }, +]; + +export const choices = [ + { + dependent_value: '', + label: 'Priviledge Escalation', + value: 'Priviledge Escalation', + element: 'category', + }, + { + dependent_value: '', + label: 'Criminal activity/investigation', + value: 'Criminal activity/investigation', + element: 'category', + }, + { + dependent_value: '', + label: 'Denial of Service', + value: 'Denial of Service', + element: 'category', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound or outbound', + value: '12', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Single or distributed (DoS or DDoS)', + value: '26', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound DDos', + value: 'inbound_ddos', + element: 'subcategory', + }, + { + dependent_value: '', + label: 'Software', + value: 'software', + element: 'category', + }, + { + dependent_value: 'software', + label: 'Operation System', + value: 'os', + element: 'subcategory', + }, + ...['severity', 'urgency', 'impact', 'priority'] + .map((element) => [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element, + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element, + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element, + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element, + }, + ]) + .flat(), +]; + +export const severity = [ + { + id: 4, + name: 'Low', + }, + { + id: 5, + name: 'Medium', + }, + { + id: 6, + name: 'High', + }, +]; + +export const incidentTypes = [ + { id: 17, name: 'Communication error (fax; email)' }, + { id: 1001, name: 'Custom type' }, +]; diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/__mocks__/api.ts b/x-pack/plugins/cases/public/components/connectors/resilient/__mocks__/api.ts new file mode 100644 index 0000000000000..c27248288907d --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/__mocks__/api.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 { incidentTypes, severity } from '../../mock'; +import { Props } from '../api'; +import { ResilientIncidentTypes, ResilientSeverity } from '../types'; + +export const getIncidentTypes = async (props: Props): Promise<{ data: ResilientIncidentTypes }> => + Promise.resolve({ data: incidentTypes }); + +export const getSeverity = async (props: Props): Promise<{ data: ResilientSeverity }> => + Promise.resolve({ data: severity }); diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/api.ts b/x-pack/plugins/cases/public/components/connectors/resilient/api.ts new file mode 100644 index 0000000000000..5fec83f303950 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/api.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from 'kibana/public'; +import { ActionTypeExecutorResult } from '../../../../../actions/common'; +import { ResilientIncidentTypes, ResilientSeverity } from './types'; + +export const BASE_ACTION_API_PATH = '/api/actions'; + +export interface Props { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; +} + +export async function getIncidentTypes({ http, signal, connectorId }: Props) { + return http.post>( + `${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'incidentTypes', subActionParams: {} }, + }), + signal, + } + ); +} + +export async function getSeverity({ http, signal, connectorId }: Props) { + return http.post>( + `${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'severity', subActionParams: {} }, + }), + signal, + } + ); +} diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.test.tsx new file mode 100644 index 0000000000000..dda6ba5de95cc --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.test.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { waitFor } from '@testing-library/react'; + +import { connector } from '../mock'; +import { useGetIncidentTypes } from './use_get_incident_types'; +import { useGetSeverity } from './use_get_severity'; +import Fields from './case_fields'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./use_get_incident_types'); +jest.mock('./use_get_severity'); + +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; + +describe('ResilientParamsFields renders', () => { + const useGetIncidentTypesResponse = { + isLoading: false, + incidentTypes: [ + { + id: 19, + name: 'Malware', + }, + { + id: 21, + name: 'Denial of Service', + }, + ], + }; + + const useGetSeverityResponse = { + isLoading: false, + severity: [ + { + id: 4, + name: 'Low', + }, + { + id: 5, + name: 'Medium', + }, + { + id: 6, + name: 'High', + }, + ], + }; + + const fields = { + severityCode: '6', + incidentTypes: ['19'], + }; + + const onChange = jest.fn(); + + beforeEach(() => { + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + jest.clearAllMocks(); + }); + + test('all params fields are rendered', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('options')).toEqual( + [ + { label: 'Malware', value: '19' }, + { label: 'Denial of Service', value: '21' }, + ] + ); + + expect( + wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('selectedOptions') + ).toEqual([{ label: 'Malware', value: '19' }]); + + expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toStrictEqual( + '6' + ); + }); + + test('it disabled the fields when loading incident types', () => { + useGetIncidentTypesMock.mockReturnValue({ ...useGetIncidentTypesResponse, isLoading: true }); + + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('isDisabled') + ).toBeTruthy(); + }); + + test('it disabled the fields when loading severity', () => { + useGetSeverityMock.mockReturnValue({ + ...useGetSeverityResponse, + isLoading: true, + }); + + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('disabled')).toBeTruthy(); + }); + + test('it sets issue type correctly', async () => { + const wrapper = mount(); + + await waitFor(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ value: '19', label: 'Denial of Service' }]); + }); + + expect(onChange).toHaveBeenCalledWith({ incidentTypes: ['19'], severityCode: '6' }); + }); + + test('it sets severity correctly', async () => { + const wrapper = mount(); + + wrapper + .find('select[data-test-subj="severitySelect"]') + .first() + .simulate('change', { + target: { value: '4' }, + }); + + expect(onChange).toHaveBeenCalledWith({ incidentTypes: ['19'], severityCode: '4' }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx new file mode 100644 index 0000000000000..e1eeb13bf684c --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFormRow, + EuiSelect, + EuiSelectOption, + EuiSpacer, +} from '@elastic/eui'; + +import { useKibana } from '../../../common/lib/kibana'; +import { ConnectorFieldsProps } from '../types'; +import { useGetIncidentTypes } from './use_get_incident_types'; +import { useGetSeverity } from './use_get_severity'; + +import * as i18n from './translations'; +import { ConnectorTypes, ResilientFieldsType } from '../../../../common'; +import { ConnectorCard } from '../card'; + +const ResilientFieldsComponent: React.FunctionComponent< + ConnectorFieldsProps +> = ({ isEdit = true, fields, connector, onChange }) => { + const init = useRef(true); + const { incidentTypes = null, severityCode = null } = fields ?? {}; + + const { http, notifications } = useKibana().services; + + const { + isLoading: isLoadingIncidentTypes, + incidentTypes: allIncidentTypes, + } = useGetIncidentTypes({ + http, + toastNotifications: notifications.toasts, + connector, + }); + + const { isLoading: isLoadingSeverity, severity } = useGetSeverity({ + http, + toastNotifications: notifications.toasts, + connector, + }); + + const severitySelectOptions: EuiSelectOption[] = useMemo( + () => + severity.map((s) => ({ + value: s.id.toString(), + text: s.name, + })), + [severity] + ); + + const incidentTypesComboBoxOptions: Array> = useMemo( + () => + allIncidentTypes + ? allIncidentTypes.map((type: { id: number; name: string }) => ({ + label: type.name, + value: type.id.toString(), + })) + : [], + [allIncidentTypes] + ); + const listItems = useMemo( + () => [ + ...(incidentTypes != null && incidentTypes.length > 0 + ? [ + { + title: i18n.INCIDENT_TYPES_LABEL, + description: allIncidentTypes + .filter((type) => incidentTypes.includes(type.id.toString())) + .map((type) => type.name) + .join(', '), + }, + ] + : []), + ...(severityCode != null && severityCode.length > 0 + ? [ + { + title: i18n.SEVERITY_LABEL, + description: + severity.find((severityObj) => severityObj.id.toString() === severityCode)?.name ?? + '', + }, + ] + : []), + ], + [incidentTypes, severityCode, allIncidentTypes, severity] + ); + + const onFieldChange = useCallback( + (key, value) => { + onChange({ + ...fields, + incidentTypes, + severityCode, + [key]: value, + }); + }, + [incidentTypes, severityCode, onChange, fields] + ); + + const selectedIncidentTypesComboBoxOptionsMemo = useMemo(() => { + const allIncidentTypesAsObject = allIncidentTypes.reduce( + (acc, type) => ({ ...acc, [type.id.toString()]: type.name }), + {} as Record + ); + return incidentTypes + ? incidentTypes + .map((type) => ({ + label: allIncidentTypesAsObject[type.toString()], + value: type.toString(), + })) + .filter((type) => type.label != null) + : []; + }, [allIncidentTypes, incidentTypes]); + + const onIncidentChange = useCallback( + (selectedOptions: Array<{ label: string; value?: string }>) => { + onFieldChange( + 'incidentTypes', + selectedOptions.map((selectedOption) => selectedOption.value ?? selectedOption.label) + ); + }, + [onFieldChange] + ); + + const onIncidentBlur = useCallback(() => { + if (!incidentTypes) { + onFieldChange('incidentTypes', []); + } + }, [incidentTypes, onFieldChange]); + + // Set field at initialization + useEffect(() => { + if (init.current) { + init.current = false; + onChange({ incidentTypes, severityCode }); + } + }, [incidentTypes, onChange, severityCode]); + + return isEdit ? ( + + + + + + + onFieldChange('severityCode', e.target.value)} + options={severitySelectOptions} + value={severityCode ?? undefined} + /> + + + + ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ResilientFieldsComponent as default }; diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/index.ts b/x-pack/plugins/cases/public/components/connectors/resilient/index.ts new file mode 100644 index 0000000000000..c8e7ad9a063cb --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; + +import { CaseConnector } from '../types'; +import { ResilientFieldsType } from '../../../../common'; +import * as i18n from './translations'; + +export * from './types'; + +export const getCaseConnector = (): CaseConnector => { + return { + id: '.resilient', + fieldsComponent: lazy(() => import('./case_fields')), + }; +}; + +export const fieldLabels = { + incidentTypes: i18n.INCIDENT_TYPES_LABEL, + severityCode: i18n.SEVERITY_LABEL, +}; diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/translations.ts b/x-pack/plugins/cases/public/components/connectors/resilient/translations.ts new file mode 100644 index 0000000000000..1b63a5098e92a --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/translations.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INCIDENT_TYPES_API_ERROR = i18n.translate( + 'xpack.cases.connectors.resilient.unableToGetIncidentTypesMessage', + { + defaultMessage: 'Unable to get incident types', + } +); + +export const SEVERITY_API_ERROR = i18n.translate( + 'xpack.cases.connectors.resilient.unableToGetSeverityMessage', + { + defaultMessage: 'Unable to get severity', + } +); + +export const INCIDENT_TYPES_PLACEHOLDER = i18n.translate( + 'xpack.cases.connectors.resilient.incidentTypesPlaceholder', + { + defaultMessage: 'Choose types', + } +); + +export const INCIDENT_TYPES_LABEL = i18n.translate( + 'xpack.cases.connectors.resilient.incidentTypesLabel', + { + defaultMessage: 'Incident Types', + } +); + +export const SEVERITY_LABEL = i18n.translate('xpack.cases.connectors.resilient.severityLabel', { + defaultMessage: 'Severity', +}); diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/types.ts b/x-pack/plugins/cases/public/components/connectors/resilient/types.ts new file mode 100644 index 0000000000000..06506d2c0d2f9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type ResilientIncidentTypes = Array<{ id: number; name: string }>; +export type ResilientSeverity = ResilientIncidentTypes; diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.test.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.test.tsx new file mode 100644 index 0000000000000..59c1f8e9b40d0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../common/lib/kibana'; +import { connector } from '../mock'; +import { useGetIncidentTypes, UseGetIncidentTypes } from './use_get_incident_types'; +import * as api from './api'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetIncidentTypes', () => { + const { http, notifications } = useKibanaMock().services; + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIncidentTypes({ http, toastNotifications: notifications.toasts }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: true, incidentTypes: [] }); + }); + }); + + test('fetch incident types', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIncidentTypes({ + http, + toastNotifications: notifications.toasts, + connector, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + incidentTypes: [ + { id: 17, name: 'Communication error (fax; email)' }, + { id: 1001, name: 'Custom type' }, + ], + }); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getIncidentTypes'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIncidentTypes({ http, toastNotifications: notifications.toasts, connector }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, incidentTypes: [] }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.tsx new file mode 100644 index 0000000000000..34cbb0a69b0f4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_incident_types.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 { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getIncidentTypes } from './api'; +import * as i18n from './translations'; + +type IncidentTypes = Array<{ id: number; name: string }>; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ActionConnector; +} + +export interface UseGetIncidentTypes { + incidentTypes: IncidentTypes; + isLoading: boolean; +} + +export const useGetIncidentTypes = ({ + http, + toastNotifications, + connector, +}: Props): UseGetIncidentTypes => { + const [isLoading, setIsLoading] = useState(true); + const [incidentTypes, setIncidentTypes] = useState([]); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + const fetchData = async () => { + if (!connector) { + setIsLoading(false); + return; + } + + try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getIncidentTypes({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + }); + + if (!didCancel.current) { + setIsLoading(false); + setIncidentTypes(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.INCIDENT_TYPES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.INCIDENT_TYPES_API_ERROR, + text: error.message, + }); + } + } + } + }; + + didCancel.current = false; + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; + }, [http, connector, toastNotifications]); + + return { + incidentTypes, + isLoading, + }; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.test.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.test.tsx new file mode 100644 index 0000000000000..f646dd7e8f7c2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../common/lib/kibana'; +import { connector } from '../mock'; +import { useGetSeverity, UseGetSeverity } from './use_get_severity'; +import * as api from './api'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetSeverity', () => { + const { http, notifications } = useKibanaMock().services; + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetSeverity({ http, toastNotifications: notifications.toasts }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: true, severity: [] }); + }); + }); + + test('fetch severity', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetSeverity({ http, toastNotifications: notifications.toasts, connector }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + severity: [ + { + id: 4, + name: 'Low', + }, + { + id: 5, + name: 'Medium', + }, + { + id: 6, + name: 'High', + }, + ], + }); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getSeverity'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetSeverity({ http, toastNotifications: notifications.toasts, connector }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, severity: [] }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.tsx new file mode 100644 index 0000000000000..5b44c6b4a32b2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/resilient/use_get_severity.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getSeverity } from './api'; +import * as i18n from './translations'; + +type Severity = Array<{ id: number; name: string }>; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ActionConnector; +} + +export interface UseGetSeverity { + severity: Severity; + isLoading: boolean; +} + +export const useGetSeverity = ({ http, toastNotifications, connector }: Props): UseGetSeverity => { + const [isLoading, setIsLoading] = useState(true); + const [severity, setSeverity] = useState([]); + const abortCtrl = useRef(new AbortController()); + const didCancel = useRef(false); + + useEffect(() => { + const fetchData = async () => { + if (!connector) { + setIsLoading(false); + return; + } + + try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getSeverity({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + }); + + if (!didCancel.current) { + setIsLoading(false); + setSeverity(res.data ?? []); + + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.SEVERITY_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.SEVERITY_API_ERROR, + text: error.message, + }); + } + } + } + }; + + didCancel.current = false; + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; + }, [http, connector, toastNotifications]); + + return { + severity, + isLoading, + }; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/__mocks__/api.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/__mocks__/api.ts new file mode 100644 index 0000000000000..215e3d6f92e6d --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/__mocks__/api.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 { choices } from '../../mock'; +import { GetChoicesProps } from '../api'; +import { Choice } from '../types'; + +export const choicesResponse = { + status: 'ok', + data: choices, +}; + +export const getChoices = async ( + props: GetChoicesProps +): Promise<{ status: string; data: Choice[] }> => Promise.resolve(choicesResponse); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/api.test.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/api.test.ts new file mode 100644 index 0000000000000..461823036ed21 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/api.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { getChoices } from './api'; +import { choices } from '../mock'; + +const choicesResponse = { + status: 'ok', + data: choices, +}; + +describe('ServiceNow API', () => { + const http = httpServiceMock.createStartContract(); + + beforeEach(() => jest.resetAllMocks()); + + describe('getChoices', () => { + test('should call get choices API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(choicesResponse); + const res = await getChoices({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + fields: ['priority'], + }); + + expect(res).toEqual(choicesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"getChoices","subActionParams":{"fields":["priority"]}}}', + signal: abortCtrl.signal, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/api.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/api.ts new file mode 100644 index 0000000000000..e68eb18860ae3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/api.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from 'kibana/public'; +import { ActionTypeExecutorResult } from '../../../../../actions/common'; +import { Choice } from './types'; + +export const BASE_ACTION_API_PATH = '/api/actions'; + +export interface GetChoicesProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + fields: string[]; +} + +export async function getChoices({ http, signal, connectorId, fields }: GetChoicesProps) { + return http.post>( + `${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'getChoices', subActionParams: { fields } }, + }), + signal, + } + ); +} diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/helpers.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/helpers.ts new file mode 100644 index 0000000000000..314d224491128 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/helpers.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSelectOption } from '@elastic/eui'; +import { Choice } from './types'; + +export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => + choices.map((choice) => ({ value: choice.value, text: choice.label })); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/index.ts new file mode 100644 index 0000000000000..a6f0795fe4d8f --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/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 { lazy } from 'react'; + +import { CaseConnector } from '../types'; +import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType } from '../../../../common'; +import * as i18n from './translations'; + +export const getServiceNowITSMCaseConnector = (): CaseConnector => { + return { + id: '.servicenow', + fieldsComponent: lazy(() => import('./servicenow_itsm_case_fields')), + }; +}; + +export const getServiceNowSIRCaseConnector = (): CaseConnector => { + return { + id: '.servicenow-sir', + fieldsComponent: lazy(() => import('./servicenow_sir_case_fields')), + }; +}; + +export const serviceNowITSMFieldLabels = { + impact: i18n.IMPACT, + severity: i18n.SEVERITY, + urgency: i18n.URGENCY, +}; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx new file mode 100644 index 0000000000000..9688ca191d672 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { waitFor, act } from '@testing-library/react'; +import { EuiSelect } from '@elastic/eui'; +import { mount } from 'enzyme'; + +import { connector, choices as mockChoices } from '../mock'; +import { Choice } from './types'; +import Fields from './servicenow_itsm_case_fields'; + +let onChoicesSuccess = (c: Choice[]) => {}; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./use_get_choices', () => ({ + useGetChoices: (args: { onSuccess: () => void }) => { + onChoicesSuccess = args.onSuccess; + return { isLoading: false, choices: mockChoices }; + }, +})); + +describe('ServiceNowITSM Fields', () => { + const fields = { + severity: '1', + urgency: '2', + impact: '3', + category: 'software', + subcategory: 'os', + }; + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('all params fields are rendered - isEdit: true', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); + }); + + it('all params fields are rendered - isEdit: false', () => { + const wrapper = mount( + + ); + act(() => { + onChoicesSuccess(mockChoices); + }); + + expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( + 'Urgency: 2 - High' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( + 'Severity: 1 - Critical' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( + 'Impact: 3 - Moderate' + ); + }); + + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { value: 'Priviledge Escalation', text: 'Priviledge Escalation' }, + { + value: 'Criminal activity/investigation', + text: 'Criminal activity/investigation', + }, + { value: 'Denial of Service', text: 'Denial of Service' }, + { + value: 'software', + text: 'Software', + }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Operation System', + value: 'os', + }, + ]); + }); + + it('it transforms the options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + const testers = ['severity', 'urgency', 'impact']; + testers.forEach((subj) => + expect(wrapper.find(`[data-test-subj="${subj}Select"]`).first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - High' }, + { value: '3', text: '3 - Moderate' }, + { value: '4', text: '4 - Low' }, + ]) + ); + }); + + describe('onChange calls', () => { + const wrapper = mount(); + + expect(onChange).toHaveBeenCalledWith(fields); + + const testers = ['severity', 'urgency', 'impact', 'subcategory']; + testers.forEach((subj) => + test(`${subj.toUpperCase()}`, async () => { + await waitFor(() => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="${subj}Select"]`)!; + select.prop('onChange')!({ + target: { + value: '9', + }, + } as React.ChangeEvent); + }); + wrapper.update(); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + [subj]: '9', + }); + }) + ); + + test('it should set subcategory to null when changing category', async () => { + await waitFor(() => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="categorySelect"]`)!; + select.prop('onChange')!({ + target: { + value: 'network', + }, + } as React.ChangeEvent); + }); + wrapper.update(); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + subcategory: null, + category: 'network', + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx new file mode 100644 index 0000000000000..710e230958354 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, useRef } from 'react'; +import { EuiFormRow, EuiSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import * as i18n from './translations'; + +import { ConnectorFieldsProps } from '../types'; +import { ConnectorTypes, ServiceNowITSMFieldsType } from '../../../../common'; +import { useKibana } from '../../../common/lib/kibana'; +import { ConnectorCard } from '../card'; +import { useGetChoices } from './use_get_choices'; +import { Fields, Choice } from './types'; +import { choicesToEuiOptions } from './helpers'; + +const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; +const defaultFields: Fields = { + urgency: [], + severity: [], + impact: [], + category: [], + subcategory: [], +}; + +const ServiceNowITSMFieldsComponent: React.FunctionComponent< + ConnectorFieldsProps +> = ({ isEdit = true, fields, connector, onChange }) => { + const init = useRef(true); + const { severity = null, urgency = null, impact = null, category = null, subcategory = null } = + fields ?? {}; + const { http, notifications } = useKibana().services; + const [choices, setChoices] = useState(defaultFields); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); + const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); + const impactOptions = useMemo(() => choicesToEuiOptions(choices.impact), [choices.impact]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter((choice) => choice.dependent_value === category) + ), + [choices.subcategory, category] + ); + + const listItems = useMemo( + () => [ + ...(urgency != null && urgency.length > 0 + ? [ + { + title: i18n.URGENCY, + description: urgencyOptions.find((option) => `${option.value}` === urgency)?.text, + }, + ] + : []), + ...(severity != null && severity.length > 0 + ? [ + { + title: i18n.SEVERITY, + description: severityOptions.find((option) => `${option.value}` === severity)?.text, + }, + ] + : []), + ...(impact != null && impact.length > 0 + ? [ + { + title: i18n.IMPACT, + description: impactOptions.find((option) => `${option.value}` === impact)?.text, + }, + ] + : []), + ...(category != null && category.length > 0 + ? [ + { + title: i18n.CATEGORY, + description: categoryOptions.find((option) => `${option.value}` === category)?.text, + }, + ] + : []), + ...(subcategory != null && subcategory.length > 0 + ? [ + { + title: i18n.SUBCATEGORY, + description: subcategoryOptions.find((option) => `${option.value}` === subcategory) + ?.text, + }, + ] + : []), + ], + [ + category, + categoryOptions, + impact, + impactOptions, + severity, + severityOptions, + subcategory, + subcategoryOptions, + urgency, + urgencyOptions, + ] + ); + + const onChoicesSuccess = (values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ + ...acc, + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], + }), + defaultFields + ) + ); + }; + + const { isLoading: isLoadingChoices } = useGetChoices({ + http, + toastNotifications: notifications.toasts, + connector, + fields: useGetChoicesFields, + onSuccess: onChoicesSuccess, + }); + + const onChangeCb = useCallback( + ( + key: keyof ServiceNowITSMFieldsType, + value: ServiceNowITSMFieldsType[keyof ServiceNowITSMFieldsType] + ) => { + onChange({ ...fields, [key]: value }); + }, + [fields, onChange] + ); + + // Set field at initialization + useEffect(() => { + if (init.current) { + init.current = false; + onChange({ urgency, severity, impact, category, subcategory }); + } + }, [category, impact, onChange, severity, subcategory, urgency]); + + return isEdit ? ( +
+ + onChangeCb('urgency', e.target.value)} + /> + + + + + + onChangeCb('severity', e.target.value)} + /> + + + + + onChangeCb('impact', e.target.value)} + /> + + + + + + + onChange({ ...fields, category: e.target.value, subcategory: null })} + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowITSMFieldsComponent as default }; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx new file mode 100644 index 0000000000000..4a5b34cd3c3cb --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { waitFor, act } from '@testing-library/react'; +import { EuiSelect } from '@elastic/eui'; + +import { connector, choices as mockChoices } from '../mock'; +import { Choice } from './types'; +import Fields from './servicenow_sir_case_fields'; + +let onChoicesSuccess = (c: Choice[]) => {}; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./use_get_choices', () => ({ + useGetChoices: (args: { onSuccess: () => void }) => { + onChoicesSuccess = args.onSuccess; + return { isLoading: false, mockChoices }; + }, +})); + +describe('ServiceNowSIR Fields', () => { + const fields = { + destIp: true, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }; + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('all params fields are rendered - isEdit: true', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="destIpCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="sourceIpCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="malwareUrlCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="malwareHashCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); + }); + + test('all params fields are rendered - isEdit: false', () => { + const wrapper = mount( + + ); + act(() => { + onChoicesSuccess(mockChoices); + }); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( + 'Destination IP: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( + 'Source IP: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( + 'Malware URL: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(3).text()).toEqual( + 'Malware Hash: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(4).text()).toEqual( + 'Priority: 1 - Critical' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(5).text()).toEqual( + 'Category: Denial of Service' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(6).text()).toEqual( + 'Subcategory: Single or distributed (DoS or DDoS)' + ); + }); + + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { value: 'Priviledge Escalation', text: 'Priviledge Escalation' }, + { + value: 'Criminal activity/investigation', + text: 'Criminal activity/investigation', + }, + { value: 'Denial of Service', text: 'Denial of Service' }, + { + text: 'Software', + value: 'software', + }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Inbound or outbound', + value: '12', + }, + { + text: 'Single or distributed (DoS or DDoS)', + value: '26', + }, + { + text: 'Inbound DDos', + value: 'inbound_ddos', + }, + ]); + }); + + test('it transforms the priorities to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('options')).toEqual([ + { + text: '1 - Critical', + value: '1', + }, + { + text: '2 - High', + value: '2', + }, + { + text: '3 - Moderate', + value: '3', + }, + { + text: '4 - Low', + value: '4', + }, + ]); + }); + + describe('onChange calls', () => { + const wrapper = mount(); + + act(() => { + onChoicesSuccess(mockChoices); + }); + wrapper.update(); + + expect(onChange).toHaveBeenCalledWith(fields); + + const checkbox = ['destIp', 'sourceIp', 'malwareHash', 'malwareUrl']; + checkbox.forEach((subj) => + test(`${subj.toUpperCase()}`, async () => { + await waitFor(() => { + wrapper + .find(`[data-test-subj="${subj}Checkbox"] input`) + .first() + .simulate('change', { target: { checked: false } }); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + [subj]: false, + }); + }); + }) + ); + + const testers = ['priority', 'subcategory']; + testers.forEach((subj) => + test(`${subj.toUpperCase()}`, async () => { + await waitFor(() => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="${subj}Select"]`)!; + select.prop('onChange')!({ + target: { + value: '9', + }, + } as React.ChangeEvent); + }); + wrapper.update(); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + [subj]: '9', + }); + }) + ); + + test('it should set subcategory to null when changing category', async () => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="categorySelect"]`)!; + select.prop('onChange')!({ + target: { + value: 'network', + }, + } as React.ChangeEvent); + + wrapper.update(); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith({ + ...fields, + subcategory: null, + category: 'network', + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx new file mode 100644 index 0000000000000..1f9a7cf7acd64 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -0,0 +1,282 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, useRef } from 'react'; +import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiCheckbox } from '@elastic/eui'; + +import { ConnectorTypes, ServiceNowSIRFieldsType } from '../../../../common'; +import { useKibana } from '../../../common/lib/kibana'; +import { ConnectorFieldsProps } from '../types'; +import { ConnectorCard } from '../card'; +import { useGetChoices } from './use_get_choices'; +import { Choice, Fields } from './types'; +import { choicesToEuiOptions } from './helpers'; + +import * as i18n from './translations'; + +const useGetChoicesFields = ['category', 'subcategory', 'priority']; +const defaultFields: Fields = { + category: [], + subcategory: [], + priority: [], +}; + +const ServiceNowSIRFieldsComponent: React.FunctionComponent< + ConnectorFieldsProps +> = ({ isEdit = true, fields, connector, onChange }) => { + const init = useRef(true); + const { + category = null, + destIp = true, + malwareHash = true, + malwareUrl = true, + priority = null, + sourceIp = true, + subcategory = null, + } = fields ?? {}; + + const { http, notifications } = useKibana().services; + + const [choices, setChoices] = useState(defaultFields); + + const onChangeCb = useCallback( + ( + key: keyof ServiceNowSIRFieldsType, + value: ServiceNowSIRFieldsType[keyof ServiceNowSIRFieldsType] + ) => { + onChange({ ...fields, [key]: value }); + }, + [fields, onChange] + ); + + const onChoicesSuccess = (values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ + ...acc, + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], + }), + defaultFields + ) + ); + }; + + const { isLoading: isLoadingChoices } = useGetChoices({ + http, + toastNotifications: notifications.toasts, + connector, + fields: useGetChoicesFields, + onSuccess: onChoicesSuccess, + }); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const priorityOptions = useMemo(() => choicesToEuiOptions(choices.priority), [choices.priority]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter((choice) => choice.dependent_value === category) + ), + [choices.subcategory, category] + ); + + const listItems = useMemo( + () => [ + ...(destIp != null && destIp + ? [ + { + title: i18n.DEST_IP, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(sourceIp != null && sourceIp + ? [ + { + title: i18n.SOURCE_IP, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(malwareUrl != null && malwareUrl + ? [ + { + title: i18n.MALWARE_URL, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(malwareHash != null && malwareHash + ? [ + { + title: i18n.MALWARE_HASH, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(priority != null && priority.length > 0 + ? [ + { + title: i18n.PRIORITY, + description: priorityOptions.find((option) => `${option.value}` === priority)?.text, + }, + ] + : []), + ...(category != null && category.length > 0 + ? [ + { + title: i18n.CATEGORY, + description: categoryOptions.find((option) => `${option.value}` === category)?.text, + }, + ] + : []), + ...(subcategory != null && subcategory.length > 0 + ? [ + { + title: i18n.SUBCATEGORY, + description: subcategoryOptions.find((option) => `${option.value}` === subcategory) + ?.text, + }, + ] + : []), + ], + [ + category, + categoryOptions, + destIp, + malwareHash, + malwareUrl, + priority, + priorityOptions, + sourceIp, + subcategory, + subcategoryOptions, + ] + ); + + // Set field at initialization + useEffect(() => { + if (init.current) { + init.current = false; + onChange({ category, destIp, malwareHash, malwareUrl, priority, sourceIp, subcategory }); + } + }, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]); + + return isEdit ? ( +
+ + + + <> + + + onChangeCb('destIp', e.target.checked)} + /> + + + onChangeCb('sourceIp', e.target.checked)} + /> + + + + + onChangeCb('malwareUrl', e.target.checked)} + /> + + + onChangeCb('malwareHash', e.target.checked)} + /> + + + + + + + + + + onChangeCb('priority', e.target.value)} + /> + + + + + + + onChange({ ...fields, category: e.target.value, subcategory: null })} + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowSIRFieldsComponent as default }; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts new file mode 100644 index 0000000000000..fc48ecf17f2c6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const URGENCY = i18n.translate('xpack.cases.connectors.serviceNow.urgencySelectFieldLabel', { + defaultMessage: 'Urgency', +}); + +export const SEVERITY = i18n.translate( + 'xpack.cases.connectors.serviceNow.severitySelectFieldLabel', + { + defaultMessage: 'Severity', + } +); + +export const IMPACT = i18n.translate('xpack.cases.connectors.serviceNow.impactSelectFieldLabel', { + defaultMessage: 'Impact', +}); + +export const CHOICES_API_ERROR = i18n.translate( + 'xpack.cases.connectors.serviceNow.unableToGetChoicesMessage', + { + defaultMessage: 'Unable to get choices', + } +); + +export const MALWARE_URL = i18n.translate('xpack.cases.connectors.serviceNow.malwareURLTitle', { + defaultMessage: 'Malware URL', +}); + +export const MALWARE_HASH = i18n.translate('xpack.cases.connectors.serviceNow.malwareHashTitle', { + defaultMessage: 'Malware Hash', +}); + +export const CATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.categoryTitle', { + defaultMessage: 'Category', +}); + +export const SUBCATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.subcategoryTitle', { + defaultMessage: 'Subcategory', +}); + +export const SOURCE_IP = i18n.translate('xpack.cases.connectors.serviceNow.sourceIPTitle', { + defaultMessage: 'Source IP', +}); + +export const DEST_IP = i18n.translate('xpack.cases.connectors.serviceNow.destinationIPTitle', { + defaultMessage: 'Destination IP', +}); + +export const PRIORITY = i18n.translate( + 'xpack.cases.connectors.serviceNow.prioritySelectFieldTitle', + { + defaultMessage: 'Priority', + } +); + +export const ALERT_FIELDS_LABEL = i18n.translate( + 'xpack.cases.connectors.serviceNow.alertFieldsTitle', + { + defaultMessage: 'Select Observables to push', + } +); + +export const ALERT_FIELD_ENABLED_TEXT = i18n.translate( + 'xpack.cases.connectors.serviceNow.alertFieldEnabledText', + { + defaultMessage: 'Yes', + } +); diff --git a/x-pack/plugins/security_solution/scripts/optimize_tsconfig.js b/x-pack/plugins/cases/public/components/connectors/servicenow/types.ts similarity index 56% rename from x-pack/plugins/security_solution/scripts/optimize_tsconfig.js rename to x-pack/plugins/cases/public/components/connectors/servicenow/types.ts index e8fda71d8b7db..fd1af62f7bb2a 100644 --- a/x-pack/plugins/security_solution/scripts/optimize_tsconfig.js +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/types.ts @@ -5,10 +5,11 @@ * 2.0. */ -const { optimizeTsConfig } = require('./optimize_tsconfig/optimize'); +export interface Choice { + value: string; + label: string; + dependent_value: string; + element: string; +} -optimizeTsConfig().catch((err) => { - console.error(err); - // eslint-disable-next-line no-process-exit - process.exit(1); -}); +export type Fields = Record; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx new file mode 100644 index 0000000000000..ed4577dd0114b --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../common/lib/kibana'; +import { ActionConnector } from '../../../containers/types'; +import { choices } from '../mock'; +import { useGetChoices, UseGetChoices, UseGetChoicesProps } from './use_get_choices'; +import * as api from './api'; + +jest.mock('./api'); +jest.mock('../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.Mocked; +const onSuccess = jest.fn(); +const fields = ['priority']; + +const connector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + name: 'ServiceNow', + isPreconfigured: false, + config: { + apiUrl: 'https://dev94428.service-now.com/', + }, +} as ActionConnector; + +describe('useGetChoices', () => { + const { services } = useKibanaMock(); + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('init', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + isLoading: false, + choices, + }); + }); + + it('returns an empty array when connector is not presented', async () => { + const { result } = renderHook(() => + useGetChoices({ + http: services.http, + connector: undefined, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + choices: [], + }); + }); + + it('it calls onSuccess', async () => { + const { waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(onSuccess).toHaveBeenCalledWith(choices); + }); + + it('it displays an error when service fails', async () => { + const spyOnGetChoices = jest.spyOn(api, 'getChoices'); + spyOnGetChoices.mockResolvedValue( + Promise.resolve({ + actionId: 'test', + status: 'error', + serviceMessage: 'An error occurred', + }) + ); + + const { waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); + + it('it displays an error when http throws an error', async () => { + const spyOnGetChoices = jest.spyOn(api, 'getChoices'); + spyOnGetChoices.mockImplementation(() => { + throw new Error('An error occurred'); + }); + + renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.tsx new file mode 100644 index 0000000000000..a979f96d84ab2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getChoices } from './api'; +import { Choice } from './types'; +import * as i18n from './translations'; + +export interface UseGetChoicesProps { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ActionConnector; + fields: string[]; + onSuccess?: (choices: Choice[]) => void; +} + +export interface UseGetChoices { + choices: Choice[]; + isLoading: boolean; +} + +export const useGetChoices = ({ + http, + connector, + toastNotifications, + fields, + onSuccess, +}: UseGetChoicesProps): UseGetChoices => { + const [isLoading, setIsLoading] = useState(false); + const [choices, setChoices] = useState([]); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + const fetchData = async () => { + if (!connector) { + setIsLoading(false); + return; + } + + try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getChoices({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + fields, + }); + + if (!didCancel.current) { + setIsLoading(false); + setChoices(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } else if (onSuccess) { + onSuccess(res.data ?? []); + } + } + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: error.message, + }); + } + } + } + }; + + didCancel.current = false; + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [http, connector, toastNotifications, fields]); + + return { + choices, + isLoading, + }; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/types.ts b/x-pack/plugins/cases/public/components/connectors/types.ts new file mode 100644 index 0000000000000..fc2f66d331700 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/types.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + ActionType as ThirdPartySupportedActions, + CaseField, + ActionConnector, + ConnectorTypeFields, +} from '../../../common'; + +export { ThirdPartyField as AllThirdPartyFields } from '../../../common'; +export type CaseActionConnector = ActionConnector; + +export interface ThirdPartyField { + label: string; + validSourceFields: CaseField[]; + defaultSourceField: CaseField; + defaultActionType: ThirdPartySupportedActions; +} + +export interface ConnectorConfiguration { + name: string; + logo: string; +} + +export interface CaseConnector { + id: string; + fieldsComponent: React.LazyExoticComponent< + React.ComponentType> + > | null; +} + +export interface CaseConnectorsRegistry { + has: (id: string) => boolean; + register: ( + connector: CaseConnector + ) => void; + get: (id: string) => CaseConnector; + list: () => CaseConnector[]; +} + +export interface ConnectorFieldsProps { + isEdit?: boolean; + connector: CaseActionConnector; + fields: TFields; + onChange: (fields: TFields) => void; +} diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx new file mode 100644 index 0000000000000..db9e5ffac1533 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { act, waitFor } from '@testing-library/react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { useForm, Form, FormHook } from '../../common/shared_imports'; +import { connectorsMock } from '../../containers/mock'; +import { Connector } from './connector'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { incidentTypes, severity, choices } from '../connectors/mock'; +import { schema, FormProps } from './schema'; + +jest.mock('../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + notifications: {}, + http: {}, + }, + }), + }; +}); +jest.mock('../../containers/configure/use_connectors'); +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/servicenow/use_get_choices'); + +const useConnectorsMock = useConnectors as jest.Mock; +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetChoicesMock = useGetChoices as jest.Mock; + +const useGetIncidentTypesResponse = { + isLoading: false, + incidentTypes, +}; + +const useGetSeverityResponse = { + isLoading: false, + severity, +}; + +const useGetChoicesResponse = { + isLoading: false, + choices, +}; + +describe('Connector', () => { + let globalForm: FormHook; + + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm({ + defaultValue: { connectorId: connectorsMock[0].id, fields: null }, + schema: { + connectorId: schema.connectorId, + fields: schema.fields, + }, + }); + + globalForm = form; + + return
{children}
; + }; + + beforeEach(() => { + jest.resetAllMocks(); + useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock }); + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + it('it renders', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeTruthy(); + + await waitFor(() => { + expect(wrapper.find(`button[data-test-subj="dropdown-connectors"]`).first().text()).toBe( + 'My Connector' + ); + }); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy(); + }); + }); + + it('it is loading when fetching connectors', async () => { + useConnectorsMock.mockReturnValue({ loading: true, connectors: connectorsMock }); + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') + ).toEqual(true); + }); + + it('it is disabled when fetching connectors', async () => { + useConnectorsMock.mockReturnValue({ loading: true, connectors: connectorsMock }); + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual( + true + ); + }); + + it('it is disabled and loading when passing loading as true', async () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') + ).toEqual(true); + expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual( + true + ); + }); + + it(`it should change connector`, async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); + wrapper.update(); + }); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); + }); + + act(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ value: '19', label: 'Denial of Service' }]); + }); + + act(() => { + wrapper + .find('select[data-test-subj="severitySelect"]') + .first() + .simulate('change', { + target: { value: '4' }, + }); + }); + + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ + connectorId: 'resilient-2', + fields: { incidentTypes: ['19'], severityCode: '4' }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx new file mode 100644 index 0000000000000..9b6063a7bf9b9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { ConnectorTypes } from '../../../common'; +import { UseField, useFormData, FieldHook, useFormContext } from '../../common/shared_imports'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { ConnectorSelector } from '../connector_selector/form'; +import { ConnectorFieldsForm } from '../connectors/fields_form'; +import { ActionConnector } from '../../containers/types'; +import { getConnectorById } from '../configure_cases/utils'; +import { FormProps } from './schema'; + +interface Props { + isLoading: boolean; + hideConnectorServiceNowSir?: boolean; +} + +interface ConnectorsFieldProps { + connectors: ActionConnector[]; + field: FieldHook; + isEdit: boolean; + hideConnectorServiceNowSir?: boolean; +} + +const ConnectorFields = ({ + connectors, + isEdit, + field, + hideConnectorServiceNowSir = false, +}: ConnectorsFieldProps) => { + const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); + const { setValue } = field; + let connector = getConnectorById(connectorId, connectors) ?? null; + if ( + connector && + hideConnectorServiceNowSir && + connector.actionTypeId === ConnectorTypes.serviceNowSIR + ) { + connector = null; + } + return ( + + ); +}; + +const ConnectorComponent: React.FC = ({ hideConnectorServiceNowSir = false, isLoading }) => { + const { getFields } = useFormContext(); + const { loading: isLoadingConnectors, connectors } = useConnectors(); + const handleConnectorChange = useCallback( + (newConnector) => { + const { fields } = getFields(); + fields.setValue(null); + }, + [getFields] + ); + + return ( + + + + + + + + + ); +}; + +ConnectorComponent.displayName = 'ConnectorComponent'; + +export const Connector = memo(ConnectorComponent); diff --git a/x-pack/plugins/cases/public/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/create/description.test.tsx new file mode 100644 index 0000000000000..fcd1f82d64a53 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/description.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 { mount } from 'enzyme'; +import { act } from '@testing-library/react'; + +import { useForm, Form, FormHook } from '../../common/shared_imports'; +import { Description } from './description'; +import { schema, FormProps } from './schema'; + +describe('Description', () => { + let globalForm: FormHook; + + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm({ + defaultValue: { description: 'My description' }, + schema: { + description: schema.description, + }, + }); + + globalForm = form; + + return
{children}
; + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('it renders', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); + }); + + it('it changes the description', async () => { + const wrapper = mount( + + + + ); + + await act(async () => { + wrapper + .find(`[data-test-subj="caseDescription"] textarea`) + .first() + .simulate('change', { target: { value: 'My new description' } }); + }); + + expect(globalForm.getFormData()).toEqual({ description: 'My new description' }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/create/description.tsx new file mode 100644 index 0000000000000..0a7102cff1ad5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/description.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { MarkdownEditorForm } from '../markdown_editor'; +import { UseField } from '../../common/shared_imports'; +interface Props { + isLoading: boolean; +} + +export const fieldName = 'description'; + +const DescriptionComponent: React.FC = ({ isLoading }) => ( + +); + +DescriptionComponent.displayName = 'DescriptionComponent'; + +export const Description = memo(DescriptionComponent); diff --git a/x-pack/plugins/cases/public/components/create/flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout.test.tsx new file mode 100644 index 0000000000000..5187029ab60c7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/flyout.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { mount } from 'enzyme'; + +import { CreateCaseFlyout } from './flyout'; +import { TestProviders } from '../../common/mock'; + +jest.mock('../create/form_context', () => { + return { + FormContext: ({ + children, + onSuccess, + }: { + children: ReactNode; + onSuccess: ({ id }: { id: string }) => Promise; + }) => { + return ( + <> + + {children} + + ); + }, + }; +}); + +jest.mock('../create/form', () => { + return { + CreateCaseForm: () => { + return <>{'form'}; + }, + }; +}); + +jest.mock('../create/submit_button', () => { + return { + SubmitCaseButton: () => { + return <>{'Submit'}; + }, + }; +}); + +const onCloseFlyout = jest.fn(); +const onSuccess = jest.fn(); +const defaultProps = { + onCloseFlyout, + onSuccess, +}; + +describe('CreateCaseFlyout', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj='create-case-flyout']`).exists()).toBeTruthy(); + }); + + it('Closing modal calls onCloseCaseModal', () => { + const wrapper = mount( + + + + ); + + wrapper.find('.euiFlyout__closeButton').first().simulate('click'); + expect(onCloseFlyout).toBeCalled(); + }); + + it('pass the correct props to FormContext component', () => { + const wrapper = mount( + + + + ); + + const props = wrapper.find('FormContext').props(); + expect(props).toEqual( + expect.objectContaining({ + onSuccess, + }) + ); + }); + + it('onSuccess called when creating a case', () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj='form-context-on-success']`).first().simulate('click'); + expect(onSuccess).toHaveBeenCalledWith({ id: 'case-id' }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout.tsx new file mode 100644 index 0000000000000..8ed09865e9eab --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/flyout.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import styled from 'styled-components'; +import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; + +import { FormContext } from '../create/form_context'; +import { CreateCaseForm } from '../create/form'; +import { SubmitCaseButton } from '../create/submit_button'; +import { Case } from '../../containers/types'; +import * as i18n from '../../common/translations'; + +export interface CreateCaseModalProps { + onCloseFlyout: () => void; + onSuccess: (theCase: Case) => Promise; + afterCaseCreated?: (theCase: Case) => Promise; +} + +const Container = styled.div` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSize}; + text-align: right; + `} +`; + +const StyledFlyout = styled(EuiFlyout)` + ${({ theme }) => ` + z-index: ${theme.eui.euiZModal}; + `} +`; + +// Adding bottom padding because timeline's +// bottom bar gonna hide the submit button. +const FormWrapper = styled.div` + padding-bottom: 50px; +`; + +const CreateCaseFlyoutComponent: React.FC = ({ + onSuccess, + afterCaseCreated, + onCloseFlyout, +}) => { + return ( + + + +

{i18n.CREATE_TITLE}

+
+
+ + + + + + + + + + +
+ ); +}; + +export const CreateCaseFlyout = memo(CreateCaseFlyoutComponent); + +CreateCaseFlyout.displayName = 'CreateCaseFlyout'; diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx new file mode 100644 index 0000000000000..9e59924bdf483 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -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 React from 'react'; +import { mount } from 'enzyme'; +import { act, waitFor } from '@testing-library/react'; + +import { useForm, Form, FormHook } from '../../common/shared_imports'; +import { useGetTags } from '../../containers/use_get_tags'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { connectorsMock } from '../../containers/mock'; +import { schema, FormProps } from './schema'; +import { CreateCaseForm } from './form'; + +jest.mock('../../containers/use_get_tags'); +jest.mock('../../containers/configure/use_connectors'); +const useGetTagsMock = useGetTags as jest.Mock; +const useConnectorsMock = useConnectors as jest.Mock; + +const initialCaseValue: FormProps = { + description: '', + tags: [], + title: '', + connectorId: 'none', + fields: null, + syncAlerts: true, +}; + +describe('CreateCaseForm', () => { + let globalForm: FormHook; + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm({ + defaultValue: initialCaseValue, + options: { stripEmptyFields: false }, + schema, + }); + + globalForm = form; + + return
{children}
; + }; + + beforeEach(() => { + jest.resetAllMocks(); + useGetTagsMock.mockReturnValue({ tags: ['test'] }); + useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock }); + }); + + it('it renders with steps', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeTruthy(); + }); + + it('it renders without steps', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeFalsy(); + }); + + it('it renders all form fields', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); + }); + + it('should render spinner when loading', async () => { + const wrapper = mount( + + + + ); + + await act(async () => { + globalForm.setFieldValue('title', 'title'); + globalForm.setFieldValue('description', 'description'); + globalForm.submit(); + // For some weird reason this is needed to pass the test. + // It does not do anything useful + await wrapper.find(`[data-test-subj="caseTitle"]`); + await wrapper.update(); + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists() + ).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx new file mode 100644 index 0000000000000..a81ecf32576a9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiLoadingSpinner, EuiSteps } from '@elastic/eui'; +import styled, { css } from 'styled-components'; + +import { useFormContext } from '../../common/shared_imports'; + +import { Title } from './title'; +import { Description } from './description'; +import { Tags } from './tags'; +import { Connector } from './connector'; +import * as i18n from './translations'; +import { SyncAlertsToggle } from './sync_alerts_toggle'; + +interface ContainerProps { + big?: boolean; +} + +const Container = styled.div.attrs((props) => props)` + ${({ big, theme }) => css` + margin-top: ${big ? theme.eui?.euiSizeXL ?? '32px' : theme.eui?.euiSize ?? '16px'}; + `} +`; + +const MySpinner = styled(EuiLoadingSpinner)` + position: absolute; + top: 50%; + left: 50%; + z-index: 99; +`; + +interface Props { + hideConnectorServiceNowSir?: boolean; + withSteps?: boolean; +} + +export const CreateCaseForm: React.FC = React.memo( + ({ hideConnectorServiceNowSir = false, withSteps = true }) => { + const { isSubmitting } = useFormContext(); + + const firstStep = useMemo( + () => ({ + title: i18n.STEP_ONE_TITLE, + children: ( + <> + + <Container> + <Tags isLoading={isSubmitting} /> + </Container> + <Container big> + <Description isLoading={isSubmitting} /> + </Container> + </> + ), + }), + [isSubmitting] + ); + + const secondStep = useMemo( + () => ({ + title: i18n.STEP_TWO_TITLE, + children: ( + <Container> + <SyncAlertsToggle isLoading={isSubmitting} /> + </Container> + ), + }), + [isSubmitting] + ); + + const thirdStep = useMemo( + () => ({ + title: i18n.STEP_THREE_TITLE, + children: ( + <Container> + <Connector + hideConnectorServiceNowSir={hideConnectorServiceNowSir} + isLoading={isSubmitting} + /> + </Container> + ), + }), + [hideConnectorServiceNowSir, isSubmitting] + ); + + const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [ + firstStep, + secondStep, + thirdStep, + ]); + + return ( + <> + {isSubmitting && <MySpinner data-test-subj="create-case-loading-spinner" size="xl" />} + {withSteps ? ( + <EuiSteps + headingElement="h2" + steps={allSteps} + data-test-subj={'case-creation-form-steps'} + /> + ) : ( + <> + {firstStep.children} + {secondStep.children} + {thirdStep.children} + </> + )} + </> + ); + } +); + +CreateCaseForm.displayName = 'CreateCaseForm'; diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx new file mode 100644 index 0000000000000..207ff6207e09d --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -0,0 +1,682 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, ReactWrapper } from 'enzyme'; +import { act, waitFor } from '@testing-library/react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { ConnectorTypes } from '../../../common'; +import { TestProviders } from '../../common/mock'; +import { usePostCase } from '../../containers/use_post_case'; +import { useGetTags } from '../../containers/use_get_tags'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { connectorsMock } from '../../containers/configure/mock'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetFieldsByIssueType } from '../connectors/jira/use_get_fields_by_issue_type'; +import { useCaseConfigureResponse } from '../configure_cases/__mock__'; +import { + sampleConnectorData, + sampleData, + sampleTags, + useGetIncidentTypesResponse, + useGetSeverityResponse, + useGetIssueTypesResponse, + useGetFieldsByIssueTypeResponse, + useGetChoicesResponse, +} from './mock'; +import { FormContext } from './form_context'; +import { CreateCaseForm } from './form'; +import { SubmitCaseButton } from './submit_button'; +import { usePostPushToService } from '../../containers/use_post_push_to_service'; + +const sampleId = 'case-id'; + +jest.mock('../../containers/use_post_case'); +jest.mock('../../containers/use_post_push_to_service'); +jest.mock('../../containers/use_get_tags'); +jest.mock('../../containers/configure/use_connectors'); +jest.mock('../../containers/configure/use_configure'); +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/jira/use_get_issue_types'); +jest.mock('../connectors/jira/use_get_fields_by_issue_type'); +jest.mock('../connectors/jira/use_get_single_issue'); +jest.mock('../connectors/jira/use_get_issues'); +jest.mock('../connectors/servicenow/use_get_choices'); + +const useConnectorsMock = useConnectors as jest.Mock; +const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const usePostCaseMock = usePostCase as jest.Mock; +const usePostPushToServiceMock = usePostPushToService as jest.Mock; +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; +const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; +const useGetChoicesMock = useGetChoices as jest.Mock; +const postCase = jest.fn(); +const pushCaseToExternalService = jest.fn(); + +const defaultPostCase = { + isLoading: false, + isError: false, + postCase, +}; + +const defaultPostPushToService = { + isLoading: false, + isError: false, + pushCaseToExternalService, +}; + +const fillForm = (wrapper: ReactWrapper) => { + wrapper + .find(`[data-test-subj="caseTitle"] input`) + .first() + .simulate('change', { target: { value: sampleData.title } }); + + wrapper + .find(`[data-test-subj="caseDescription"] textarea`) + .first() + .simulate('change', { target: { value: sampleData.description } }); + + act(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange(sampleTags.map((tag) => ({ label: tag }))); + }); +}; + +describe('Create case', () => { + const fetchTags = jest.fn(); + const onFormSubmitSuccess = jest.fn(); + const afterCaseCreated = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + postCase.mockResolvedValue({ + id: sampleId, + ...sampleData, + }); + usePostCaseMock.mockImplementation(() => defaultPostCase); + usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService); + useConnectorsMock.mockReturnValue(sampleConnectorData); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); + useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + + (useGetTags as jest.Mock).mockImplementation(() => ({ + tags: sampleTags, + fetchTags, + })); + }); + + describe('Step 1 - Case Fields', () => { + it('it renders', async () => { + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + expect(wrapper.find(`[data-test-subj="caseTitle"]`).first().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseDescription"]`).first().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseTags"]`).first().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseConnectors"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="case-creation-form-steps"]`).first().exists() + ).toBeTruthy(); + }); + + it('should post case on submit click', async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); + }); + + it('should toggle sync settings', async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => + expect(postCase).toBeCalledWith({ ...sampleData, settings: { syncAlerts: false } }) + ); + }); + + it('it should select the default connector set in the configuration', async () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + persistLoading: false, + })); + + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + await act(async () => { + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + }); + + await waitFor(() => + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + fields: { + impact: null, + severity: null, + urgency: null, + category: null, + subcategory: null, + }, + id: 'servicenow-1', + name: 'My Connector', + type: '.servicenow', + }, + }) + ); + }); + + it('it should default to none if the default connector does not exist in connectors', async () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + connector: { + id: 'not-exist', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + persistLoading: false, + })); + + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => { + expect(postCase).toBeCalledWith(sampleData); + expect(pushCaseToExternalService).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Step 2 - Connector Fields', () => { + it(`it should submit and push to Jira connector`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); + }); + + wrapper + .find('select[data-test-subj="issueTypeSelect"]') + .first() + .simulate('change', { + target: { value: '10007' }, + }); + + wrapper + .find('select[data-test-subj="prioritySelect"]') + .first() + .simulate('change', { + target: { value: '2' }, + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => { + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + id: 'jira-1', + name: 'Jira', + type: '.jira', + fields: { issueType: '10007', parent: null, priority: '2' }, + }, + }); + expect(pushCaseToExternalService).toHaveBeenCalledWith({ + caseId: sampleId, + connector: { + id: 'jira-1', + name: 'Jira', + type: '.jira', + fields: { issueType: '10007', parent: null, priority: '2' }, + }, + }); + expect(onFormSubmitSuccess).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); + }); + + it(`it should submit and push to resilient connector`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); + }); + + act(() => { + ((wrapper.find(EuiComboBox).at(1).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ value: '19', label: 'Denial of Service' }]); + }); + + wrapper + .find('select[data-test-subj="severitySelect"]') + .first() + .simulate('change', { + target: { value: '4' }, + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => { + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + id: 'resilient-2', + name: 'My Connector 2', + type: '.resilient', + fields: { incidentTypes: ['19'], severityCode: '4' }, + }, + }); + + expect(pushCaseToExternalService).toHaveBeenCalledWith({ + caseId: sampleId, + connector: { + id: 'resilient-2', + name: 'My Connector 2', + type: '.resilient', + fields: { incidentTypes: ['19'], severityCode: '4' }, + }, + }); + + expect(onFormSubmitSuccess).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); + }); + + it(`it should submit and push to servicenow itsm connector`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy(); + }); + + ['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => { + wrapper + .find(`select[data-test-subj="${subj}"]`) + .first() + .simulate('change', { + target: { value: '2' }, + }); + }); + + wrapper + .find('select[data-test-subj="categorySelect"]') + .first() + .simulate('change', { + target: { value: 'software' }, + }); + + wrapper + .find('select[data-test-subj="subcategorySelect"]') + .first() + .simulate('change', { + target: { value: 'os' }, + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => { + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + id: 'servicenow-1', + name: 'My Connector', + type: '.servicenow', + fields: { + impact: '2', + severity: '2', + urgency: '2', + category: 'software', + subcategory: 'os', + }, + }, + }); + + expect(pushCaseToExternalService).toHaveBeenCalledWith({ + caseId: sampleId, + connector: { + id: 'servicenow-1', + name: 'My Connector', + type: '.servicenow', + fields: { + impact: '2', + severity: '2', + urgency: '2', + category: 'software', + subcategory: 'os', + }, + }, + }); + + expect(onFormSubmitSuccess).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); + }); + + it(`it should submit and push to servicenow sir connector`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-sir"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeTruthy(); + }); + + wrapper + .find('[data-test-subj="destIpCheckbox"] input') + .first() + .simulate('change', { target: { checked: false } }); + + wrapper + .find('select[data-test-subj="prioritySelect"]') + .first() + .simulate('change', { + target: { value: '1' }, + }); + + wrapper + .find('select[data-test-subj="categorySelect"]') + .first() + .simulate('change', { + target: { value: 'Denial of Service' }, + }); + + wrapper + .find('select[data-test-subj="subcategorySelect"]') + .first() + .simulate('change', { + target: { value: '26' }, + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => { + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + id: 'servicenow-sir', + name: 'My Connector SIR', + type: '.servicenow-sir', + fields: { + destIp: false, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }, + }, + }); + + expect(pushCaseToExternalService).toHaveBeenCalledWith({ + caseId: sampleId, + connector: { + id: 'servicenow-sir', + name: 'My Connector SIR', + type: '.servicenow-sir', + fields: { + destIp: false, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }, + }, + }); + + expect(onFormSubmitSuccess).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); + }); + }); + + it(`it should call afterCaseCreated`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => { + expect(afterCaseCreated).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); + }); + + it(`it should call callbacks in correct order`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}> + <CreateCaseForm /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => { + expect(postCase).toHaveBeenCalled(); + expect(afterCaseCreated).toHaveBeenCalled(); + expect(pushCaseToExternalService).toHaveBeenCalled(); + expect(onFormSubmitSuccess).toHaveBeenCalled(); + }); + + const postCaseOrder = postCase.mock.invocationCallOrder[0]; + const afterCaseOrder = afterCaseCreated.mock.invocationCallOrder[0]; + const pushCaseToExternalServiceOrder = pushCaseToExternalService.mock.invocationCallOrder[0]; + const onFormSubmitSuccessOrder = onFormSubmitSuccess.mock.invocationCallOrder[0]; + + expect( + postCaseOrder < afterCaseOrder && + postCaseOrder < pushCaseToExternalServiceOrder && + postCaseOrder < onFormSubmitSuccessOrder + ).toBe(true); + + expect( + afterCaseOrder < pushCaseToExternalServiceOrder && afterCaseOrder < onFormSubmitSuccessOrder + ).toBe(true); + + expect(pushCaseToExternalServiceOrder < onFormSubmitSuccessOrder).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx new file mode 100644 index 0000000000000..e84f451ab4215 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo } from 'react'; +import { schema, FormProps } from './schema'; +import { Form, useForm } from '../../common/shared_imports'; +import { + getConnectorById, + getNoneConnector, + normalizeActionConnector, +} from '../configure_cases/utils'; +import { usePostCase } from '../../containers/use_post_case'; +import { usePostPushToService } from '../../containers/use_post_push_to_service'; + +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { Case } from '../../containers/types'; +import { CaseType, ConnectorTypes } from '../../../common'; + +const initialCaseValue: FormProps = { + description: '', + tags: [], + title: '', + connectorId: 'none', + fields: null, + syncAlerts: true, +}; + +interface Props { + afterCaseCreated?: (theCase: Case) => Promise<void>; + caseType?: CaseType; + hideConnectorServiceNowSir?: boolean; + onSuccess?: (theCase: Case) => Promise<void>; +} + +export const FormContext: React.FC<Props> = ({ + afterCaseCreated, + caseType = CaseType.individual, + children, + hideConnectorServiceNowSir, + onSuccess, +}) => { + const { connectors } = useConnectors(); + const { connector: configurationConnector } = useCaseConfigure(); + const { postCase } = usePostCase(); + const { pushCaseToExternalService } = usePostPushToService(); + + const connectorId = useMemo(() => { + if ( + hideConnectorServiceNowSir && + configurationConnector.type === ConnectorTypes.serviceNowSIR + ) { + return 'none'; + } + return connectors.some((connector) => connector.id === configurationConnector.id) + ? configurationConnector.id + : 'none'; + }, [ + configurationConnector.id, + configurationConnector.type, + connectors, + hideConnectorServiceNowSir, + ]); + + const submitCase = useCallback( + async ( + { connectorId: dataConnectorId, fields, syncAlerts, ...dataWithoutConnectorId }, + isValid + ) => { + if (isValid) { + const caseConnector = getConnectorById(dataConnectorId, connectors); + + const connectorToUpdate = caseConnector + ? normalizeActionConnector(caseConnector, fields) + : getNoneConnector(); + + const updatedCase = await postCase({ + ...dataWithoutConnectorId, + type: caseType, + connector: connectorToUpdate, + settings: { syncAlerts }, + }); + + if (afterCaseCreated && updatedCase) { + await afterCaseCreated(updatedCase); + } + + if (updatedCase?.id && dataConnectorId !== 'none') { + await pushCaseToExternalService({ + caseId: updatedCase.id, + connector: connectorToUpdate, + }); + } + + if (onSuccess && updatedCase) { + await onSuccess(updatedCase); + } + } + }, + [caseType, connectors, postCase, onSuccess, pushCaseToExternalService, afterCaseCreated] + ); + + const { form } = useForm<FormProps>({ + defaultValue: initialCaseValue, + options: { stripEmptyFields: false }, + schema, + onSubmit: submitCase, + }); + const { setFieldValue } = form; + // Set the selected connector to the configuration connector + useEffect(() => setFieldValue('connectorId', connectorId), [connectorId, setFieldValue]); + + return <Form form={form}>{children}</Form>; +}; + +FormContext.displayName = 'FormContext'; diff --git a/x-pack/plugins/cases/public/components/create/index.test.tsx b/x-pack/plugins/cases/public/components/create/index.test.tsx new file mode 100644 index 0000000000000..e82af8edc6337 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/index.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { act, waitFor } from '@testing-library/react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { TestProviders } from '../../common/mock'; +import { useGetTags } from '../../containers/use_get_tags'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; +import { useGetFieldsByIssueType } from '../connectors/jira/use_get_fields_by_issue_type'; +import { useCaseConfigureResponse } from '../configure_cases/__mock__'; +import { + sampleConnectorData, + sampleData, + sampleTags, + useGetIncidentTypesResponse, + useGetSeverityResponse, + useGetIssueTypesResponse, + useGetFieldsByIssueTypeResponse, +} from './mock'; +import { CreateCase } from '.'; + +jest.mock('../../containers/api'); +jest.mock('../../containers/use_get_tags'); +jest.mock('../../containers/configure/use_connectors'); +jest.mock('../../containers/configure/use_configure'); +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/jira/use_get_issue_types'); +jest.mock('../connectors/jira/use_get_fields_by_issue_type'); +jest.mock('../connectors/jira/use_get_single_issue'); +jest.mock('../connectors/jira/use_get_issues'); + +const useConnectorsMock = useConnectors as jest.Mock; +const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useGetTagsMock = useGetTags as jest.Mock; +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; +const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; +const fetchTags = jest.fn(); + +const fillForm = (wrapper: ReactWrapper) => { + wrapper + .find(`[data-test-subj="caseTitle"] input`) + .first() + .simulate('change', { target: { value: sampleData.title } }); + + wrapper + .find(`[data-test-subj="caseDescription"] textarea`) + .first() + .simulate('change', { target: { value: sampleData.description } }); + + act(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange(sampleTags.map((tag) => ({ label: tag }))); + }); +}; + +const defaultProps = { + onCancel: jest.fn(), + onSuccess: jest.fn(), +}; + +describe('CreateCase case', () => { + beforeEach(() => { + jest.resetAllMocks(); + useConnectorsMock.mockReturnValue(sampleConnectorData); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); + useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); + useGetTagsMock.mockImplementation(() => ({ + tags: sampleTags, + fetchTags, + })); + }); + + it('it renders', async () => { + const wrapper = mount( + <TestProviders> + <CreateCase {...defaultProps} /> + </TestProviders> + ); + + expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="create-case-cancel"]`).exists()).toBeTruthy(); + }); + + it('should call cancel on cancel click', async () => { + const wrapper = mount( + <TestProviders> + <CreateCase {...defaultProps} /> + </TestProviders> + ); + + wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click'); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('should redirect to new case when posting the case', async () => { + const wrapper = mount( + <TestProviders> + <CreateCase {...defaultProps} /> + </TestProviders> + ); + + fillForm(wrapper); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => { + expect(defaultProps.onSuccess).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/index.tsx b/x-pack/plugins/cases/public/components/create/index.tsx new file mode 100644 index 0000000000000..192effb6adb24 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/index.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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { Field, getUseField } from '../../common/shared_imports'; +import * as i18n from './translations'; +import { CreateCaseForm } from './form'; +import { FormContext } from './form_context'; +import { SubmitCaseButton } from './submit_button'; +import { Case } from '../../containers/types'; + +export const CommonUseField = getUseField({ component: Field }); + +const Container = styled.div` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSize}; + `} +`; + +export interface CreateCaseProps { + afterCaseCreated?: (theCase: Case) => Promise<void>; + onCancel: () => void; + onSuccess: (theCase: Case) => Promise<void>; +} + +export const CreateCase = ({ afterCaseCreated, onCancel, onSuccess }: CreateCaseProps) => ( + <FormContext afterCaseCreated={afterCaseCreated} onSuccess={onSuccess}> + <CreateCaseForm /> + <Container> + <EuiFlexGroup alignItems="center" justifyContent="flexEnd" gutterSize="xs" responsive={false}> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + data-test-subj="create-case-cancel" + size="s" + onClick={onCancel} + iconType="cross" + > + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <SubmitCaseButton /> + </EuiFlexItem> + </EuiFlexGroup> + </Container> + </FormContext> +); + +// eslint-disable-next-line import/no-default-export +export { CreateCase as default }; diff --git a/x-pack/plugins/cases/public/components/create/mock.ts b/x-pack/plugins/cases/public/components/create/mock.ts new file mode 100644 index 0000000000000..eb40fa097d3cc --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/mock.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CasePostRequest, CaseType, ConnectorTypes } from '../../../common'; +import { choices } from '../connectors/mock'; + +export const sampleTags = ['coke', 'pepsi']; +export const sampleData: CasePostRequest = { + description: 'what a great description', + tags: sampleTags, + title: 'what a cool title', + type: CaseType.individual, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + settings: { + syncAlerts: true, + }, +}; + +export const sampleConnectorData = { loading: false, connectors: [] }; + +export const useGetIncidentTypesResponse = { + isLoading: false, + incidentTypes: [ + { + id: 19, + name: 'Malware', + }, + { + id: 21, + name: 'Denial of Service', + }, + ], +}; + +export const useGetSeverityResponse = { + isLoading: false, + severity: [ + { + id: 4, + name: 'Low', + }, + { + id: 5, + name: 'Medium', + }, + { + id: 6, + name: 'High', + }, + ], +}; + +export const useGetIssueTypesResponse = { + isLoading: false, + issueTypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], +}; + +export const useGetFieldsByIssueTypeResponse = { + isLoading: false, + fields: { + summary: { allowedValues: [], defaultValue: {} }, + labels: { allowedValues: [], defaultValue: {} }, + description: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + { + name: 'Low', + id: '2', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, + }, +}; + +export const useGetChoicesResponse = { + isLoading: false, + choices, +}; diff --git a/x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx b/x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx new file mode 100644 index 0000000000000..4b6d5f90513ef --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; + +import { OptionalFieldLabel } from '.'; + +describe('OptionalFieldLabel', () => { + it('it renders correctly', async () => { + const wrapper = mount(OptionalFieldLabel); + expect(wrapper.find('[data-test-subj="form-optional-field-label"]').first().text()).toBe( + 'Optional' + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx b/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx new file mode 100644 index 0000000000000..ea994b2219961 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiText } from '@elastic/eui'; +import React from 'react'; + +import * as i18n from '../../../common/translations'; + +export const OptionalFieldLabel = ( + <EuiText color="subdued" size="xs" data-test-subj="form-optional-field-label"> + {i18n.OPTIONAL} + </EuiText> +); diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx new file mode 100644 index 0000000000000..7ca1e2e061545 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CasePostRequest, ConnectorTypeFields } from '../../../common'; +import { FIELD_TYPES, fieldValidators, FormSchema } from '../../common/shared_imports'; +import * as i18n from './translations'; + +import { OptionalFieldLabel } from './optional_field_label'; +const { emptyField } = fieldValidators; + +export const schemaTags = { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, +}; + +export type FormProps = Omit<CasePostRequest, 'connector' | 'settings'> & { + connectorId: string; + fields: ConnectorTypeFields['fields']; + syncAlerts: boolean; +}; + +export const schema: FormSchema<FormProps> = { + title: { + type: FIELD_TYPES.TEXT, + label: i18n.NAME, + validations: [ + { + validator: emptyField(i18n.TITLE_REQUIRED), + }, + ], + }, + description: { + label: i18n.DESCRIPTION, + validations: [ + { + validator: emptyField(i18n.DESCRIPTION_REQUIRED), + }, + ], + }, + tags: schemaTags, + connectorId: { + type: FIELD_TYPES.SUPER_SELECT, + label: i18n.CONNECTORS, + defaultValue: 'none', + }, + fields: {}, + syncAlerts: { + helpText: i18n.SYNC_ALERTS_HELP, + type: FIELD_TYPES.TOGGLE, + defaultValue: true, + }, +}; diff --git a/x-pack/plugins/cases/public/components/create/submit_button.test.tsx b/x-pack/plugins/cases/public/components/create/submit_button.test.tsx new file mode 100644 index 0000000000000..dd67c8170dc3f --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/submit_button.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { act, waitFor } from '@testing-library/react'; + +import { useForm, Form } from '../../common/shared_imports'; +import { SubmitCaseButton } from './submit_button'; +import { schema, FormProps } from './schema'; + +describe('SubmitCaseButton', () => { + const onSubmit = jest.fn(); + + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm<FormProps>({ + defaultValue: { title: 'My title' }, + schema: { + title: schema.title, + }, + onSubmit, + }); + + return <Form form={form}>{children}</Form>; + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('it renders', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <SubmitCaseButton /> + </MockHookWrapperComponent> + ); + + expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); + }); + + it('it submits', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <SubmitCaseButton /> + </MockHookWrapperComponent> + ); + + await act(async () => { + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + }); + + await waitFor(() => expect(onSubmit).toBeCalled()); + }); + + it('it disables when submitting', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <SubmitCaseButton /> + </MockHookWrapperComponent> + ); + + await waitFor(() => { + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + expect( + wrapper.find(`[data-test-subj="create-case-submit"]`).first().prop('isDisabled') + ).toBeTruthy(); + }); + }); + + it('it is loading when submitting', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <SubmitCaseButton /> + </MockHookWrapperComponent> + ); + + await waitFor(() => { + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + expect( + wrapper.find(`[data-test-subj="create-case-submit"]`).first().prop('isLoading') + ).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/submit_button.tsx b/x-pack/plugins/cases/public/components/create/submit_button.tsx new file mode 100644 index 0000000000000..b5e58517e6ec1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/submit_button.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiButton } from '@elastic/eui'; + +import { useFormContext } from '../../common/shared_imports'; +import * as i18n from './translations'; + +const SubmitCaseButtonComponent: React.FC = () => { + const { submit, isSubmitting } = useFormContext(); + + return ( + <EuiButton + data-test-subj="create-case-submit" + fill + iconType="plusInCircle" + isDisabled={isSubmitting} + isLoading={isSubmitting} + onClick={submit} + > + {i18n.CREATE_CASE} + </EuiButton> + ); +}; + +export const SubmitCaseButton = memo(SubmitCaseButtonComponent); diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx new file mode 100644 index 0000000000000..b4a37f0abb518 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; + +import { useForm, Form, FormHook } from '../../common/shared_imports'; +import { SyncAlertsToggle } from './sync_alerts_toggle'; +import { schema, FormProps } from './schema'; + +describe('SyncAlertsToggle', () => { + let globalForm: FormHook; + + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm<FormProps>({ + defaultValue: { syncAlerts: true }, + schema: { + syncAlerts: schema.syncAlerts, + }, + }); + + globalForm = form; + + return <Form form={form}>{children}</Form>; + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('it renders', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <SyncAlertsToggle isLoading={false} /> + </MockHookWrapperComponent> + ); + + expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); + }); + + it('it toggles the switch', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <SyncAlertsToggle isLoading={false} /> + </MockHookWrapperComponent> + ); + + wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); + + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ syncAlerts: false }); + }); + }); + + it('it shows the correct labels', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <SyncAlertsToggle isLoading={false} /> + </MockHookWrapperComponent> + ); + + expect(wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text()).toBe( + 'On' + ); + + wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); + + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text() + ).toBe('Off'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx new file mode 100644 index 0000000000000..bed8e6d18f5e3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { Field, getUseField, useFormData } from '../../common/shared_imports'; +import * as i18n from './translations'; + +const CommonUseField = getUseField({ component: Field }); + +interface Props { + isLoading: boolean; +} + +const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading }) => { + const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); + return ( + <CommonUseField + path="syncAlerts" + componentProps={{ + idAria: 'caseSyncAlerts', + 'data-test-subj': 'caseSyncAlerts', + label: i18n.SYNC_ALERTS_LABEL, + euiFieldProps: { + disabled: isLoading, + label: syncAlerts ? i18n.SYNC_ALERTS_SWITCH_LABEL_ON : i18n.SYNC_ALERTS_SWITCH_LABEL_OFF, + }, + }} + /> + ); +}; + +SyncAlertsToggleComponent.displayName = 'SyncAlertsToggleComponent'; + +export const SyncAlertsToggle = memo(SyncAlertsToggleComponent); diff --git a/x-pack/plugins/cases/public/components/create/tags.test.tsx b/x-pack/plugins/cases/public/components/create/tags.test.tsx new file mode 100644 index 0000000000000..2eddb83dcac29 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/tags.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { waitFor } from '@testing-library/react'; + +import { useForm, Form, FormHook } from '../../common/shared_imports'; +import { useGetTags } from '../../containers/use_get_tags'; +import { Tags } from './tags'; +import { schema, FormProps } from './schema'; + +jest.mock('../../containers/use_get_tags'); +const useGetTagsMock = useGetTags as jest.Mock; + +describe('Tags', () => { + let globalForm: FormHook; + + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm<FormProps>({ + defaultValue: { tags: [] }, + schema: { + tags: schema.tags, + }, + }); + + globalForm = form; + + return <Form form={form}>{children}</Form>; + }; + + beforeEach(() => { + jest.resetAllMocks(); + useGetTagsMock.mockReturnValue({ tags: ['test'] }); + }); + + it('it renders', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <Tags isLoading={false} /> + </MockHookWrapperComponent> + ); + + await waitFor(() => { + expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy(); + }); + }); + + it('it disables the input when loading', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <Tags isLoading={true} /> + </MockHookWrapperComponent> + ); + + expect(wrapper.find(EuiComboBox).prop('disabled')).toBeTruthy(); + }); + + it('it changes the tags', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <Tags isLoading={false} /> + </MockHookWrapperComponent> + ); + + await waitFor(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange(['test', 'case'].map((tag) => ({ label: tag }))); + }); + + expect(globalForm.getFormData()).toEqual({ tags: ['test', 'case'] }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx new file mode 100644 index 0000000000000..ac0b67529e15a --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; + +import { Field, getUseField } from '../../common/shared_imports'; +import { useGetTags } from '../../containers/use_get_tags'; + +const CommonUseField = getUseField({ component: Field }); + +interface Props { + isLoading: boolean; +} + +const TagsComponent: React.FC<Props> = ({ isLoading }) => { + const { tags: tagOptions, isLoading: isLoadingTags } = useGetTags(); + const options = useMemo( + () => + tagOptions.map((label) => ({ + label, + })), + [tagOptions] + ); + + return ( + <CommonUseField + path="tags" + componentProps={{ + idAria: 'caseTags', + 'data-test-subj': 'caseTags', + euiFieldProps: { + fullWidth: true, + placeholder: '', + disabled: isLoading || isLoadingTags, + options, + noSuggestions: false, + }, + }} + /> + ); +}; + +TagsComponent.displayName = 'TagsComponent'; + +export const Tags = memo(TagsComponent); diff --git a/x-pack/plugins/cases/public/components/create/title.test.tsx b/x-pack/plugins/cases/public/components/create/title.test.tsx new file mode 100644 index 0000000000000..a41d5afbb4038 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/title.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from '@testing-library/react'; + +import { useForm, Form, FormHook } from '../../common/shared_imports'; +import { Title } from './title'; +import { schema, FormProps } from './schema'; + +describe('Title', () => { + let globalForm: FormHook; + + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm<FormProps>({ + defaultValue: { title: 'My title' }, + schema: { + title: schema.title, + }, + }); + + globalForm = form; + + return <Form form={form}>{children}</Form>; + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('it renders', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <Title isLoading={false} /> + </MockHookWrapperComponent> + ); + + expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy(); + }); + + it('it disables the input when loading', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <Title isLoading={true} /> + </MockHookWrapperComponent> + ); + + expect(wrapper.find(`[data-test-subj="caseTitle"] input`).prop('disabled')).toBeTruthy(); + }); + + it('it changes the title', async () => { + const wrapper = mount( + <MockHookWrapperComponent> + <Title isLoading={false} /> + </MockHookWrapperComponent> + ); + + await act(async () => { + wrapper + .find(`[data-test-subj="caseTitle"] input`) + .first() + .simulate('change', { target: { value: 'My new title' } }); + }); + + expect(globalForm.getFormData()).toEqual({ title: 'My new title' }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/title.tsx b/x-pack/plugins/cases/public/components/create/title.tsx new file mode 100644 index 0000000000000..cc51a805b5c38 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/title.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { Field, getUseField } from '../../common/shared_imports'; + +const CommonUseField = getUseField({ component: Field }); + +interface Props { + isLoading: boolean; +} + +const TitleComponent: React.FC<Props> = ({ isLoading }) => ( + <CommonUseField + path="title" + componentProps={{ + idAria: 'caseTitle', + 'data-test-subj': 'caseTitle', + euiFieldProps: { + fullWidth: true, + disabled: isLoading, + }, + }} + /> +); + +TitleComponent.displayName = 'TitleComponent'; + +export const Title = memo(TitleComponent); diff --git a/x-pack/plugins/cases/public/components/create/translations.ts b/x-pack/plugins/cases/public/components/create/translations.ts new file mode 100644 index 0000000000000..7e0f7e5a6b9d5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/translations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const STEP_ONE_TITLE = i18n.translate('xpack.cases.create.stepOneTitle', { + defaultMessage: 'Case fields', +}); + +export const STEP_TWO_TITLE = i18n.translate('xpack.cases.create.stepTwoTitle', { + defaultMessage: 'Case settings', +}); + +export const STEP_THREE_TITLE = i18n.translate('xpack.cases.create.stepThreeTitle', { + defaultMessage: 'External Connector Fields', +}); + +export const SYNC_ALERTS_LABEL = i18n.translate('xpack.cases.create.syncAlertsLabel', { + defaultMessage: 'Sync alert status with case status', +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editor.tsx new file mode 100644 index 0000000000000..a7d37fdda3085 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/editor.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, { memo, useEffect, useState, useCallback } from 'react'; +import { + EuiMarkdownEditor, + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, + getDefaultEuiMarkdownUiPlugins, +} from '@elastic/eui'; + +interface MarkdownEditorProps { + onChange: (content: string) => void; + value: string; + ariaLabel: string; + editorId?: string; + dataTestSubj?: string; + height?: number; +} + +// create plugin stuff here +export const { uiPlugins, parsingPlugins, processingPlugins } = { + uiPlugins: getDefaultEuiMarkdownUiPlugins(), + parsingPlugins: getDefaultEuiMarkdownParsingPlugins(), + processingPlugins: getDefaultEuiMarkdownProcessingPlugins(), +}; +const MarkdownEditorComponent: React.FC<MarkdownEditorProps> = ({ + onChange, + value, + ariaLabel, + editorId, + dataTestSubj, + height, +}) => { + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + + useEffect( + () => document.querySelector<HTMLElement>('textarea.euiMarkdownEditorTextArea')?.focus(), + [] + ); + + return ( + <EuiMarkdownEditor + aria-label={ariaLabel} + editorId={editorId} + onChange={onChange} + value={value} + uiPlugins={uiPlugins} + parsingPluginList={parsingPlugins} + processingPluginList={processingPlugins} + onParse={onParse} + errors={markdownErrorMessages} + data-test-subj={dataTestSubj} + height={height} + /> + ); +}; + +export const MarkdownEditor = memo(MarkdownEditorComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx new file mode 100644 index 0000000000000..858e79ff65baf --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.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 from 'react'; +import styled from 'styled-components'; +import { EuiMarkdownEditorProps, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports'; + +import { MarkdownEditor } from './editor'; + +type MarkdownEditorFormProps = EuiMarkdownEditorProps & { + id: string; + field: FieldHook; + dataTestSubj: string; + idAria: string; + isDisabled?: boolean; + bottomRightContent?: React.ReactNode; +}; + +const BottomContentWrapper = styled(EuiFlexGroup)` + ${({ theme }) => ` + padding: ${theme.eui.ruleMargins.marginSmall} 0; + `} +`; + +export const MarkdownEditorForm: React.FC<MarkdownEditorFormProps> = ({ + id, + field, + dataTestSubj, + idAria, + bottomRightContent, +}) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + return ( + <EuiFormRow + data-test-subj={dataTestSubj} + describedByIds={idAria ? [idAria] : undefined} + error={errorMessage} + fullWidth + helpText={field.helpText} + isInvalid={isInvalid} + label={field.label} + labelAppend={field.labelAppend} + > + <> + <MarkdownEditor + ariaLabel={idAria} + editorId={id} + onChange={field.setValue} + value={field.value as string} + data-test-subj={`${dataTestSubj}-markdown-editor`} + /> + {bottomRightContent && ( + <BottomContentWrapper justifyContent={'flexEnd'}> + <EuiFlexItem grow={false}>{bottomRightContent}</EuiFlexItem> + </BottomContentWrapper> + )} + </> + </EuiFormRow> + ); +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/index.tsx b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx new file mode 100644 index 0000000000000..e77a36d48f7d9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './types'; +export * from './renderer'; +export * from './editor'; +export * from './eui_form'; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/markdown_link.tsx b/x-pack/plugins/cases/public/components/markdown_editor/markdown_link.tsx new file mode 100644 index 0000000000000..7cc8a07c8c04e --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/markdown_link.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, { memo } from 'react'; +import { EuiLink, EuiLinkAnchorProps, EuiToolTip } from '@elastic/eui'; + +type MarkdownLinkProps = { disableLinks?: boolean } & EuiLinkAnchorProps; + +/** prevents search engine manipulation by noting the linked document is not trusted or endorsed by us */ +const REL_NOFOLLOW = 'nofollow'; + +const MarkdownLinkComponent: React.FC<MarkdownLinkProps> = ({ + disableLinks, + href, + target, + children, + ...props +}) => ( + <EuiToolTip content={href}> + <EuiLink + href={disableLinks ? undefined : href} + data-test-subj="markdown-link" + rel={`${REL_NOFOLLOW}`} + target="_blank" + > + {children} + </EuiLink> + </EuiToolTip> +); + +export const MarkdownLink = memo(MarkdownLinkComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx new file mode 100644 index 0000000000000..5d299529561ba --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { removeExternalLinkText } from '../../common/test_utils'; +import { MarkdownRenderer } from './renderer'; + +describe('Markdown', () => { + describe('markdown links', () => { + const markdownWithLink = 'A link to an external site [External Site](https://google.com)'; + + test('it renders the expected link text', () => { + const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>); + + expect( + removeExternalLinkText(wrapper.find('[data-test-subj="markdown-link"]').first().text()) + ).toEqual('External Site'); + }); + + test('it renders the expected href', () => { + const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'href', + 'https://google.com/' + ); + }); + + test('it does NOT render the href if links are disabled', () => { + const wrapper = mount( + <MarkdownRenderer disableLinks={true}>{markdownWithLink}</MarkdownRenderer> + ); + + expect( + wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode() + ).not.toHaveProperty('href'); + }); + + test('it opens links in a new tab via target="_blank"', () => { + const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'target', + '_blank' + ); + }); + + test('it sets the link `rel` attribute to `noopener` to prevent the new page from accessing `window.opener`, `nofollow` to note the link is not endorsed by us, and noreferrer to prevent the browser from sending the current address', () => { + const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'rel', + 'nofollow noopener noreferrer' + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/renderer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/renderer.tsx new file mode 100644 index 0000000000000..c321c794c1e77 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/renderer.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, { memo, useMemo } from 'react'; +import { cloneDeep } from 'lodash/fp'; +import { EuiMarkdownFormat, EuiLinkAnchorProps } from '@elastic/eui'; + +import { parsingPlugins, processingPlugins } from './'; +import { MarkdownLink } from './markdown_link'; + +interface Props { + children: string; + disableLinks?: boolean; +} + +const MarkdownRendererComponent: React.FC<Props> = ({ children, disableLinks }) => { + const MarkdownLinkProcessingComponent: React.FC<EuiLinkAnchorProps> = useMemo( + () => (props) => <MarkdownLink {...props} disableLinks={disableLinks} />, + [disableLinks] + ); + + // Deep clone of the processing plugins to prevent affecting the markdown editor. + const processingPluginList = cloneDeep(processingPlugins); + // This line of code is TS-compatible and it will break if [1][1] change in the future. + processingPluginList[1][1].components.a = MarkdownLinkProcessingComponent; + + return ( + <EuiMarkdownFormat + parsingPluginList={parsingPlugins} + processingPluginList={processingPluginList} + > + {children} + </EuiMarkdownFormat> + ); +}; + +export const MarkdownRenderer = memo(MarkdownRendererComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/translations.ts b/x-pack/plugins/cases/public/components/markdown_editor/translations.ts new file mode 100644 index 0000000000000..365738f53ef8a --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/translations.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 { i18n } from '@kbn/i18n'; + +export const MARKDOWN_SYNTAX_HELP = i18n.translate('xpack.cases.markdownEditor.markdownInputHelp', { + defaultMessage: 'Markdown syntax help', +}); + +export const MARKDOWN = i18n.translate('xpack.cases.markdownEditor.markdown', { + defaultMessage: 'Markdown', +}); +export const PREVIEW = i18n.translate('xpack.cases.markdownEditor.preview', { + defaultMessage: 'Preview', +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/types.ts b/x-pack/plugins/cases/public/components/markdown_editor/types.ts new file mode 100644 index 0000000000000..8a30a4a143f54 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface CursorPosition { + start: number; + end: number; +} diff --git a/x-pack/plugins/cases/public/components/status/button.test.tsx b/x-pack/plugins/cases/public/components/status/button.test.tsx new file mode 100644 index 0000000000000..a4d4a53ff4a62 --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/button.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { CaseStatuses } from '../../../common'; +import { StatusActionButton } from './button'; + +describe('StatusActionButton', () => { + const onStatusChanged = jest.fn(); + const defaultProps = { + status: CaseStatuses.open, + disabled: false, + isLoading: false, + onStatusChanged, + }; + + it('it renders', async () => { + const wrapper = mount(<StatusActionButton {...defaultProps} />); + + expect(wrapper.find(`[data-test-subj="case-view-status-action-button"]`).exists()).toBeTruthy(); + }); + + describe('Button icons', () => { + it('it renders the correct button icon: status open', () => { + const wrapper = mount(<StatusActionButton {...defaultProps} />); + + expect( + wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') + ).toBe('folderExclamation'); + }); + + it('it renders the correct button icon: status in-progress', () => { + const wrapper = mount( + <StatusActionButton {...defaultProps} status={CaseStatuses['in-progress']} /> + ); + + expect( + wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') + ).toBe('folderCheck'); + }); + + it('it renders the correct button icon: status closed', () => { + const wrapper = mount(<StatusActionButton {...defaultProps} status={CaseStatuses.closed} />); + + expect( + wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') + ).toBe('folderOpen'); + }); + }); + + describe('Status rotation', () => { + it('rotates correctly to in-progress when status is open', () => { + const wrapper = mount(<StatusActionButton {...defaultProps} />); + + wrapper + .find(`button[data-test-subj="case-view-status-action-button"]`) + .first() + .simulate('click'); + expect(onStatusChanged).toHaveBeenCalledWith('in-progress'); + }); + + it('rotates correctly to closed when status is in-progress', () => { + const wrapper = mount( + <StatusActionButton {...defaultProps} status={CaseStatuses['in-progress']} /> + ); + + wrapper + .find(`button[data-test-subj="case-view-status-action-button"]`) + .first() + .simulate('click'); + expect(onStatusChanged).toHaveBeenCalledWith('closed'); + }); + + it('rotates correctly to open when status is closed', () => { + const wrapper = mount(<StatusActionButton {...defaultProps} status={CaseStatuses.closed} />); + + wrapper + .find(`button[data-test-subj="case-view-status-action-button"]`) + .first() + .simulate('click'); + expect(onStatusChanged).toHaveBeenCalledWith('open'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/status/button.tsx b/x-pack/plugins/cases/public/components/status/button.tsx new file mode 100644 index 0000000000000..623afeb43c596 --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/button.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 React, { memo, useCallback, useMemo } from 'react'; +import { EuiButton } from '@elastic/eui'; + +import { CaseStatuses, caseStatuses } from '../../../common'; +import { statuses } from './config'; + +interface Props { + status: CaseStatuses; + disabled: boolean; + isLoading: boolean; + onStatusChanged: (status: CaseStatuses) => void; +} + +// Rotate over the statuses. open -> in-progress -> closes -> open... +const getNextItem = (item: number) => (item + 1) % caseStatuses.length; + +const StatusActionButtonComponent: React.FC<Props> = ({ + status, + onStatusChanged, + disabled, + isLoading, +}) => { + const indexOfCurrentStatus = useMemo( + () => caseStatuses.findIndex((caseStatus) => caseStatus === status), + [status] + ); + const nextStatusIndex = useMemo(() => getNextItem(indexOfCurrentStatus), [indexOfCurrentStatus]); + + const onClick = useCallback(() => { + onStatusChanged(caseStatuses[nextStatusIndex]); + }, [nextStatusIndex, onStatusChanged]); + + return ( + <EuiButton + data-test-subj="case-view-status-action-button" + iconType={statuses[caseStatuses[nextStatusIndex]].icon} + isDisabled={disabled} + isLoading={isLoading} + onClick={onClick} + > + {statuses[caseStatuses[nextStatusIndex]].button.label} + </EuiButton> + ); +}; +export const StatusActionButton = memo(StatusActionButtonComponent); diff --git a/x-pack/plugins/cases/public/components/status/config.ts b/x-pack/plugins/cases/public/components/status/config.ts new file mode 100644 index 0000000000000..e85d429067724 --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/config.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { CaseStatuses } from '../../../common'; +import * as i18n from './translations'; +import { AllCaseStatus, Statuses, StatusAll } from './types'; + +export const allCaseStatus: AllCaseStatus = { + [StatusAll]: { color: 'hollow', label: i18n.ALL }, +}; + +export const statuses: Statuses = { + [CaseStatuses.open]: { + color: 'primary', + label: i18n.OPEN, + icon: 'folderOpen' as const, + actions: { + bulk: { + title: i18n.BULK_ACTION_OPEN_SELECTED, + }, + single: { + title: i18n.OPEN_CASE, + }, + }, + actionBar: { + title: i18n.CASE_OPENED, + }, + button: { + label: i18n.REOPEN_CASE, + }, + stats: { + title: i18n.OPEN_CASES, + }, + }, + [CaseStatuses['in-progress']]: { + color: 'warning', + label: i18n.IN_PROGRESS, + icon: 'folderExclamation' as const, + actions: { + bulk: { + title: i18n.BULK_ACTION_MARK_IN_PROGRESS, + }, + single: { + title: i18n.MARK_CASE_IN_PROGRESS, + }, + }, + actionBar: { + title: i18n.CASE_IN_PROGRESS, + }, + button: { + label: i18n.MARK_CASE_IN_PROGRESS, + }, + stats: { + title: i18n.IN_PROGRESS_CASES, + }, + }, + [CaseStatuses.closed]: { + color: 'default', + label: i18n.CLOSED, + icon: 'folderCheck' as const, + actions: { + bulk: { + title: i18n.BULK_ACTION_CLOSE_SELECTED, + }, + single: { + title: i18n.CLOSE_CASE, + }, + }, + actionBar: { + title: i18n.CASE_CLOSED, + }, + button: { + label: i18n.CLOSE_CASE, + }, + stats: { + title: i18n.CLOSED_CASES, + }, + }, +}; diff --git a/x-pack/plugins/cases/public/components/status/index.ts b/x-pack/plugins/cases/public/components/status/index.ts new file mode 100644 index 0000000000000..94d7cb6a31830 --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './status'; +export * from './config'; +export * from './stats'; +export * from './types'; diff --git a/x-pack/plugins/cases/public/components/status/stats.test.tsx b/x-pack/plugins/cases/public/components/status/stats.test.tsx new file mode 100644 index 0000000000000..b2da828da77b0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/stats.test.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 from 'react'; +import { mount } from 'enzyme'; + +import { CaseStatuses } from '../../../common'; +import { Stats } from './stats'; + +describe('Stats', () => { + const defaultProps = { + caseStatus: CaseStatuses.open, + caseCount: 2, + isLoading: false, + dataTestSubj: 'test-stats', + }; + it('it renders', async () => { + const wrapper = mount(<Stats {...defaultProps} />); + + expect(wrapper.find(`[data-test-subj="test-stats"]`).exists()).toBeTruthy(); + }); + + it('shows the count', async () => { + const wrapper = mount(<Stats {...defaultProps} />); + + expect( + wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__description`).first().text() + ).toBe('2'); + }); + + it('shows the loading spinner', async () => { + const wrapper = mount(<Stats {...defaultProps} isLoading={true} />); + + expect(wrapper.find(`[data-test-subj="test-stats-loading-spinner"]`).exists()).toBeTruthy(); + }); + + describe('Status title', () => { + it('shows the correct title for status open', async () => { + const wrapper = mount(<Stats {...defaultProps} />); + + expect( + wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() + ).toBe('Open cases'); + }); + + it('shows the correct title for status in-progress', async () => { + const wrapper = mount(<Stats {...defaultProps} caseStatus={CaseStatuses['in-progress']} />); + + expect( + wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() + ).toBe('In progress cases'); + }); + + it('shows the correct title for status closed', async () => { + const wrapper = mount(<Stats {...defaultProps} caseStatus={CaseStatuses.closed} />); + + expect( + wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() + ).toBe('Closed cases'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/status/stats.tsx b/x-pack/plugins/cases/public/components/status/stats.tsx new file mode 100644 index 0000000000000..071ea43746fdc --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/stats.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui'; +import { CaseStatuses } from '../../../common'; +import { statuses } from './config'; + +export interface Props { + caseCount: number | null; + caseStatus: CaseStatuses; + isLoading: boolean; + dataTestSubj?: string; +} + +const StatsComponent: React.FC<Props> = ({ caseCount, caseStatus, isLoading, dataTestSubj }) => { + const statusStats = useMemo( + () => [ + { + title: statuses[caseStatus].stats.title, + description: isLoading ? ( + <EuiLoadingSpinner data-test-subj={`${dataTestSubj}-loading-spinner`} /> + ) : ( + caseCount ?? 'N/A' + ), + }, + ], + [caseCount, caseStatus, dataTestSubj, isLoading] + ); + return ( + <EuiDescriptionList data-test-subj={dataTestSubj} textStyle="reverse" listItems={statusStats} /> + ); +}; + +StatsComponent.displayName = 'StatsComponent'; +export const Stats = memo(StatsComponent); diff --git a/x-pack/plugins/cases/public/components/status/status.test.tsx b/x-pack/plugins/cases/public/components/status/status.test.tsx new file mode 100644 index 0000000000000..7cddbf5ca4a1d --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/status.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { CaseStatuses } from '../../../common'; +import { Status } from './status'; + +describe('Stats', () => { + const onClick = jest.fn(); + + it('it renders', async () => { + const wrapper = mount(<Status type={CaseStatuses.open} withArrow={false} onClick={onClick} />); + + expect(wrapper.find(`[data-test-subj="status-badge-open"]`).exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).exists() + ).toBeFalsy(); + }); + + it('it renders with arrow', async () => { + const wrapper = mount(<Status type={CaseStatuses.open} withArrow={true} onClick={onClick} />); + + expect( + wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).exists() + ).toBeTruthy(); + }); + + it('it calls onClick when pressing the badge', async () => { + const wrapper = mount(<Status type={CaseStatuses.open} withArrow={true} onClick={onClick} />); + + wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); + + describe('Colors', () => { + it('shows the correct color when status is open', async () => { + const wrapper = mount( + <Status type={CaseStatuses.open} withArrow={false} onClick={onClick} /> + ); + + expect(wrapper.find(`[data-test-subj="status-badge-open"]`).first().prop('color')).toBe( + 'primary' + ); + }); + + it('shows the correct color when status is in-progress', async () => { + const wrapper = mount( + <Status type={CaseStatuses['in-progress']} withArrow={false} onClick={onClick} /> + ); + + expect( + wrapper.find(`[data-test-subj="status-badge-in-progress"]`).first().prop('color') + ).toBe('warning'); + }); + + it('shows the correct color when status is closed', async () => { + const wrapper = mount( + <Status type={CaseStatuses.closed} withArrow={false} onClick={onClick} /> + ); + + expect(wrapper.find(`[data-test-subj="status-badge-closed"]`).first().prop('color')).toBe( + 'default' + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/status/status.tsx b/x-pack/plugins/cases/public/components/status/status.tsx new file mode 100644 index 0000000000000..de4c979daf4c1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/status.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { noop } from 'lodash/fp'; +import { EuiBadge } from '@elastic/eui'; + +import { allCaseStatus, statuses } from './config'; +import { CaseStatusWithAllStatus, StatusAll } from './types'; +import * as i18n from './translations'; + +interface Props { + type: CaseStatusWithAllStatus; + withArrow?: boolean; + onClick?: () => void; +} + +const StatusComponent: React.FC<Props> = ({ type, withArrow = false, onClick = noop }) => { + const props = useMemo( + () => ({ + color: type === StatusAll ? allCaseStatus[StatusAll].color : statuses[type].color, + ...(withArrow ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + }), + [withArrow, type] + ); + + return ( + <EuiBadge + {...props} + iconOnClick={onClick} + iconOnClickAriaLabel={i18n.STATUS_ICON_ARIA} + data-test-subj={`status-badge-${type}`} + > + {type === StatusAll ? allCaseStatus[StatusAll].label : statuses[type].label} + </EuiBadge> + ); +}; + +export const Status = memo(StatusComponent); diff --git a/x-pack/plugins/cases/public/components/status/translations.ts b/x-pack/plugins/cases/public/components/status/translations.ts new file mode 100644 index 0000000000000..b3eadfd681ba5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/translations.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +export * from '../../common/translations'; + +export const ALL = i18n.translate('xpack.cases.status.all', { + defaultMessage: 'All', +}); + +export const OPEN = i18n.translate('xpack.cases.status.open', { + defaultMessage: 'Open', +}); + +export const IN_PROGRESS = i18n.translate('xpack.cases.status.inProgress', { + defaultMessage: 'In progress', +}); + +export const CLOSED = i18n.translate('xpack.cases.status.closed', { + defaultMessage: 'Closed', +}); + +export const STATUS_ICON_ARIA = i18n.translate('xpack.cases.status.iconAria', { + defaultMessage: 'Change status', +}); + +export const CASE_OPENED = i18n.translate('xpack.cases.caseView.caseOpened', { + defaultMessage: 'Case opened', +}); + +export const CASE_IN_PROGRESS = i18n.translate('xpack.cases.caseView.caseInProgress', { + defaultMessage: 'Case in progress', +}); + +export const CASE_CLOSED = i18n.translate('xpack.cases.caseView.caseClosed', { + defaultMessage: 'Case closed', +}); + +export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( + 'xpack.cases.caseTable.bulkActions.closeSelectedTitle', + { + defaultMessage: 'Close selected', + } +); + +export const BULK_ACTION_OPEN_SELECTED = i18n.translate( + 'xpack.cases.caseTable.bulkActions.openSelectedTitle', + { + defaultMessage: 'Open selected', + } +); + +export const BULK_ACTION_DELETE_SELECTED = i18n.translate( + 'xpack.cases.caseTable.bulkActions.deleteSelectedTitle', + { + defaultMessage: 'Delete selected', + } +); + +export const BULK_ACTION_MARK_IN_PROGRESS = i18n.translate( + 'xpack.cases.caseTable.bulkActions.markInProgressTitle', + { + defaultMessage: 'Mark in progress', + } +); diff --git a/x-pack/plugins/cases/public/components/status/types.ts b/x-pack/plugins/cases/public/components/status/types.ts new file mode 100644 index 0000000000000..674838067b0ac --- /dev/null +++ b/x-pack/plugins/cases/public/components/status/types.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { CaseStatuses } from '../../../common'; + +export const StatusAll = 'all' as const; +type StatusAllType = typeof StatusAll; + +export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType; + +export type AllCaseStatus = Record<StatusAllType, { color: string; label: string }>; + +export type Statuses = Record< + CaseStatuses, + { + color: string; + label: string; + icon: EuiIconType; + actions: { + bulk: { + title: string; + }; + single: { + title: string; + description?: string; + }; + }; + actionBar: { + title: string; + }; + button: { + label: string; + }; + stats: { + title: string; + }; + } +>; diff --git a/x-pack/plugins/cases/public/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap b/x-pack/plugins/cases/public/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap new file mode 100644 index 0000000000000..5e008e28073de --- /dev/null +++ b/x-pack/plugins/cases/public/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal all errors rendering it renders the default all errors modal when isShowing is positive 1`] = ` +<EuiModal + onClose={[Function]} +> + <EuiModalHeader> + <EuiModalHeaderTitle> + Your visualization has error(s) + </EuiModalHeaderTitle> + </EuiModalHeader> + <EuiModalBody> + <EuiCallOut + color="danger" + iconType="alert" + size="s" + title="Test & Test" + /> + <EuiSpacer + size="s" + /> + <EuiAccordion + arrowDisplay="left" + buttonContent="Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt u ..." + data-test-subj="modal-all-errors-accordion" + id="accordion1" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + key="id-super-id-0" + paddingSize="none" + > + <MyEuiCodeBlock> + Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + </MyEuiCodeBlock> + </EuiAccordion> + </EuiModalBody> + <EuiModalFooter> + <EuiButton + data-test-subj="modal-all-errors-close" + fill={true} + onClick={[Function]} + > + Close + </EuiButton> + </EuiModalFooter> +</EuiModal> +`; diff --git a/x-pack/plugins/cases/public/components/toasters/errors.ts b/x-pack/plugins/cases/public/components/toasters/errors.ts new file mode 100644 index 0000000000000..0a672aeee8b7c --- /dev/null +++ b/x-pack/plugins/cases/public/components/toasters/errors.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. + */ + +export class ToasterError extends Error { + public readonly messages: string[]; + + constructor(messages: string[]) { + super(messages[0]); + this.name = 'ToasterError'; + this.messages = messages; + } +} + +export const isToasterError = (error: unknown): error is ToasterError => + error instanceof ToasterError; diff --git a/x-pack/plugins/cases/public/components/toasters/index.test.tsx b/x-pack/plugins/cases/public/components/toasters/index.test.tsx new file mode 100644 index 0000000000000..1d78570e18a59 --- /dev/null +++ b/x-pack/plugins/cases/public/components/toasters/index.test.tsx @@ -0,0 +1,307 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { set } from '@elastic/safer-lodash-set/fp'; +import { cloneDeep } from 'lodash/fp'; +import { mount } from 'enzyme'; +import React, { useEffect } from 'react'; + +import { + AppToast, + useStateToaster, + ManageGlobalToaster, + GlobalToaster, + displayErrorToast, +} from '.'; + +jest.mock('uuid', () => { + return { + v1: jest.fn(() => '27261ae0-0bbb-11ea-b0ea-db767b07ea47'), + v4: jest.fn(() => '9e1f72a9-7c73-4b7f-a562-09940f7daf4a'), + }; +}); + +const mockToast: AppToast = { + color: 'danger', + id: 'id-super-id', + iconType: 'alert', + title: 'Test & Test', + toastLifeTimeMs: 100, + text: + 'Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', +}; + +describe('Toaster', () => { + describe('Manage Global Toaster Reducer', () => { + test('we can add a toast in the reducer', () => { + const AddToaster = () => { + const [{ toasts }, dispatch] = useStateToaster(); + return ( + <> + <button + data-test-subj="add-toast" + type="button" + onClick={() => dispatch({ type: 'addToaster', toast: mockToast })} + /> + {toasts.map((toast) => ( + <span + data-test-subj={`add-toaster-${toast.id}`} + key={`add-toaster-${toast.id}`} + >{`${toast.title} ${toast.text}`}</span> + ))} + </> + ); + }; + const wrapper = mount( + <ManageGlobalToaster> + <AddToaster /> + </ManageGlobalToaster> + ); + wrapper.find('[data-test-subj="add-toast"]').simulate('click'); + expect(wrapper.find('[data-test-subj="add-toaster-id-super-id"]').exists()).toBe(true); + }); + test('we can delete a toast in the reducer', () => { + const DeleteToaster = () => { + const [{ toasts }, dispatch] = useStateToaster(); + useEffect(() => { + if (toasts.length === 0) { + dispatch({ type: 'addToaster', toast: mockToast }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + <> + <button + data-test-subj="delete-toast" + type="button" + onClick={() => dispatch({ type: 'deleteToaster', id: mockToast.id })} + /> + {toasts.map((toast) => ( + <span + data-test-subj={`delete-toaster-${toast.id}`} + key={`delete-toaster-${toast.id}`} + >{`${toast.title} ${toast.text}`}</span> + ))} + </> + ); + }; + const wrapper = mount( + <ManageGlobalToaster> + <DeleteToaster /> + </ManageGlobalToaster> + ); + + expect(wrapper.find('[data-test-subj="delete-toaster-id-super-id"]').exists()).toBe(true); + wrapper.find('[data-test-subj="delete-toast"]').simulate('click'); + expect(wrapper.find('[data-test-subj="delete-toaster-id-super-id"]').exists()).toBe(false); + }); + }); + + describe('Global Toaster', () => { + test('Render a basic toaster', () => { + const AddToaster = () => { + const [{ toasts }, dispatch] = useStateToaster(); + return ( + <> + <button + data-test-subj="add-toast" + type="button" + onClick={() => dispatch({ type: 'addToaster', toast: mockToast })} + /> + {toasts.map((toast) => ( + <span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span> + ))} + </> + ); + }; + const wrapper = mount( + <ManageGlobalToaster> + <AddToaster /> + <GlobalToaster /> + </ManageGlobalToaster> + ); + wrapper.find('[data-test-subj="add-toast"]').simulate('click'); + + expect(wrapper.find('.euiGlobalToastList').exists()).toBe(true); + expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test'); + }); + + test('Render an error toaster', () => { + let mockErrorToast: AppToast = cloneDeep(mockToast); + mockErrorToast.title = 'Test & Test ERROR'; + mockErrorToast = set('errors', [mockErrorToast.text], mockErrorToast); + + const AddToaster = () => { + const [{ toasts }, dispatch] = useStateToaster(); + return ( + <> + <button + data-test-subj="add-toast" + type="button" + onClick={() => dispatch({ type: 'addToaster', toast: mockErrorToast })} + /> + {toasts.map((toast) => ( + <span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span> + ))} + </> + ); + }; + const wrapper = mount( + <ManageGlobalToaster> + <AddToaster /> + <GlobalToaster /> + </ManageGlobalToaster> + ); + wrapper.find('[data-test-subj="add-toast"]').simulate('click'); + + expect(wrapper.find('.euiGlobalToastList').exists()).toBe(true); + expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test ERROR'); + expect(wrapper.find('button[data-test-subj="toaster-show-all-error-modal"]').exists()).toBe( + true + ); + }); + + test('Only show one toast at the time', () => { + const mockOneMoreToast: AppToast = cloneDeep(mockToast); + mockOneMoreToast.id = 'id-super-id-II'; + mockOneMoreToast.title = 'Test & Test II'; + + const AddToaster = () => { + const [{ toasts }, dispatch] = useStateToaster(); + return ( + <> + <button + data-test-subj="add-toast" + type="button" + onClick={() => { + dispatch({ type: 'addToaster', toast: mockToast }); + dispatch({ type: 'addToaster', toast: mockOneMoreToast }); + }} + /> + <button + data-test-subj="delete-toast" + type="button" + onClick={() => { + dispatch({ type: 'deleteToaster', id: mockToast.id }); + }} + /> + {toasts.map((toast) => ( + <span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span> + ))} + </> + ); + }; + const wrapper = mount( + <ManageGlobalToaster> + <AddToaster /> + <GlobalToaster /> + </ManageGlobalToaster> + ); + wrapper.find('[data-test-subj="add-toast"]').simulate('click'); + + expect(wrapper.find('button[data-test-subj="toastCloseButton"]').length).toBe(1); + expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test'); + wrapper.find('button[data-test-subj="delete-toast"]').simulate('click'); + expect(wrapper.find('.euiToast').length).toBe(1); + expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test II'); + }); + + test('Do not show anymore toaster when modal error is open', () => { + let mockErrorToast: AppToast = cloneDeep(mockToast); + mockErrorToast.id = 'id-super-id-error'; + mockErrorToast = set('errors', [mockErrorToast.text], mockErrorToast); + + const AddToaster = () => { + const [{ toasts }, dispatch] = useStateToaster(); + return ( + <> + <button + data-test-subj="add-toast" + type="button" + onClick={() => { + dispatch({ type: 'addToaster', toast: mockErrorToast }); + dispatch({ type: 'addToaster', toast: mockToast }); + }} + /> + {toasts.map((toast) => ( + <span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span> + ))} + </> + ); + }; + const wrapper = mount( + <ManageGlobalToaster> + <AddToaster /> + <GlobalToaster /> + </ManageGlobalToaster> + ); + wrapper.find('[data-test-subj="add-toast"]').simulate('click'); + wrapper.find('button[data-test-subj="toaster-show-all-error-modal"]').simulate('click'); + + expect(wrapper.find('.euiToast').length).toBe(0); + }); + + test('Show new toaster when modal error is closing', () => { + let mockErrorToast: AppToast = cloneDeep(mockToast); + mockErrorToast.title = 'Test & Test II'; + mockErrorToast.id = 'id-super-id-error'; + mockErrorToast = set('errors', [mockErrorToast.text], mockErrorToast); + + const AddToaster = () => { + const [{ toasts }, dispatch] = useStateToaster(); + return ( + <> + <button + data-test-subj="add-toast" + type="button" + onClick={() => { + dispatch({ type: 'addToaster', toast: mockErrorToast }); + dispatch({ type: 'addToaster', toast: mockToast }); + }} + /> + {toasts.map((toast) => ( + <span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span> + ))} + </> + ); + }; + const wrapper = mount( + <ManageGlobalToaster> + <AddToaster /> + <GlobalToaster /> + </ManageGlobalToaster> + ); + wrapper.find('[data-test-subj="add-toast"]').simulate('click'); + expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test II'); + + wrapper.find('button[data-test-subj="toaster-show-all-error-modal"]').simulate('click'); + expect(wrapper.find('.euiToast').length).toBe(0); + + wrapper.find('button[data-test-subj="modal-all-errors-close"]').simulate('click'); + expect(wrapper.find('.euiToast').length).toBe(1); + expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test'); + }); + }); + + describe('displayErrorToast', () => { + test('dispatches toast with correct title and message', () => { + const mockErrorToast = { + toast: { + color: 'danger', + errors: ['message'], + iconType: 'alert', + id: '9e1f72a9-7c73-4b7f-a562-09940f7daf4a', + title: 'Title', + }, + type: 'addToaster', + }; + const dispatchToasterMock = jest.fn(); + displayErrorToast('Title', ['message'], dispatchToasterMock); + expect(dispatchToasterMock.mock.calls[0][0]).toEqual(mockErrorToast); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/toasters/index.tsx b/x-pack/plugins/cases/public/components/toasters/index.tsx new file mode 100644 index 0000000000000..ea17b03082751 --- /dev/null +++ b/x-pack/plugins/cases/public/components/toasters/index.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiGlobalToastList, EuiGlobalToastListToast as Toast } from '@elastic/eui'; +import { noop } from 'lodash/fp'; +import React, { createContext, Dispatch, useContext, useReducer, useState } from 'react'; +import styled from 'styled-components'; + +import { ModalAllErrors } from './modal_all_errors'; +import * as i18n from './translations'; + +export * from './utils'; +export * from './errors'; + +export interface AppToast extends Toast { + errors?: string[]; +} + +interface ToastState { + toasts: AppToast[]; +} + +const initialToasterState: ToastState = { + toasts: [], +}; + +export type ActionToaster = + | { type: 'addToaster'; toast: AppToast } + | { type: 'deleteToaster'; id: string } + | { type: 'toggleWaitToShowNextToast' }; + +export const StateToasterContext = createContext<[ToastState, Dispatch<ActionToaster>]>([ + initialToasterState, + () => noop, +]); + +export const useStateToaster = () => useContext(StateToasterContext); + +interface ManageGlobalToasterProps { + children: React.ReactNode; +} + +export const ManageGlobalToaster = ({ children }: ManageGlobalToasterProps) => { + const reducerToaster = (state: ToastState, action: ActionToaster) => { + switch (action.type) { + case 'addToaster': + return { ...state, toasts: [...state.toasts, action.toast] }; + case 'deleteToaster': + return { ...state, toasts: state.toasts.filter((msg) => msg.id !== action.id) }; + default: + return state; + } + }; + + return ( + <StateToasterContext.Provider value={useReducer(reducerToaster, initialToasterState)}> + {children} + </StateToasterContext.Provider> + ); +}; + +const GlobalToasterListContainer = styled.div` + position: absolute; + right: 0; + bottom: 0; +`; + +interface GlobalToasterProps { + toastLifeTimeMs?: number; +} + +export const GlobalToaster = ({ toastLifeTimeMs = 5000 }: GlobalToasterProps) => { + const [{ toasts }, dispatch] = useStateToaster(); + const [isShowing, setIsShowing] = useState(false); + const [toastInModal, setToastInModal] = useState<AppToast | null>(null); + + const toggle = (toast: AppToast) => { + if (isShowing) { + dispatch({ type: 'deleteToaster', id: toast.id }); + setToastInModal(null); + } else { + setToastInModal(toast); + } + setIsShowing(!isShowing); + }; + + return ( + <> + {toasts.length > 0 && !isShowing && ( + <GlobalToasterListContainer> + <EuiGlobalToastList + toasts={[formatToErrorToastIfNeeded(toasts[0], toggle)]} + dismissToast={({ id }) => { + dispatch({ type: 'deleteToaster', id }); + }} + toastLifeTimeMs={toastLifeTimeMs} + /> + </GlobalToasterListContainer> + )} + {toastInModal != null && ( + <ModalAllErrors isShowing={isShowing} toast={toastInModal} toggle={toggle} /> + )} + </> + ); +}; + +const formatToErrorToastIfNeeded = ( + toast: AppToast, + toggle: (toast: AppToast) => void +): AppToast => { + if (toast != null && toast.errors != null && toast.errors.length > 0) { + toast.text = ( + <ErrorToastContainer> + <EuiButton + data-test-subj="toaster-show-all-error-modal" + size="s" + color="danger" + onClick={() => toast != null && toggle(toast)} + > + {i18n.SEE_ALL_ERRORS} + </EuiButton> + </ErrorToastContainer> + ); + } + return toast; +}; + +const ErrorToastContainer = styled.div` + text-align: right; +`; + +ErrorToastContainer.displayName = 'ErrorToastContainer'; diff --git a/x-pack/plugins/cases/public/components/toasters/modal_all_errors.test.tsx b/x-pack/plugins/cases/public/components/toasters/modal_all_errors.test.tsx new file mode 100644 index 0000000000000..7ec0553591103 --- /dev/null +++ b/x-pack/plugins/cases/public/components/toasters/modal_all_errors.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { shallow } from 'enzyme'; + +import React from 'react'; + +import { ModalAllErrors } from './modal_all_errors'; +import { AppToast } from '.'; +import { cloneDeep } from 'lodash/fp'; + +const mockToast: AppToast = { + color: 'danger', + id: 'id-super-id', + iconType: 'alert', + title: 'Test & Test', + errors: [ + 'Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + ], +}; + +describe('Modal all errors', () => { + const toggle = jest.fn(); + describe('rendering', () => { + test('it renders the default all errors modal when isShowing is positive', () => { + const wrapper = shallow( + <ModalAllErrors isShowing={true} toast={mockToast} toggle={toggle} /> + ); + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders null when isShowing is negative', () => { + const wrapper = shallow( + <ModalAllErrors isShowing={false} toast={mockToast} toggle={toggle} /> + ); + expect(wrapper.html()).toEqual(null); + }); + + test('it renders multiple errors in modal', () => { + const mockToastWithTwoError = cloneDeep(mockToast); + mockToastWithTwoError.errors = [ + 'Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + 'Error 2, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + 'Error 3, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + ]; + const wrapper = shallow( + <ModalAllErrors isShowing={true} toast={mockToastWithTwoError} toggle={toggle} /> + ); + expect(wrapper.find('[data-test-subj="modal-all-errors-accordion"]').length).toBe( + mockToastWithTwoError.errors.length + ); + }); + }); + + describe('events', () => { + test('Make sure that toggle function has been called when you click on the close button', () => { + const wrapper = shallow( + <ModalAllErrors isShowing={true} toast={mockToast} toggle={toggle} /> + ); + + wrapper.find('[data-test-subj="modal-all-errors-close"]').simulate('click'); + wrapper.update(); + expect(toggle).toHaveBeenCalledWith(mockToast); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/toasters/modal_all_errors.tsx b/x-pack/plugins/cases/public/components/toasters/modal_all_errors.tsx new file mode 100644 index 0000000000000..0a78139f5fe3a --- /dev/null +++ b/x-pack/plugins/cases/public/components/toasters/modal_all_errors.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiCallOut, + EuiSpacer, + EuiCodeBlock, + EuiModalFooter, + EuiAccordion, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import styled from 'styled-components'; + +import { AppToast } from '.'; +import * as i18n from './translations'; + +interface FullErrorProps { + isShowing: boolean; + toast: AppToast; + toggle: (toast: AppToast) => void; +} + +const ModalAllErrorsComponent: React.FC<FullErrorProps> = ({ isShowing, toast, toggle }) => { + const handleClose = useCallback(() => toggle(toast), [toggle, toast]); + + if (!isShowing || toast == null) return null; + + return ( + <EuiModal onClose={handleClose}> + <EuiModalHeader> + <EuiModalHeaderTitle>{i18n.TITLE_ERROR_MODAL}</EuiModalHeaderTitle> + </EuiModalHeader> + + <EuiModalBody> + <EuiCallOut title={toast.title} color="danger" size="s" iconType="alert" /> + <EuiSpacer size="s" /> + {toast.errors != null && + toast.errors.map((error, index) => ( + <EuiAccordion + key={`${toast.id}-${index}`} + id="accordion1" + initialIsOpen={index === 0 ? true : false} + buttonContent={error.length > 100 ? `${error.substring(0, 100)} ...` : error} + data-test-subj="modal-all-errors-accordion" + > + <MyEuiCodeBlock>{error}</MyEuiCodeBlock> + </EuiAccordion> + ))} + </EuiModalBody> + + <EuiModalFooter> + <EuiButton onClick={handleClose} fill data-test-subj="modal-all-errors-close"> + {i18n.CLOSE_ERROR_MODAL} + </EuiButton> + </EuiModalFooter> + </EuiModal> + ); +}; + +export const ModalAllErrors = React.memo(ModalAllErrorsComponent); + +const MyEuiCodeBlock = styled(EuiCodeBlock)` + margin-top: 4px; +`; + +MyEuiCodeBlock.displayName = 'MyEuiCodeBlock'; diff --git a/x-pack/plugins/cases/public/components/toasters/translations.ts b/x-pack/plugins/cases/public/components/toasters/translations.ts new file mode 100644 index 0000000000000..cf7fac462a122 --- /dev/null +++ b/x-pack/plugins/cases/public/components/toasters/translations.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SEE_ALL_ERRORS = i18n.translate('xpack.cases.modalAllErrors.seeAllErrors.button', { + defaultMessage: 'See the full error(s)', +}); + +export const TITLE_ERROR_MODAL = i18n.translate('xpack.cases.modalAllErrors.title', { + defaultMessage: 'Your visualization has error(s)', +}); + +export const CLOSE_ERROR_MODAL = i18n.translate('xpack.cases.modalAllErrors.close.button', { + defaultMessage: 'Close', +}); diff --git a/x-pack/plugins/cases/public/components/toasters/utils.test.ts b/x-pack/plugins/cases/public/components/toasters/utils.test.ts new file mode 100644 index 0000000000000..34871b2e68efa --- /dev/null +++ b/x-pack/plugins/cases/public/components/toasters/utils.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { errorToToaster } from './utils'; +import { ToasterError } from './errors'; + +const ApiError = class extends Error { + public body: {} = {}; +}; + +describe('error_to_toaster', () => { + let dispatchToaster = jest.fn(); + + beforeEach(() => { + dispatchToaster = jest.fn(); + }); + + describe('#errorToToaster', () => { + test('dispatches an error toast given a ToasterError with multiple error messages', () => { + const error = new ToasterError(['some error 1', 'some error 2']); + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['some error 1', 'some error 2'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + + test('dispatches an error toast given a ToasterError with a single error message', () => { + const error = new ToasterError(['some error 1']); + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['some error 1'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + + test('dispatches an error toast given an ApiError with a message', () => { + const error = new ApiError('Internal Server Error'); + error.body = { message: 'something bad happened', status_code: 500 }; + + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['something bad happened'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + + test('dispatches an error toast given an ApiError with no message', () => { + const error = new ApiError('Internal Server Error'); + + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['Internal Server Error'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + + test('dispatches an error toast given a standard Error', () => { + const error = new Error('some error 1'); + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['some error 1'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + + test('adds a generic Network Error given a non Error object such as a string', () => { + const error = 'terrible string'; + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['Network Error'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/toasters/utils.ts b/x-pack/plugins/cases/public/components/toasters/utils.ts new file mode 100644 index 0000000000000..0575c40107668 --- /dev/null +++ b/x-pack/plugins/cases/public/components/toasters/utils.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type React from 'react'; +import uuid from 'uuid'; +import { isError } from 'lodash/fp'; + +import { AppToast, ActionToaster } from './'; +import { isToasterError } from './errors'; +import { isAppError } from '../../common/errors'; + +/** + * Displays an error toast for the provided title and message + * + * @param errorTitle Title of error to display in toaster and modal + * @param errorMessages Message to display in error modal when clicked + * @param dispatchToaster provided by useStateToaster() + */ +export const displayErrorToast = ( + errorTitle: string, + errorMessages: string[], + dispatchToaster: React.Dispatch<ActionToaster>, + id: string = uuid.v4() +): void => { + const toast: AppToast = { + id, + title: errorTitle, + color: 'danger', + iconType: 'alert', + errors: errorMessages, + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); +}; + +/** + * Displays a warning toast for the provided title and message + * + * @param title warning message to display in toaster and modal + * @param dispatchToaster provided by useStateToaster() + * @param id unique ID if necessary + */ +export const displayWarningToast = ( + title: string, + dispatchToaster: React.Dispatch<ActionToaster>, + id: string = uuid.v4() +): void => { + const toast: AppToast = { + id, + title, + color: 'warning', + iconType: 'help', + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); +}; + +/** + * Displays a success toast for the provided title and message + * + * @param title success message to display in toaster and modal + * @param dispatchToaster provided by useStateToaster() + */ +export const displaySuccessToast = ( + title: string, + dispatchToaster: React.Dispatch<ActionToaster>, + id: string = uuid.v4() +): void => { + const toast: AppToast = { + id, + title, + color: 'success', + iconType: 'check', + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); +}; + +export type ErrorToToasterArgs = Partial<AppToast> & { + error: unknown; + dispatchToaster: React.Dispatch<ActionToaster>; +}; + +/** + * Displays an error toast with messages parsed from the error + * + * @param title error message to display in toaster and modal + * @param error the error from which messages will be parsed + * @param dispatchToaster provided by useStateToaster() + */ +export const errorToToaster = ({ + id = uuid.v4(), + title, + error, + color = 'danger', + iconType = 'alert', + dispatchToaster, +}: ErrorToToasterArgs) => { + let toast: AppToast; + + if (isToasterError(error)) { + toast = { + id, + title, + color, + iconType, + errors: error.messages, + }; + } else if (isAppError(error)) { + toast = { + id, + title, + color, + iconType, + errors: [error.body.message], + }; + } else if (isError(error)) { + toast = { + id, + title, + color, + iconType, + errors: [error.message], + }; + } else { + toast = { + id, + title, + color, + iconType, + errors: ['Network Error'], + }; + } + + dispatchToaster({ + type: 'addToaster', + toast, + }); +}; diff --git a/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx new file mode 100644 index 0000000000000..fcdc2f8e58774 --- /dev/null +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { mount } from 'enzyme'; + +import { CreateCaseModal } from './create_case_modal'; +import { TestProviders } from '../../common/mock'; + +jest.mock('../create/form_context', () => { + return { + FormContext: ({ + children, + onSuccess, + }: { + children: ReactNode; + onSuccess: ({ id }: { id: string }) => Promise<void>; + }) => { + return ( + <> + <button + type="button" + data-test-subj="form-context-on-success" + onClick={async () => { + await onSuccess({ id: 'case-id' }); + }} + > + {'submit'} + </button> + {children} + </> + ); + }, + }; +}); + +jest.mock('../create/form', () => { + return { + CreateCaseForm: () => { + return <>{'form'}</>; + }, + }; +}); + +jest.mock('../create/submit_button', () => { + return { + SubmitCaseButton: () => { + return <>{'Submit'}</>; + }, + }; +}); + +const onCloseCaseModal = jest.fn(); +const onSuccess = jest.fn(); +const defaultProps = { + isModalOpen: true, + onCloseCaseModal, + onSuccess, +}; + +describe('CreateCaseModal', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders', () => { + const wrapper = mount( + <TestProviders> + <CreateCaseModal {...defaultProps} /> + </TestProviders> + ); + + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy(); + }); + + it('it does not render the modal isModalOpen=false ', () => { + const wrapper = mount( + <TestProviders> + <CreateCaseModal {...defaultProps} isModalOpen={false} /> + </TestProviders> + ); + + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy(); + }); + + it('Closing modal calls onCloseCaseModal', () => { + const wrapper = mount( + <TestProviders> + <CreateCaseModal {...defaultProps} /> + </TestProviders> + ); + + wrapper.find('.euiModal__closeIcon').first().simulate('click'); + expect(onCloseCaseModal).toBeCalled(); + }); + + it('pass the correct props to FormContext component', () => { + const wrapper = mount( + <TestProviders> + <CreateCaseModal {...defaultProps} /> + </TestProviders> + ); + + const props = wrapper.find('FormContext').props(); + expect(props).toEqual( + expect.objectContaining({ + onSuccess, + }) + ); + }); + + it('onSuccess called when creating a case', () => { + const wrapper = mount( + <TestProviders> + <CreateCaseModal {...defaultProps} /> + </TestProviders> + ); + + wrapper.find(`[data-test-subj='form-context-on-success']`).first().simulate('click'); + expect(onSuccess).toHaveBeenCalledWith({ id: 'case-id' }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx new file mode 100644 index 0000000000000..fc397b24e7046 --- /dev/null +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import styled from 'styled-components'; +import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; + +import { FormContext } from '../create/form_context'; +import { CreateCaseForm } from '../create/form'; +import { SubmitCaseButton } from '../create/submit_button'; +import { Case } from '../../containers/types'; +import * as i18n from '../../common/translations'; +import { CaseType } from '../../../common'; + +export interface CreateCaseModalProps { + isModalOpen: boolean; + onCloseCaseModal: () => void; + onSuccess: (theCase: Case) => Promise<void>; + caseType?: CaseType; + hideConnectorServiceNowSir?: boolean; +} + +const Container = styled.div` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSize}; + text-align: right; + `} +`; + +const CreateModalComponent: React.FC<CreateCaseModalProps> = ({ + isModalOpen, + onCloseCaseModal, + onSuccess, + caseType = CaseType.individual, + hideConnectorServiceNowSir = false, +}) => { + return isModalOpen ? ( + <EuiModal onClose={onCloseCaseModal} data-test-subj="all-cases-modal"> + <EuiModalHeader> + <EuiModalHeaderTitle>{i18n.CREATE_TITLE}</EuiModalHeaderTitle> + </EuiModalHeader> + <EuiModalBody> + <FormContext + hideConnectorServiceNowSir={hideConnectorServiceNowSir} + caseType={caseType} + onSuccess={onSuccess} + > + <CreateCaseForm + withSteps={false} + hideConnectorServiceNowSir={hideConnectorServiceNowSir} + /> + <Container> + <SubmitCaseButton /> + </Container> + </FormContext> + </EuiModalBody> + </EuiModal> + ) : null; +}; + +export const CreateCaseModal = memo(CreateModalComponent); + +CreateCaseModal.displayName = 'CreateCaseModal'; diff --git a/x-pack/plugins/cases/public/components/use_create_case_modal/index.test.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/index.test.tsx new file mode 100644 index 0000000000000..df9e6f0af60d9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/index.test.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useKibana } from '../../common/lib/kibana'; +import { useCreateCaseModal, UseCreateCaseModalProps, UseCreateCaseModalReturnedValues } from '.'; +import { TestProviders } from '../../common/mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../create/form_context', () => { + return { + FormContext: ({ + children, + onSuccess, + }: { + children: ReactNode; + onSuccess: ({ id }: { id: string }) => Promise<void>; + }) => { + return ( + <> + <button + type="button" + data-test-subj="form-context-on-success" + onClick={async () => { + await onSuccess({ id: 'case-id' }); + }} + > + {'Form submit'} + </button> + {children} + </> + ); + }, + }; +}); + +jest.mock('../create/form', () => { + return { + CreateCaseForm: () => { + return <>{'form'}</>; + }, + }; +}); + +jest.mock('../create/submit_button', () => { + return { + SubmitCaseButton: () => { + return <>{'Submit'}</>; + }, + }; +}); + +const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; +const onCaseCreated = jest.fn(); + +describe('useCreateCaseModal', () => { + let navigateToApp: jest.Mock; + + beforeEach(() => { + navigateToApp = jest.fn(); + useKibanaMock().services.application.navigateToApp = navigateToApp; + }); + + it('init', async () => { + const { result } = renderHook<UseCreateCaseModalProps, UseCreateCaseModalReturnedValues>( + () => useCreateCaseModal({ onCaseCreated }), + { + wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, + } + ); + + expect(result.current.isModalOpen).toBe(false); + }); + + it('opens the modal', async () => { + const { result } = renderHook<UseCreateCaseModalProps, UseCreateCaseModalReturnedValues>( + () => useCreateCaseModal({ onCaseCreated }), + { + wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, + } + ); + + act(() => { + result.current.openModal(); + }); + + expect(result.current.isModalOpen).toBe(true); + }); + + it('closes the modal', async () => { + const { result } = renderHook<UseCreateCaseModalProps, UseCreateCaseModalReturnedValues>( + () => useCreateCaseModal({ onCaseCreated }), + { + wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, + } + ); + + act(() => { + result.current.openModal(); + result.current.closeModal(); + }); + + expect(result.current.isModalOpen).toBe(false); + }); + + it('returns a memoized value', async () => { + const { result, rerender } = renderHook< + UseCreateCaseModalProps, + UseCreateCaseModalReturnedValues + >(() => useCreateCaseModal({ onCaseCreated }), { + wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, + }); + + const result1 = result.current; + act(() => rerender()); + const result2 = result.current; + + expect(Object.is(result1, result2)).toBe(true); + }); + + it('closes the modal when creating a case', async () => { + const { result } = renderHook<UseCreateCaseModalProps, UseCreateCaseModalReturnedValues>( + () => useCreateCaseModal({ onCaseCreated }), + { + wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, + } + ); + + act(() => { + result.current.openModal(); + }); + + const modal = result.current.modal; + render(<TestProviders>{modal}</TestProviders>); + + act(() => { + userEvent.click(screen.getByText('Form submit')); + }); + + expect(result.current.isModalOpen).toBe(false); + expect(onCaseCreated).toHaveBeenCalledWith({ id: 'case-id' }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx new file mode 100644 index 0000000000000..7da3f49be721d --- /dev/null +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { CaseType } from '../../../common'; +import { Case } from '../../containers/types'; +import { CreateCaseModal } from './create_case_modal'; + +export interface UseCreateCaseModalProps { + onCaseCreated: (theCase: Case) => void; + caseType?: CaseType; + hideConnectorServiceNowSir?: boolean; +} +export interface UseCreateCaseModalReturnedValues { + modal: JSX.Element; + isModalOpen: boolean; + closeModal: () => void; + openModal: () => void; +} + +export const useCreateCaseModal = ({ + caseType = CaseType.individual, + onCaseCreated, + hideConnectorServiceNowSir = false, +}: UseCreateCaseModalProps) => { + const [isModalOpen, setIsModalOpen] = useState<boolean>(false); + const closeModal = useCallback(() => setIsModalOpen(false), []); + const openModal = useCallback(() => setIsModalOpen(true), []); + const onSuccess = useCallback( + async (theCase) => { + onCaseCreated(theCase); + closeModal(); + }, + [onCaseCreated, closeModal] + ); + + const state = useMemo( + () => ({ + modal: ( + <CreateCaseModal + caseType={caseType} + hideConnectorServiceNowSir={hideConnectorServiceNowSir} + isModalOpen={isModalOpen} + onCloseCaseModal={closeModal} + onSuccess={onSuccess} + /> + ), + isModalOpen, + closeModal, + openModal, + }), + [caseType, closeModal, hideConnectorServiceNowSir, isModalOpen, onSuccess, openModal] + ); + + return state; +}; diff --git a/x-pack/plugins/cases/public/components/wrappers/index.tsx b/x-pack/plugins/cases/public/components/wrappers/index.tsx new file mode 100644 index 0000000000000..3b33e9304da83 --- /dev/null +++ b/x-pack/plugins/cases/public/components/wrappers/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import styled from 'styled-components'; + +export const WhitePageWrapper = styled.div` + background-color: ${({ theme }) => theme.eui.euiColorEmptyShade}; + border-top: ${({ theme }) => theme.eui.euiBorderThin}; + flex: 1 1 auto; +`; + +export const SectionWrapper = styled.div` + box-sizing: content-box; + margin: 0 auto; + max-width: 1175px; + width: 100%; +`; + +export const HeaderWrapper = styled.div` + padding: ${({ theme }) => + `${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 0 ${theme.eui.paddingSizes.l}`}; +`; diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts new file mode 100644 index 0000000000000..4dbb10da95b2d --- /dev/null +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ActionLicense, + AllCases, + BulkUpdateStatus, + Case, + CasesStatus, + CaseUserActions, + FetchCasesProps, + SortFieldCase, +} from '../types'; +import { + actionLicenses, + allCases, + basicCase, + basicCaseCommentPatch, + basicCasePost, + casesStatus, + caseUserActions, + pushedCase, + respReporters, + tags, +} from '../mock'; +import { + CasePatchRequest, + CasePostRequest, + CommentRequest, + User, + CaseStatuses, +} from '../../../common'; + +export const getCase = async ( + caseId: string, + includeComments: boolean = true, + signal: AbortSignal +): Promise<Case> => { + return Promise.resolve(basicCase); +}; + +export const getCasesStatus = async (signal: AbortSignal): Promise<CasesStatus> => + Promise.resolve(casesStatus); + +export const getTags = async (signal: AbortSignal): Promise<string[]> => Promise.resolve(tags); + +export const getReporters = async (signal: AbortSignal): Promise<User[]> => + Promise.resolve(respReporters); + +export const getCaseUserActions = async ( + caseId: string, + signal: AbortSignal +): Promise<CaseUserActions[]> => Promise.resolve(caseUserActions); + +export const getCases = async ({ + filterOptions = { + search: '', + reporters: [], + status: CaseStatuses.open, + tags: [], + }, + queryParams = { + page: 1, + perPage: 5, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', + }, + signal, +}: FetchCasesProps): Promise<AllCases> => Promise.resolve(allCases); + +export const postCase = async (newCase: CasePostRequest, signal: AbortSignal): Promise<Case> => + Promise.resolve(basicCasePost); + +export const patchCase = async ( + caseId: string, + updatedCase: Pick<CasePatchRequest, 'description' | 'status' | 'tags' | 'title'>, + version: string, + signal: AbortSignal +): Promise<Case[]> => Promise.resolve([basicCase]); + +export const patchCasesStatus = async ( + cases: BulkUpdateStatus[], + signal: AbortSignal +): Promise<Case[]> => Promise.resolve(allCases.cases); + +export const postComment = async ( + newComment: CommentRequest, + caseId: string, + signal: AbortSignal +): Promise<Case> => Promise.resolve(basicCase); + +export const patchComment = async ( + caseId: string, + commentId: string, + commentUpdate: string, + version: string, + signal: AbortSignal +): Promise<Case> => Promise.resolve(basicCaseCommentPatch); + +export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promise<boolean> => + Promise.resolve(true); + +export const pushCase = async ( + caseId: string, + connectorId: string, + signal: AbortSignal +): Promise<Case> => Promise.resolve(pushedCase); + +export const getActionLicense = async (signal: AbortSignal): Promise<ActionLicense[]> => + Promise.resolve(actionLicenses); diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx new file mode 100644 index 0000000000000..3e71a05df7cc1 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -0,0 +1,465 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { KibanaServices } from '../common/lib/kibana'; + +import { ConnectorTypes, CommentType, CaseStatuses } from '../../common'; +import { CASES_URL } from '../../common'; + +import { + deleteCases, + getActionLicense, + getCase, + getCases, + getCasesStatus, + getCaseUserActions, + getReporters, + getTags, + patchCase, + patchCasesStatus, + patchComment, + postCase, + postComment, + pushCase, +} from './api'; + +import { + actionLicenses, + allCases, + basicCase, + allCasesSnake, + basicCaseSnake, + pushedCaseSnake, + casesStatus, + casesSnake, + cases, + caseUserActions, + pushedCase, + reporters, + respReporters, + tags, + caseUserActionsSnake, + casesStatusSnake, +} from './mock'; + +import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; + +const abortCtrl = new AbortController(); +const mockKibanaServices = KibanaServices.get as jest.Mock; +jest.mock('../common/lib/kibana'); + +const fetchMock = jest.fn(); +mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); + +describe('Case Configuration API', () => { + describe('deleteCases', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(''); + }); + const data = ['1', '2']; + + test('check url, method, signal', async () => { + await deleteCases(data, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, { + method: 'DELETE', + query: { ids: JSON.stringify(data) }, + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await deleteCases(data, abortCtrl.signal); + expect(resp).toEqual(''); + }); + }); + + describe('getActionLicense', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(actionLicenses); + }); + + test('check url, method, signal', async () => { + await getActionLicense(abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`/api/actions/list_action_types`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getActionLicense(abortCtrl.signal); + expect(resp).toEqual(actionLicenses); + }); + }); + + describe('getCase', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + const data = basicCase.id; + + test('check url, method, signal', async () => { + await getCase(data, true, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}`, { + method: 'GET', + query: { includeComments: true }, + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCase(data, true, abortCtrl.signal); + expect(resp).toEqual(basicCase); + }); + }); + + describe('getCases', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(allCasesSnake); + }); + test('check url, method, signal', async () => { + await getCases({ + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + reporters: [], + tags: [], + }, + signal: abortCtrl.signal, + }); + }); + + test('correctly applies filters', async () => { + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + reporters: [...respReporters, { username: null, full_name: null, email: null }], + tags, + status: CaseStatuses.open, + search: 'hello', + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + reporters, + tags: ['"coke"', '"pepsi"'], + search: 'hello', + status: CaseStatuses.open, + }, + signal: abortCtrl.signal, + }); + }); + + test('tags with weird chars get handled gracefully', async () => { + const weirdTags: string[] = ['(', '"double"']; + + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + reporters: [...respReporters, { username: null, full_name: null, email: null }], + tags: weirdTags, + status: CaseStatuses.open, + search: 'hello', + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + reporters, + tags: ['"("', '"\\"double\\""'], + search: 'hello', + status: CaseStatuses.open, + }, + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCases({ + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(resp).toEqual({ ...allCases }); + }); + }); + + describe('getCasesStatus', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(casesStatusSnake); + }); + test('check url, method, signal', async () => { + await getCasesStatus(abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/status`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCasesStatus(abortCtrl.signal); + expect(resp).toEqual(casesStatus); + }); + }); + + describe('getCaseUserActions', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(caseUserActionsSnake); + }); + + test('check url, method, signal', async () => { + await getCaseUserActions(basicCase.id, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/user_actions`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCaseUserActions(basicCase.id, abortCtrl.signal); + expect(resp).toEqual(caseUserActions); + }); + }); + + describe('getReporters', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(respReporters); + }); + + test('check url, method, signal', async () => { + await getReporters(abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/reporters`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getReporters(abortCtrl.signal); + expect(resp).toEqual(respReporters); + }); + }); + + describe('getTags', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(tags); + }); + + test('check url, method, signal', async () => { + await getTags(abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/tags`, { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getTags(abortCtrl.signal); + expect(resp).toEqual(tags); + }); + }); + + describe('patchCase', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue([basicCaseSnake]); + }); + const data = { description: 'updated description' }; + test('check url, method, signal', async () => { + await patchCase(basicCase.id, data, basicCase.version, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, { + method: 'PATCH', + body: JSON.stringify({ + cases: [{ ...data, id: basicCase.id, version: basicCase.version }], + }), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await patchCase( + basicCase.id, + { description: 'updated description' }, + basicCase.version, + abortCtrl.signal + ); + expect(resp).toEqual({ ...[basicCase] }); + }); + }); + + describe('patchCasesStatus', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(casesSnake); + }); + const data = [ + { + status: CaseStatuses.closed, + id: basicCase.id, + version: basicCase.version, + }, + ]; + + test('check url, method, signal', async () => { + await patchCasesStatus(data, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, { + method: 'PATCH', + body: JSON.stringify({ cases: data }), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await patchCasesStatus(data, abortCtrl.signal); + expect(resp).toEqual({ ...cases }); + }); + }); + + describe('patchComment', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + + test('check url, method, signal', async () => { + await patchComment( + basicCase.id, + basicCase.comments[0].id, + 'updated comment', + basicCase.comments[0].version, + abortCtrl.signal + ); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/comments`, { + method: 'PATCH', + body: JSON.stringify({ + comment: 'updated comment', + type: CommentType.user, + id: basicCase.comments[0].id, + version: basicCase.comments[0].version, + }), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await patchComment( + basicCase.id, + basicCase.comments[0].id, + 'updated comment', + basicCase.comments[0].version, + abortCtrl.signal + ); + expect(resp).toEqual(basicCase); + }); + }); + + describe('postCase', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + const data = { + description: 'description', + tags: ['tag'], + title: 'title', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + }; + + test('check url, method, signal', async () => { + await postCase(data, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, { + method: 'POST', + body: JSON.stringify(data), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await postCase(data, abortCtrl.signal); + expect(resp).toEqual(basicCase); + }); + }); + + describe('postComment', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + const data = { + comment: 'comment', + type: CommentType.user as const, + }; + + test('check url, method, signal', async () => { + await postComment(data, basicCase.id, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/comments`, { + method: 'POST', + body: JSON.stringify(data), + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await postComment(data, basicCase.id, abortCtrl.signal); + expect(resp).toEqual(basicCase); + }); + }); + + describe('pushCase', () => { + const connectorId = 'connectorId'; + + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(pushedCaseSnake); + }); + + test('check url, method, signal', async () => { + await pushCase(basicCase.id, connectorId, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith( + `${CASES_URL}/${basicCase.id}/connector/${connectorId}/_push`, + { + method: 'POST', + body: JSON.stringify({}), + signal: abortCtrl.signal, + } + ); + }); + + test('happy path', async () => { + const resp = await pushCase(basicCase.id, connectorId, abortCtrl.signal); + expect(resp).toEqual(pushedCase); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts new file mode 100644 index 0000000000000..5827083bfdbd2 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -0,0 +1,347 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { assign, omit } from 'lodash'; + +import { + CasePatchRequest, + CasePostRequest, + CaseResponse, + CasesFindResponse, + CasesResponse, + CasesStatusResponse, + CaseType, + CaseUserActionsResponse, + CommentRequest, + CommentType, + SubCasePatchRequest, + SubCaseResponse, + SubCasesResponse, + User, +} from '../../common'; + +import { + ACTION_TYPES_URL, + CASE_REPORTERS_URL, + CASE_STATUS_URL, + CASE_TAGS_URL, + CASES_URL, + SUB_CASE_DETAILS_URL, + SUB_CASES_PATCH_DEL_URL, +} from '../../common'; + +import { + getCaseCommentsUrl, + getCasePushUrl, + getCaseDetailsUrl, + getCaseUserActionUrl, + getSubCaseDetailsUrl, + getSubCaseUserActionUrl, +} from '../../common'; + +import { KibanaServices } from '../common/lib/kibana'; +import { StatusAll } from '../components/status'; + +import { + ActionLicense, + AllCases, + BulkUpdateStatus, + Case, + CasesStatus, + FetchCasesProps, + SortFieldCase, + CaseUserActions, +} from './types'; + +import { + convertToCamelCase, + convertAllCasesToCamel, + convertArrayToCamelCase, + decodeCaseResponse, + decodeCasesResponse, + decodeCasesFindResponse, + decodeCasesStatusResponse, + decodeCaseUserActionsResponse, +} from './utils'; + +export const getCase = async ( + caseId: string, + includeComments: boolean = true, + signal: AbortSignal +): Promise<Case> => { + const response = await KibanaServices.get().http.fetch<CaseResponse>(getCaseDetailsUrl(caseId), { + method: 'GET', + query: { + includeComments, + }, + signal, + }); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response)); +}; + +export const getSubCase = async ( + caseId: string, + subCaseId: string, + includeComments: boolean = true, + signal: AbortSignal +): Promise<Case> => { + const [caseResponse, subCaseResponse] = await Promise.all([ + KibanaServices.get().http.fetch<CaseResponse>(getCaseDetailsUrl(caseId), { + method: 'GET', + query: { + includeComments: false, + }, + signal, + }), + KibanaServices.get().http.fetch<SubCaseResponse>(getSubCaseDetailsUrl(caseId, subCaseId), { + method: 'GET', + query: { + includeComments, + }, + signal, + }), + ]); + const response = assign<CaseResponse, SubCaseResponse>(caseResponse, subCaseResponse); + const subCaseIndex = response.subCaseIds?.findIndex((scId) => scId === response.id) ?? -1; + response.title = `${response.title}${subCaseIndex >= 0 ? ` ${subCaseIndex + 1}` : ''}`; + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response)); +}; + +export const getCasesStatus = async (signal: AbortSignal): Promise<CasesStatus> => { + const response = await KibanaServices.get().http.fetch<CasesStatusResponse>(CASE_STATUS_URL, { + method: 'GET', + signal, + }); + return convertToCamelCase<CasesStatusResponse, CasesStatus>(decodeCasesStatusResponse(response)); +}; + +export const getTags = async (signal: AbortSignal): Promise<string[]> => { + const response = await KibanaServices.get().http.fetch<string[]>(CASE_TAGS_URL, { + method: 'GET', + signal, + }); + return response ?? []; +}; + +export const getReporters = async (signal: AbortSignal): Promise<User[]> => { + const response = await KibanaServices.get().http.fetch<User[]>(CASE_REPORTERS_URL, { + method: 'GET', + signal, + }); + return response ?? []; +}; + +export const getCaseUserActions = async ( + caseId: string, + signal: AbortSignal +): Promise<CaseUserActions[]> => { + const response = await KibanaServices.get().http.fetch<CaseUserActionsResponse>( + getCaseUserActionUrl(caseId), + { + method: 'GET', + signal, + } + ); + return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[]; +}; + +export const getSubCaseUserActions = async ( + caseId: string, + subCaseId: string, + signal: AbortSignal +): Promise<CaseUserActions[]> => { + const response = await KibanaServices.get().http.fetch<CaseUserActionsResponse>( + getSubCaseUserActionUrl(caseId, subCaseId), + { + method: 'GET', + signal, + } + ); + return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[]; +}; + +export const getCases = async ({ + filterOptions = { + onlyCollectionType: false, + search: '', + reporters: [], + status: StatusAll, + tags: [], + }, + queryParams = { + page: 1, + perPage: 20, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', + }, + signal, +}: FetchCasesProps): Promise<AllCases> => { + const query = { + reporters: filterOptions.reporters.map((r) => r.username ?? '').filter((r) => r !== ''), + tags: filterOptions.tags.map((t) => `"${t.replace(/"/g, '\\"')}"`), + status: filterOptions.status, + ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), + ...(filterOptions.onlyCollectionType ? { type: CaseType.collection } : {}), + ...queryParams, + }; + const response = await KibanaServices.get().http.fetch<CasesFindResponse>(`${CASES_URL}/_find`, { + method: 'GET', + query: query.status === StatusAll ? omit(query, ['status']) : query, + signal, + }); + return convertAllCasesToCamel(decodeCasesFindResponse(response)); +}; + +export const postCase = async (newCase: CasePostRequest, signal: AbortSignal): Promise<Case> => { + const response = await KibanaServices.get().http.fetch<CaseResponse>(CASES_URL, { + method: 'POST', + body: JSON.stringify(newCase), + signal, + }); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response)); +}; + +export const patchCase = async ( + caseId: string, + updatedCase: Pick< + CasePatchRequest, + 'description' | 'status' | 'tags' | 'title' | 'settings' | 'connector' + >, + version: string, + signal: AbortSignal +): Promise<Case[]> => { + const response = await KibanaServices.get().http.fetch<CasesResponse>(CASES_URL, { + method: 'PATCH', + body: JSON.stringify({ cases: [{ ...updatedCase, id: caseId, version }] }), + signal, + }); + return convertToCamelCase<CasesResponse, Case[]>(decodeCasesResponse(response)); +}; + +export const patchSubCase = async ( + caseId: string, + subCaseId: string, + updatedSubCase: Pick<SubCasePatchRequest, 'status'>, + version: string, + signal: AbortSignal +): Promise<Case[]> => { + const subCaseResponse = await KibanaServices.get().http.fetch<SubCasesResponse>( + SUB_CASE_DETAILS_URL, + { + method: 'PATCH', + body: JSON.stringify({ cases: [{ ...updatedSubCase, id: caseId, version }] }), + signal, + } + ); + const caseResponse = await KibanaServices.get().http.fetch<CaseResponse>( + getCaseDetailsUrl(caseId), + { + method: 'GET', + query: { + includeComments: false, + }, + signal, + } + ); + const response = subCaseResponse.map((subCaseResp) => assign(caseResponse, subCaseResp)); + return convertToCamelCase<CasesResponse, Case[]>(decodeCasesResponse(response)); +}; + +export const patchCasesStatus = async ( + cases: BulkUpdateStatus[], + signal: AbortSignal +): Promise<Case[]> => { + const response = await KibanaServices.get().http.fetch<CasesResponse>(CASES_URL, { + method: 'PATCH', + body: JSON.stringify({ cases }), + signal, + }); + return convertToCamelCase<CasesResponse, Case[]>(decodeCasesResponse(response)); +}; + +export const postComment = async ( + newComment: CommentRequest, + caseId: string, + signal: AbortSignal, + subCaseId?: string +): Promise<Case> => { + const response = await KibanaServices.get().http.fetch<CaseResponse>( + `${CASES_URL}/${caseId}/comments`, + { + method: 'POST', + body: JSON.stringify(newComment), + ...(subCaseId ? { query: { subCaseId } } : {}), + signal, + } + ); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response)); +}; + +export const patchComment = async ( + caseId: string, + commentId: string, + commentUpdate: string, + version: string, + signal: AbortSignal, + subCaseId?: string +): Promise<Case> => { + const response = await KibanaServices.get().http.fetch<CaseResponse>(getCaseCommentsUrl(caseId), { + method: 'PATCH', + body: JSON.stringify({ + comment: commentUpdate, + type: CommentType.user, + id: commentId, + version, + }), + ...(subCaseId ? { query: { subCaseId } } : {}), + signal, + }); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response)); +}; + +export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promise<string> => { + const response = await KibanaServices.get().http.fetch<string>(CASES_URL, { + method: 'DELETE', + query: { ids: JSON.stringify(caseIds) }, + signal, + }); + return response; +}; + +export const deleteSubCases = async (caseIds: string[], signal: AbortSignal): Promise<string> => { + const response = await KibanaServices.get().http.fetch<string>(SUB_CASES_PATCH_DEL_URL, { + method: 'DELETE', + query: { ids: JSON.stringify(caseIds) }, + signal, + }); + return response; +}; + +export const pushCase = async ( + caseId: string, + connectorId: string, + signal: AbortSignal +): Promise<Case> => { + const response = await KibanaServices.get().http.fetch<CaseResponse>( + getCasePushUrl(caseId, connectorId), + { + method: 'POST', + body: JSON.stringify({}), + signal, + } + ); + + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response)); +}; + +export const getActionLicense = async (signal: AbortSignal): Promise<ActionLicense[]> => { + const response = await KibanaServices.get().http.fetch<ActionLicense[]>(ACTION_TYPES_URL, { + method: 'GET', + signal, + }); + return response; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts new file mode 100644 index 0000000000000..ea4b92706b4d1 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CasesConfigurePatch, + CasesConfigureRequest, + ActionConnector, + ActionTypeConnector, +} from '../../../../common'; + +import { ApiProps } from '../../types'; +import { CaseConfigure } from '../types'; +import { connectorsMock, caseConfigurationCamelCaseResponseMock, actionTypesMock } from '../mock'; + +export const fetchConnectors = async ({ signal }: ApiProps): Promise<ActionConnector[]> => + Promise.resolve(connectorsMock); + +export const getCaseConfigure = async ({ signal }: ApiProps): Promise<CaseConfigure> => + Promise.resolve(caseConfigurationCamelCaseResponseMock); + +export const postCaseConfigure = async ( + caseConfiguration: CasesConfigureRequest, + signal: AbortSignal +): Promise<CaseConfigure> => Promise.resolve(caseConfigurationCamelCaseResponseMock); + +export const patchCaseConfigure = async ( + caseConfiguration: CasesConfigurePatch, + signal: AbortSignal +): Promise<CaseConfigure> => Promise.resolve(caseConfigurationCamelCaseResponseMock); + +export const fetchActionTypes = async ({ signal }: ApiProps): Promise<ActionTypeConnector[]> => + Promise.resolve(actionTypesMock); diff --git a/x-pack/plugins/cases/public/containers/configure/api.test.ts b/x-pack/plugins/cases/public/containers/configure/api.test.ts new file mode 100644 index 0000000000000..ae749b4391776 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/api.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + fetchConnectors, + getCaseConfigure, + postCaseConfigure, + patchCaseConfigure, + fetchActionTypes, +} from './api'; +import { + connectorsMock, + actionTypesMock, + caseConfigurationMock, + caseConfigurationResposeMock, + caseConfigurationCamelCaseResponseMock, +} from './mock'; +import { ConnectorTypes } from '../../../common'; +import { KibanaServices } from '../../common/lib/kibana'; + +const abortCtrl = new AbortController(); +const mockKibanaServices = KibanaServices.get as jest.Mock; +jest.mock('../../common/lib/kibana'); + +const fetchMock = jest.fn(); +mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); + +describe('Case Configuration API', () => { + describe('fetch connectors', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(connectorsMock); + }); + + test('check url, method, signal', async () => { + await fetchConnectors({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure/connectors/_find', { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await fetchConnectors({ signal: abortCtrl.signal }); + expect(resp).toEqual(connectorsMock); + }); + }); + + describe('fetch configuration', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(caseConfigurationResposeMock); + }); + + test('check url, method, signal', async () => { + await getCaseConfigure({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await getCaseConfigure({ signal: abortCtrl.signal }); + expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + }); + + test('return null on empty response', async () => { + fetchMock.mockResolvedValue({}); + const resp = await getCaseConfigure({ signal: abortCtrl.signal }); + expect(resp).toBe(null); + }); + }); + + describe('create configuration', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(caseConfigurationResposeMock); + }); + + test('check url, body, method, signal', async () => { + await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { + body: + '{"connector":{"id":"123","name":"My connector","type":".jira","fields":null},"closure_type":"close-by-user"}', + method: 'POST', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); + expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + }); + }); + + describe('update configuration', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(caseConfigurationResposeMock); + }); + + test('check url, body, method, signal', async () => { + await patchCaseConfigure( + { + connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, + version: 'WzHJ12', + }, + abortCtrl.signal + ); + expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { + body: + '{"connector":{"id":"456","name":"My Connector 2","type":".none","fields":null},"version":"WzHJ12"}', + method: 'PATCH', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await patchCaseConfigure( + { + connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, + version: 'WzHJ12', + }, + abortCtrl.signal + ); + expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + }); + }); + + describe('fetch actionTypes', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(actionTypesMock); + }); + + test('check url, method, signal', async () => { + await fetchActionTypes({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/actions/list_action_types', { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await fetchActionTypes({ signal: abortCtrl.signal }); + expect(resp).toEqual(actionTypesMock); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts new file mode 100644 index 0000000000000..006370fcb5533 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash/fp'; +import { + ActionConnector, + ActionTypeConnector, + CasesConfigurePatch, + CasesConfigureResponse, + CasesConfigureRequest, +} from '../../../common'; +import { KibanaServices } from '../../common/lib/kibana'; + +import { + CASE_CONFIGURE_CONNECTORS_URL, + CASE_CONFIGURE_URL, + ACTION_TYPES_URL, +} from '../../../common'; + +import { ApiProps } from '../types'; +import { convertToCamelCase, decodeCaseConfigureResponse } from '../utils'; +import { CaseConfigure } from './types'; + +export const fetchConnectors = async ({ signal }: ApiProps): Promise<ActionConnector[]> => { + const response = await KibanaServices.get().http.fetch(`${CASE_CONFIGURE_CONNECTORS_URL}/_find`, { + method: 'GET', + signal, + }); + + return response; +}; + +export const getCaseConfigure = async ({ signal }: ApiProps): Promise<CaseConfigure | null> => { + const response = await KibanaServices.get().http.fetch<CasesConfigureResponse>( + CASE_CONFIGURE_URL, + { + method: 'GET', + signal, + } + ); + + return !isEmpty(response) + ? convertToCamelCase<CasesConfigureResponse, CaseConfigure>( + decodeCaseConfigureResponse(response) + ) + : null; +}; + +export const getConnectorMappings = async ({ signal }: ApiProps): Promise<ActionConnector[]> => { + const response = await KibanaServices.get().http.fetch(`${CASE_CONFIGURE_CONNECTORS_URL}/_find`, { + method: 'GET', + signal, + }); + + return response; +}; + +export const postCaseConfigure = async ( + caseConfiguration: CasesConfigureRequest, + signal: AbortSignal +): Promise<CaseConfigure> => { + const response = await KibanaServices.get().http.fetch<CasesConfigureResponse>( + CASE_CONFIGURE_URL, + { + method: 'POST', + body: JSON.stringify(caseConfiguration), + signal, + } + ); + return convertToCamelCase<CasesConfigureResponse, CaseConfigure>( + decodeCaseConfigureResponse(response) + ); +}; + +export const patchCaseConfigure = async ( + caseConfiguration: CasesConfigurePatch, + signal: AbortSignal +): Promise<CaseConfigure> => { + const response = await KibanaServices.get().http.fetch<CasesConfigureResponse>( + CASE_CONFIGURE_URL, + { + method: 'PATCH', + body: JSON.stringify(caseConfiguration), + signal, + } + ); + return convertToCamelCase<CasesConfigureResponse, CaseConfigure>( + decodeCaseConfigureResponse(response) + ); +}; + +export const fetchActionTypes = async ({ signal }: ApiProps): Promise<ActionTypeConnector[]> => { + const response = await KibanaServices.get().http.fetch(ACTION_TYPES_URL, { + method: 'GET', + signal, + }); + + return response; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts new file mode 100644 index 0000000000000..766452e3e58e7 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ActionConnector, + ActionTypeConnector, + CasesConfigureResponse, + CasesConfigureRequest, + ConnectorTypes, +} from '../../../common'; +import { CaseConfigure, CaseConnectorMapping } from './types'; + +export const mappings: CaseConnectorMapping[] = [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, +]; + +export const connectorsMock: ActionConnector[] = [ + { + id: 'servicenow-1', + actionTypeId: '.servicenow', + name: 'My Connector', + config: { + apiUrl: 'https://instance1.service-now.com', + }, + isPreconfigured: false, + }, + { + id: 'resilient-2', + actionTypeId: '.resilient', + name: 'My Connector 2', + config: { + apiUrl: 'https://test/', + orgId: '201', + }, + isPreconfigured: false, + }, + { + id: 'jira-1', + actionTypeId: '.jira', + name: 'Jira', + config: { + apiUrl: 'https://instance.atlassian.ne', + }, + isPreconfigured: false, + }, + { + id: 'servicenow-sir', + actionTypeId: '.servicenow-sir', + name: 'My Connector SIR', + config: { + apiUrl: 'https://instance1.service-now.com', + }, + isPreconfigured: false, + }, +]; + +export const actionTypesMock: ActionTypeConnector[] = [ + { + id: '.email', + name: 'Email', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, +]; + +export const caseConfigurationResposeMock: CasesConfigureResponse = { + created_at: '2020-04-06T13:03:18.657Z', + created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, + closure_type: 'close-by-pushing', + error: null, + mappings: [], + updated_at: '2020-04-06T14:03:18.657Z', + updated_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, + version: 'WzHJ12', +}; + +export const caseConfigurationMock: CasesConfigureRequest = { + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, + closure_type: 'close-by-user', +}; + +export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { + createdAt: '2020-04-06T13:03:18.657Z', + createdBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, + closureType: 'close-by-pushing', + error: null, + mappings: [], + updatedAt: '2020-04-06T14:03:18.657Z', + updatedBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, + version: 'WzHJ12', +}; diff --git a/x-pack/plugins/cases/public/containers/configure/translations.ts b/x-pack/plugins/cases/public/containers/configure/translations.ts new file mode 100644 index 0000000000000..e77b9f57c8f4c --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../translations'; + +export const SUCCESS_CONFIGURE = i18n.translate('xpack.cases.configure.successSaveToast', { + defaultMessage: 'Saved external connection settings', +}); diff --git a/x-pack/plugins/cases/public/containers/configure/types.ts b/x-pack/plugins/cases/public/containers/configure/types.ts new file mode 100644 index 0000000000000..b021ae2163fa2 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/types.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticUser } from '../types'; +import { + ActionConnector, + ActionTypeConnector, + ActionType, + CaseConnector, + CaseField, + CasesConfigure, + ClosureType, + ThirdPartyField, +} from '../../../common'; + +export { + ActionConnector, + ActionTypeConnector, + ActionType, + CaseConnector, + CaseField, + ClosureType, + ThirdPartyField, +}; + +export interface CaseConnectorMapping { + actionType: ActionType; + source: CaseField; + target: string; +} + +export interface CaseConfigure { + closureType: ClosureType; + connector: CasesConfigure['connector']; + createdAt: string; + createdBy: ElasticUser; + error: string | null; + mappings: CaseConnectorMapping[]; + updatedAt: string; + updatedBy: ElasticUser; + version: string; +} diff --git a/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx new file mode 100644 index 0000000000000..25017f7931db8 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useActionTypes, UseActionTypesResponse } from './use_action_types'; +import { actionTypesMock } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useActionTypes', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: true, + actionTypes: [], + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); + + test('fetch action types', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + actionTypes: actionTypesMock, + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); + + test('refetch actionTypes', async () => { + const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.refetchActionTypes(); + expect(spyOnfetchActionTypes).toHaveBeenCalledTimes(2); + }); + }); + + test('set isLoading to true when refetching actionTypes', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.refetchActionTypes(); + + expect(result.current.loading).toBe(true); + }); + }); + + test('unhappy path', async () => { + const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes'); + spyOnfetchActionTypes.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + actionTypes: [], + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/configure/use_action_types.tsx b/x-pack/plugins/cases/public/containers/configure/use_action_types.tsx new file mode 100644 index 0000000000000..206952661e672 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_action_types.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; + +import { useStateToaster, errorToToaster } from '../../components/toasters'; +import * as i18n from '../translations'; +import { fetchActionTypes } from './api'; +import { ActionTypeConnector } from './types'; + +export interface UseActionTypesResponse { + loading: boolean; + actionTypes: ActionTypeConnector[]; + refetchActionTypes: () => void; +} + +export const useActionTypes = (): UseActionTypesResponse => { + const [, dispatchToaster] = useStateToaster(); + const [loading, setLoading] = useState(true); + const [actionTypes, setActionTypes] = useState<ActionTypeConnector[]>([]); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + const queryFirstTime = useRef(true); + + const refetchActionTypes = useCallback(async () => { + try { + setLoading(true); + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + const res = await fetchActionTypes({ signal: abortCtrlRef.current.signal }); + + if (!isCancelledRef.current) { + setLoading(false); + setActionTypes(res); + } + } catch (error) { + if (!isCancelledRef.current) { + setLoading(false); + setActionTypes([]); + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + } + }, [dispatchToaster]); + + useEffect(() => { + if (queryFirstTime.current) { + refetchActionTypes(); + queryFirstTime.current = false; + } + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + queryFirstTime.current = true; + }; + }, [refetchActionTypes]); + + return { + loading, + actionTypes, + refetchActionTypes, + }; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx new file mode 100644 index 0000000000000..4e4db4cb5e82e --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx @@ -0,0 +1,326 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { + initialState, + useCaseConfigure, + ReturnUseCaseConfigure, + ConnectorConfiguration, +} from './use_configure'; +import { mappings, caseConfigurationCamelCaseResponseMock } from './mock'; +import * as api from './api'; +import { ConnectorTypes } from '../../../common'; + +jest.mock('./api'); +const mockErrorToToaster = jest.fn(); +jest.mock('../../components/toasters', () => { + const original = jest.requireActual('../../components/toasters'); + return { + ...original, + errorToToaster: () => mockErrorToToaster(), + }; +}); +const configuration: ConnectorConfiguration = { + connector: { + id: '456', + name: 'My connector 2', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-pushing', +}; + +describe('useConfigure', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + ...initialState, + refetchCaseConfigure: result.current.refetchCaseConfigure, + persistCaseConfigure: result.current.persistCaseConfigure, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setConnector: result.current.setConnector, + setClosureType: result.current.setClosureType, + setMappings: result.current.setMappings, + }); + }); + }); + + test('fetch case configuration', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + ...initialState, + closureType: caseConfigurationCamelCaseResponseMock.closureType, + connector: caseConfigurationCamelCaseResponseMock.connector, + currentConfiguration: { + closureType: caseConfigurationCamelCaseResponseMock.closureType, + connector: caseConfigurationCamelCaseResponseMock.connector, + }, + mappings: [], + firstLoad: true, + loading: false, + persistCaseConfigure: result.current.persistCaseConfigure, + refetchCaseConfigure: result.current.refetchCaseConfigure, + setClosureType: result.current.setClosureType, + setConnector: result.current.setConnector, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setMappings: result.current.setMappings, + version: caseConfigurationCamelCaseResponseMock.version, + }); + }); + }); + + test('refetch case configuration', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchCaseConfigure(); + expect(spyOnGetCaseConfigure).toHaveBeenCalledTimes(2); + }); + }); + + test('correctly sets mappings', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current.mappings).toEqual([]); + result.current.setMappings(mappings); + expect(result.current.mappings).toEqual(mappings); + }); + }); + + test('set isLoading to true when fetching case configuration', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchCaseConfigure(); + + expect(result.current.loading).toBe(true); + }); + }); + + test('persist case configuration', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.persistCaseConfigure(configuration); + expect(result.current.persistLoading).toBeTruthy(); + }); + }); + + test('save case configuration - postCaseConfigure', async () => { + // When there is no version, a configuration is created. Otherwise is updated. + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + spyOnGetCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + version: '', + }) + ); + + const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); + spyOnPostCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + ...configuration, + }) + ); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(mockErrorToToaster).not.toHaveBeenCalled(); + + result.current.persistCaseConfigure(configuration); + + expect(result.current.connector.id).toEqual('123'); + await waitForNextUpdate(); + expect(result.current.connector.id).toEqual('456'); + }); + }); + + test('Displays error when present - getCaseConfigure', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + spyOnGetCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + error: 'uh oh homeboy', + version: '', + }) + ); + + await act(async () => { + const { waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(mockErrorToToaster).toHaveBeenCalled(); + }); + }); + + test('Displays error when present - postCaseConfigure', async () => { + // When there is no version, a configuration is created. Otherwise is updated. + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + spyOnGetCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + version: '', + }) + ); + + const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); + spyOnPostCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + ...configuration, + error: 'uh oh homeboy', + }) + ); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(mockErrorToToaster).not.toHaveBeenCalled(); + + result.current.persistCaseConfigure(configuration); + expect(mockErrorToToaster).not.toHaveBeenCalled(); + await waitForNextUpdate(); + expect(mockErrorToToaster).toHaveBeenCalled(); + }); + }); + + test('save case configuration - patchCaseConfigure', async () => { + const spyOnPatchCaseConfigure = jest.spyOn(api, 'patchCaseConfigure'); + spyOnPatchCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + ...configuration, + }) + ); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.persistCaseConfigure(configuration); + + expect(result.current.connector.id).toEqual('123'); + await waitForNextUpdate(); + expect(result.current.connector.id).toEqual('456'); + }); + }); + + test('unhappy path - fetch case configuration', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + ...initialState, + loading: false, + persistCaseConfigure: result.current.persistCaseConfigure, + persistLoading: false, + refetchCaseConfigure: result.current.refetchCaseConfigure, + setClosureType: result.current.setClosureType, + setConnector: result.current.setConnector, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setMappings: result.current.setMappings, + }); + }); + }); + + test('unhappy path - persist case configuration', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + spyOnGetCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + version: '', + }) + ); + const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); + spyOnPostCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, ReturnUseCaseConfigure>(() => + useCaseConfigure() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.persistCaseConfigure(configuration); + + expect(result.current).toEqual({ + ...initialState, + closureType: caseConfigurationCamelCaseResponseMock.closureType, + connector: caseConfigurationCamelCaseResponseMock.connector, + currentConfiguration: { + closureType: caseConfigurationCamelCaseResponseMock.closureType, + connector: caseConfigurationCamelCaseResponseMock.connector, + }, + firstLoad: true, + loading: false, + mappings: [], + persistCaseConfigure: result.current.persistCaseConfigure, + refetchCaseConfigure: result.current.refetchCaseConfigure, + setClosureType: result.current.setClosureType, + setConnector: result.current.setConnector, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setMappings: result.current.setMappings, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx new file mode 100644 index 0000000000000..3d5e43b2772a9 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx @@ -0,0 +1,361 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useCallback, useReducer, useRef } from 'react'; +import { getCaseConfigure, patchCaseConfigure, postCaseConfigure } from './api'; + +import { useStateToaster, errorToToaster, displaySuccessToast } from '../../components/toasters'; +import * as i18n from './translations'; +import { ClosureType, CaseConfigure, CaseConnector, CaseConnectorMapping } from './types'; +import { ConnectorTypes } from '../../../common'; + +export type ConnectorConfiguration = { connector: CaseConnector } & { + closureType: CaseConfigure['closureType']; +}; + +export interface State extends ConnectorConfiguration { + currentConfiguration: ConnectorConfiguration; + firstLoad: boolean; + loading: boolean; + mappings: CaseConnectorMapping[]; + persistLoading: boolean; + version: string; +} +export type Action = + | { + type: 'setCurrentConfiguration'; + currentConfiguration: ConnectorConfiguration; + } + | { + type: 'setConnector'; + connector: CaseConnector; + } + | { + type: 'setLoading'; + payload: boolean; + } + | { + type: 'setFirstLoad'; + payload: boolean; + } + | { + type: 'setPersistLoading'; + payload: boolean; + } + | { + type: 'setVersion'; + payload: string; + } + | { + type: 'setClosureType'; + closureType: ClosureType; + } + | { + type: 'setMappings'; + mappings: CaseConnectorMapping[]; + }; + +export const configureCasesReducer = (state: State, action: Action) => { + switch (action.type) { + case 'setLoading': + return { + ...state, + loading: action.payload, + }; + case 'setFirstLoad': + return { + ...state, + firstLoad: action.payload, + }; + case 'setPersistLoading': + return { + ...state, + persistLoading: action.payload, + }; + case 'setVersion': + return { + ...state, + version: action.payload, + }; + case 'setCurrentConfiguration': { + return { + ...state, + currentConfiguration: { ...action.currentConfiguration }, + }; + } + case 'setConnector': { + return { + ...state, + connector: action.connector, + }; + } + case 'setClosureType': { + return { + ...state, + closureType: action.closureType, + }; + } + case 'setMappings': { + return { + ...state, + mappings: action.mappings, + }; + } + default: + return state; + } +}; + +export interface ReturnUseCaseConfigure extends State { + persistCaseConfigure: ({ connector, closureType }: ConnectorConfiguration) => unknown; + refetchCaseConfigure: () => void; + setClosureType: (closureType: ClosureType) => void; + setConnector: (connector: CaseConnector) => void; + setCurrentConfiguration: (configuration: ConnectorConfiguration) => void; + setMappings: (newMapping: CaseConnectorMapping[]) => void; +} + +export const initialState: State = { + closureType: 'close-by-user', + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + currentConfiguration: { + closureType: 'close-by-user', + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + }, + firstLoad: false, + loading: true, + mappings: [], + persistLoading: false, + version: '', +}; + +export const useCaseConfigure = (): ReturnUseCaseConfigure => { + const [state, dispatch] = useReducer(configureCasesReducer, initialState); + + const setCurrentConfiguration = useCallback((configuration: ConnectorConfiguration) => { + dispatch({ + currentConfiguration: configuration, + type: 'setCurrentConfiguration', + }); + }, []); + + const setConnector = useCallback((connector: CaseConnector) => { + dispatch({ + connector, + type: 'setConnector', + }); + }, []); + + const setClosureType = useCallback((closureType: ClosureType) => { + dispatch({ + closureType, + type: 'setClosureType', + }); + }, []); + + const setMappings = useCallback((mappings: CaseConnectorMapping[]) => { + dispatch({ + mappings, + type: 'setMappings', + }); + }, []); + + const setLoading = useCallback((isLoading: boolean) => { + dispatch({ + payload: isLoading, + type: 'setLoading', + }); + }, []); + + const setFirstLoad = useCallback((isFirstLoad: boolean) => { + dispatch({ + payload: isFirstLoad, + type: 'setFirstLoad', + }); + }, []); + + const setPersistLoading = useCallback((isPersistLoading: boolean) => { + dispatch({ + payload: isPersistLoading, + type: 'setPersistLoading', + }); + }, []); + + const setVersion = useCallback((version: string) => { + dispatch({ + payload: version, + type: 'setVersion', + }); + }, []); + + const [, dispatchToaster] = useStateToaster(); + const isCancelledRefetchRef = useRef(false); + const abortCtrlRefetchRef = useRef(new AbortController()); + + const isCancelledPersistRef = useRef(false); + const abortCtrlPersistRef = useRef(new AbortController()); + + const refetchCaseConfigure = useCallback(async () => { + try { + isCancelledRefetchRef.current = false; + abortCtrlRefetchRef.current.abort(); + abortCtrlRefetchRef.current = new AbortController(); + + setLoading(true); + const res = await getCaseConfigure({ signal: abortCtrlRefetchRef.current.signal }); + + if (!isCancelledRefetchRef.current) { + if (res != null) { + setConnector(res.connector); + if (setClosureType != null) { + setClosureType(res.closureType); + } + setVersion(res.version); + setMappings(res.mappings); + + if (!state.firstLoad) { + setFirstLoad(true); + if (setCurrentConfiguration != null) { + setCurrentConfiguration({ + closureType: res.closureType, + connector: { + ...res.connector, + }, + }); + } + } + if (res.error != null) { + errorToToaster({ + dispatchToaster, + error: new Error(res.error), + title: i18n.ERROR_TITLE, + }); + } + } + setLoading(false); + } + } catch (error) { + if (!isCancelledRefetchRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + dispatchToaster, + error: error.body && error.body.message ? new Error(error.body.message) : error, + title: i18n.ERROR_TITLE, + }); + } + setLoading(false); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.firstLoad]); + + const persistCaseConfigure = useCallback( + async ({ connector, closureType }: ConnectorConfiguration) => { + try { + isCancelledPersistRef.current = false; + abortCtrlPersistRef.current.abort(); + abortCtrlPersistRef.current = new AbortController(); + setPersistLoading(true); + + const connectorObj = { + connector, + closure_type: closureType, + }; + + const res = + state.version.length === 0 + ? await postCaseConfigure(connectorObj, abortCtrlPersistRef.current.signal) + : await patchCaseConfigure( + { + ...connectorObj, + version: state.version, + }, + abortCtrlPersistRef.current.signal + ); + + if (!isCancelledPersistRef.current) { + setConnector(res.connector); + if (setClosureType) { + setClosureType(res.closureType); + } + setVersion(res.version); + setMappings(res.mappings); + if (setCurrentConfiguration != null) { + setCurrentConfiguration({ + closureType: res.closureType, + connector: { + ...res.connector, + }, + }); + } + if (res.error != null) { + errorToToaster({ + dispatchToaster, + error: new Error(res.error), + title: i18n.ERROR_TITLE, + }); + } + displaySuccessToast(i18n.SUCCESS_CONFIGURE, dispatchToaster); + setPersistLoading(false); + } + } catch (error) { + if (!isCancelledPersistRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + setConnector(state.currentConfiguration.connector); + setPersistLoading(false); + } + } + }, + [ + dispatchToaster, + setClosureType, + setConnector, + setCurrentConfiguration, + setMappings, + setPersistLoading, + setVersion, + state, + ] + ); + + useEffect(() => { + refetchCaseConfigure(); + return () => { + isCancelledRefetchRef.current = true; + abortCtrlRefetchRef.current.abort(); + isCancelledPersistRef.current = true; + abortCtrlPersistRef.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + ...state, + refetchCaseConfigure, + persistCaseConfigure, + setCurrentConfiguration, + setConnector, + setClosureType, + setMappings, + }; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx new file mode 100644 index 0000000000000..ed1dfcbc40c87 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useConnectors, UseConnectorsResponse } from './use_connectors'; +import { connectorsMock } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useConnectors', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseConnectorsResponse>(() => + useConnectors() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: true, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); + }); + }); + + test('fetch connectors', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseConnectorsResponse>(() => + useConnectors() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: false, + connectors: connectorsMock, + refetchConnectors: result.current.refetchConnectors, + }); + }); + }); + + test('refetch connectors', async () => { + const spyOnfetchConnectors = jest.spyOn(api, 'fetchConnectors'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseConnectorsResponse>(() => + useConnectors() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchConnectors(); + expect(spyOnfetchConnectors).toHaveBeenCalledTimes(2); + }); + }); + + test('set isLoading to true when refetching connectors', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseConnectorsResponse>(() => + useConnectors() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchConnectors(); + + expect(result.current.loading).toBe(true); + }); + }); + + test('unhappy path', async () => { + const spyOnfetchConnectors = jest.spyOn(api, 'fetchConnectors'); + spyOnfetchConnectors.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseConnectorsResponse>(() => + useConnectors() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx new file mode 100644 index 0000000000000..b385a2676e044 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; + +import { useStateToaster, errorToToaster } from '../../components/toasters'; +import * as i18n from '../translations'; +import { fetchConnectors } from './api'; +import { ActionConnector } from './types'; + +export interface UseConnectorsResponse { + loading: boolean; + connectors: ActionConnector[]; + refetchConnectors: () => void; +} + +export const useConnectors = (): UseConnectorsResponse => { + const [, dispatchToaster] = useStateToaster(); + const [loading, setLoading] = useState(true); + const [connectors, setConnectors] = useState<ActionConnector[]>([]); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const refetchConnectors = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + setLoading(true); + const res = await fetchConnectors({ signal: abortCtrlRef.current.signal }); + + if (!isCancelledRef.current) { + setLoading(false); + setConnectors(res); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + + setLoading(false); + setConnectors([]); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + refetchConnectors(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + loading, + connectors, + refetchConnectors, + }; +}; diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts new file mode 100644 index 0000000000000..be030f4d2f75b --- /dev/null +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DEFAULT_TABLE_ACTIVE_PAGE = 1; +export const DEFAULT_TABLE_LIMIT = 5; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts new file mode 100644 index 0000000000000..1e7cec29de56b --- /dev/null +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -0,0 +1,377 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } from './types'; + +import { + AssociationType, + CaseResponse, + CasesFindResponse, + CasesResponse, + CasesStatusResponse, + CaseStatuses, + CaseType, + CaseUserActionsResponse, + CommentResponse, + CommentType, + ConnectorTypes, + UserAction, + UserActionField, +} from '../../common'; +import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; +export { connectorsMock } from './configure/mock'; + +export const basicCaseId = 'basic-case-id'; +export const basicSubCaseId = 'basic-sub-case-id'; +const basicCommentId = 'basic-comment-id'; +const basicCreatedAt = '2020-02-19T23:06:33.798Z'; +const basicUpdatedAt = '2020-02-20T15:02:57.995Z'; +const laterTime = '2020-02-28T15:02:57.995Z'; + +export const elasticUser = { + fullName: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', +}; + +export const tags: string[] = ['coke', 'pepsi']; + +export const basicComment: Comment = { + associationType: AssociationType.case, + comment: 'Solve this fast!', + type: CommentType.user, + id: basicCommentId, + createdAt: basicCreatedAt, + createdBy: elasticUser, + pushedAt: null, + pushedBy: null, + updatedAt: null, + updatedBy: null, + version: 'WzQ3LDFc', +}; + +export const alertComment: Comment = { + alertId: 'alert-id-1', + associationType: AssociationType.case, + index: 'alert-index-1', + type: CommentType.alert, + id: 'alert-comment-id', + createdAt: basicCreatedAt, + createdBy: elasticUser, + pushedAt: null, + pushedBy: null, + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + }, + updatedAt: null, + updatedBy: null, + version: 'WzQ3LDFc', +}; + +export const basicCase: Case = { + type: CaseType.individual, + closedAt: null, + closedBy: null, + id: basicCaseId, + comments: [basicComment], + createdAt: basicCreatedAt, + createdBy: elasticUser, + connector: { + id: '123', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + description: 'Security banana Issue', + externalService: null, + status: CaseStatuses.open, + tags, + title: 'Another horrible breach!!', + totalComment: 1, + totalAlerts: 0, + updatedAt: basicUpdatedAt, + updatedBy: elasticUser, + version: 'WzQ3LDFd', + settings: { + syncAlerts: true, + }, + subCaseIds: [], +}; + +export const collectionCase: Case = { + type: CaseType.collection, + closedAt: null, + closedBy: null, + id: 'collection-id', + comments: [basicComment], + createdAt: basicCreatedAt, + createdBy: elasticUser, + connector: { + id: '123', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + description: 'Security banana Issue', + externalService: null, + status: CaseStatuses.open, + tags, + title: 'Another horrible breach in a collection!!', + totalComment: 1, + totalAlerts: 0, + updatedAt: basicUpdatedAt, + updatedBy: elasticUser, + version: 'WzQ3LDFd', + settings: { + syncAlerts: true, + }, + subCases: [], + subCaseIds: [], +}; + +export const basicCasePost: Case = { + ...basicCase, + updatedAt: null, + updatedBy: null, +}; + +export const basicCommentPatch: Comment = { + ...basicComment, + updatedAt: basicUpdatedAt, + updatedBy: { + username: 'elastic', + }, +}; + +export const basicCaseCommentPatch = { + ...basicCase, + comments: [basicCommentPatch], +}; + +export const casesStatus: CasesStatus = { + countOpenCases: 20, + countInProgressCases: 40, + countClosedCases: 130, +}; + +export const basicPush = { + connectorId: '123', + connectorName: 'connector name', + externalId: 'external_id', + externalTitle: 'external title', + externalUrl: 'basicPush.com', + pushedAt: basicUpdatedAt, + pushedBy: elasticUser, +}; + +export const pushedCase: Case = { + ...basicCase, + externalService: basicPush, +}; + +const basicAction = { + actionAt: basicCreatedAt, + actionBy: elasticUser, + oldValue: null, + newValue: 'what a cool value', + caseId: basicCaseId, + commentId: null, +}; + +export const cases: Case[] = [ + basicCase, + { ...pushedCase, id: '1', totalComment: 0, comments: [] }, + { ...pushedCase, updatedAt: laterTime, id: '2', totalComment: 0, comments: [] }, + { ...basicCase, id: '3', totalComment: 0, comments: [] }, + { ...basicCase, id: '4', totalComment: 0, comments: [] }, +]; + +export const allCases: AllCases = { + cases, + page: 1, + perPage: 5, + total: 10, + ...casesStatus, +}; + +export const actionLicenses: ActionLicense[] = [ + { + id: '.servicenow', + name: 'ServiceNow', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, +]; + +// Snake case for mock api responses +export const elasticUserSnake = { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', +}; + +export const basicCommentSnake: CommentResponse = { + associationType: AssociationType.case, + comment: 'Solve this fast!', + type: CommentType.user, + id: basicCommentId, + created_at: basicCreatedAt, + created_by: elasticUserSnake, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + version: 'WzQ3LDFc', +}; + +export const basicCaseSnake: CaseResponse = { + ...basicCase, + status: CaseStatuses.open, + closed_at: null, + closed_by: null, + comments: [basicCommentSnake], + connector: { + id: '123', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + created_at: basicCreatedAt, + created_by: elasticUserSnake, + external_service: null, + updated_at: basicUpdatedAt, + updated_by: elasticUserSnake, +} as CaseResponse; + +export const casesStatusSnake: CasesStatusResponse = { + count_closed_cases: 130, + count_in_progress_cases: 40, + count_open_cases: 20, +}; + +export const pushSnake = { + connector_id: '123', + connector_name: 'connector name', + external_id: 'external_id', + external_title: 'external title', + external_url: 'basicPush.com', +}; + +export const basicPushSnake = { + ...pushSnake, + pushed_at: basicUpdatedAt, + pushed_by: elasticUserSnake, +}; + +export const pushedCaseSnake = { + ...basicCaseSnake, + external_service: basicPushSnake, +}; + +export const reporters: string[] = ['alexis', 'kim', 'maria', 'steph']; +export const respReporters = [ + { username: 'alexis', full_name: null, email: null }, + { username: 'kim', full_name: null, email: null }, + { username: 'maria', full_name: null, email: null }, + { username: 'steph', full_name: null, email: null }, +]; +export const casesSnake: CasesResponse = [ + basicCaseSnake, + { ...pushedCaseSnake, id: '1', totalComment: 0, comments: [] }, + { ...pushedCaseSnake, updated_at: laterTime, id: '2', totalComment: 0, comments: [] }, + { ...basicCaseSnake, id: '3', totalComment: 0, comments: [] }, + { ...basicCaseSnake, id: '4', totalComment: 0, comments: [] }, +]; + +export const allCasesSnake: CasesFindResponse = { + cases: casesSnake, + page: 1, + per_page: 5, + total: 10, + ...casesStatusSnake, +}; + +const basicActionSnake = { + action_at: basicCreatedAt, + action_by: elasticUserSnake, + old_value: null, + new_value: 'what a cool value', + case_id: basicCaseId, + comment_id: null, +}; +export const getUserActionSnake = (af: UserActionField, a: UserAction) => ({ + ...basicActionSnake, + action_id: `${af[0]}-${a}`, + action_field: af, + action: a, + comment_id: af[0] === 'comment' ? basicCommentId : null, + new_value: + a === 'push-to-service' && af[0] === 'pushed' + ? JSON.stringify(basicPushSnake) + : basicAction.newValue, +}); + +export const caseUserActionsSnake: CaseUserActionsResponse = [ + getUserActionSnake(['description'], 'create'), + getUserActionSnake(['comment'], 'create'), + getUserActionSnake(['description'], 'update'), +]; + +// user actions + +export const getUserAction = (af: UserActionField, a: UserAction) => ({ + ...basicAction, + actionId: `${af[0]}-${a}`, + actionField: af, + action: a, + commentId: af[0] === 'comment' ? basicCommentId : null, + newValue: + a === 'push-to-service' && af[0] === 'pushed' + ? JSON.stringify(basicPushSnake) + : basicAction.newValue, +}); + +export const getAlertUserAction = () => ({ + ...basicAction, + actionId: 'alert-action-id', + actionField: ['comment'], + action: 'create', + commentId: 'alert-comment-id', + newValue: '{"type":"alert","alertId":"alert-id-1","index":"index-id-1"}', +}); + +export const caseUserActions: CaseUserActions[] = [ + getUserAction(['description'], 'create'), + getUserAction(['comment'], 'create'), + getUserAction(['description'], 'update'), +]; + +// components tests +export const useGetCasesMockState: UseGetCasesState = { + data: allCases, + loading: [], + selectedCases: [], + isError: false, + queryParams: DEFAULT_QUERY_PARAMS, + filterOptions: DEFAULT_FILTER_OPTIONS, +}; + +export const basicCaseClosed: Case = { + ...basicCase, + closedAt: '2020-02-25T23:06:33.798Z', + closedBy: elasticUser, + status: CaseStatuses.closed, +}; diff --git a/x-pack/plugins/cases/public/containers/translations.ts b/x-pack/plugins/cases/public/containers/translations.ts new file mode 100644 index 0000000000000..966a5e158923f --- /dev/null +++ b/x-pack/plugins/cases/public/containers/translations.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 { i18n } from '@kbn/i18n'; + +export * from '../common/translations'; + +export const ERROR_TITLE = i18n.translate('xpack.cases.containers.errorTitle', { + defaultMessage: 'Error fetching data', +}); + +export const ERROR_DELETING = i18n.translate('xpack.cases.containers.errorDeletingTitle', { + defaultMessage: 'Error deleting data', +}); + +export const UPDATED_CASE = (caseTitle: string) => + i18n.translate('xpack.cases.containers.updatedCase', { + values: { caseTitle }, + defaultMessage: 'Updated "{caseTitle}"', + }); + +export const DELETED_CASES = (totalCases: number, caseTitle?: string) => + i18n.translate('xpack.cases.containers.deletedCases', { + values: { caseTitle, totalCases }, + defaultMessage: 'Deleted {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', + }); + +export const CLOSED_CASES = ({ + totalCases, + caseTitle, +}: { + totalCases: number; + caseTitle?: string; +}) => + i18n.translate('xpack.cases.containers.closedCases', { + values: { caseTitle, totalCases }, + defaultMessage: 'Closed {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', + }); + +export const REOPENED_CASES = ({ + totalCases, + caseTitle, +}: { + totalCases: number; + caseTitle?: string; +}) => + i18n.translate('xpack.cases.containers.reopenedCases', { + values: { caseTitle, totalCases }, + defaultMessage: 'Opened {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', + }); + +export const MARK_IN_PROGRESS_CASES = ({ + totalCases, + caseTitle, +}: { + totalCases: number; + caseTitle?: string; +}) => + i18n.translate('xpack.cases.containers.markInProgressCases', { + values: { caseTitle, totalCases }, + defaultMessage: + 'Marked {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}} as in progress', + }); + +export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = (serviceName: string) => + i18n.translate('xpack.cases.containers.pushToExternalService', { + values: { serviceName }, + defaultMessage: 'Successfully sent to { serviceName }', + }); + +export const ERROR_GET_FIELDS = i18n.translate('xpack.cases.configure.errorGetFields', { + defaultMessage: 'Error getting fields from service', +}); + +export const SYNC_CASE = (caseTitle: string) => + i18n.translate('xpack.cases.containers.syncCase', { + values: { caseTitle }, + defaultMessage: 'Alerts in "{caseTitle}" have been synced', + }); + +export const STATUS_CHANGED_TOASTER_TEXT = i18n.translate( + 'xpack.cases.containers.statusChangeToasterText', + { + defaultMessage: 'Alerts in this case have been also had their status updated', + } +); diff --git a/x-pack/plugins/cases/public/containers/types.ts b/x-pack/plugins/cases/public/containers/types.ts new file mode 100644 index 0000000000000..db6c6e678d188 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/types.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 { + User, + UserActionField, + UserAction, + CaseConnector, + CommentRequest, + CaseStatuses, + CaseAttributes, + CasePatchRequest, + CaseType, + AssociationType, +} from '../../common'; +import { CaseStatusWithAllStatus } from '../components/status'; + +export { CaseConnector, ActionConnector, CaseStatuses } from '../../common'; + +export type Comment = CommentRequest & { + associationType: AssociationType; + id: string; + createdAt: string; + createdBy: ElasticUser; + pushedAt: string | null; + pushedBy: string | null; + updatedAt: string | null; + updatedBy: ElasticUser | null; + version: string; +}; +export interface CaseUserActions { + actionId: string; + actionField: UserActionField; + action: UserAction; + actionAt: string; + actionBy: ElasticUser; + caseId: string; + commentId: string | null; + newValue: string | null; + oldValue: string | null; +} + +export interface CaseExternalService { + pushedAt: string; + pushedBy: ElasticUser; + connectorId: string; + connectorName: string; + externalId: string; + externalTitle: string; + externalUrl: string; +} + +interface BasicCase { + id: string; + closedAt: string | null; + closedBy: ElasticUser | null; + comments: Comment[]; + createdAt: string; + createdBy: ElasticUser; + status: CaseStatuses; + title: string; + totalAlerts: number; + totalComment: number; + updatedAt: string | null; + updatedBy: ElasticUser | null; + version: string; +} + +export interface SubCase extends BasicCase { + associationType: AssociationType; + caseParentId: string; +} + +export interface Case extends BasicCase { + connector: CaseConnector; + description: string; + externalService: CaseExternalService | null; + subCases?: SubCase[] | null; + subCaseIds: string[]; + settings: CaseAttributes['settings']; + tags: string[]; + type: CaseType; +} + +export interface QueryParams { + page: number; + perPage: number; + sortField: SortFieldCase; + sortOrder: 'asc' | 'desc'; +} + +export interface FilterOptions { + search: string; + status: CaseStatusWithAllStatus; + tags: string[]; + reporters: User[]; + onlyCollectionType?: boolean; +} + +export interface CasesStatus { + countClosedCases: number | null; + countOpenCases: number | null; + countInProgressCases: number | null; +} + +export interface AllCases extends CasesStatus { + cases: Case[]; + page: number; + perPage: number; + total: number; +} + +export enum SortFieldCase { + createdAt = 'createdAt', + closedAt = 'closedAt', + updatedAt = 'updatedAt', +} + +export interface ElasticUser { + readonly email?: string | null; + readonly fullName?: string | null; + readonly username?: string | null; +} + +export interface FetchCasesProps extends ApiProps { + queryParams?: QueryParams; + filterOptions?: FilterOptions; +} + +export interface ApiProps { + signal: AbortSignal; +} + +export interface BulkUpdateStatus { + status: string; + id: string; + version: string; +} +export interface ActionLicense { + id: string; + name: string; + enabled: boolean; + enabledInConfig: boolean; + enabledInLicense: boolean; +} + +export interface DeleteCase { + id: string; + type: CaseType | null; + title?: string; +} + +export interface FieldMappings { + id: string; + title?: string; +} + +export type UpdateKey = keyof Pick< + CasePatchRequest, + 'connector' | 'description' | 'status' | 'tags' | 'title' | 'settings' +>; + +export interface UpdateByKey { + updateKey: UpdateKey; + updateValue: CasePatchRequest[UpdateKey]; + fetchCaseUserActions?: (caseId: string, caseConnectorId: string, subCaseId?: string) => void; + updateCase?: (newCase: Case) => void; + caseData: Case; + onSuccess?: () => void; + onError?: () => void; +} diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx new file mode 100644 index 0000000000000..8b5993255552a --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { CaseStatuses } from '../../common'; +import { + DEFAULT_FILTER_OPTIONS, + DEFAULT_QUERY_PARAMS, + initialData, + useGetCases, + UseGetCases, +} from './use_get_cases'; +import { UpdateKey } from './types'; +import { allCases, basicCase } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useGetCases', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + expect(result.current).toEqual({ + data: initialData, + dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty, + filterOptions: DEFAULT_FILTER_OPTIONS, + isError: false, + loading: [], + queryParams: DEFAULT_QUERY_PARAMS, + refetchCases: result.current.refetchCases, + selectedCases: [], + setFilters: result.current.setFilters, + setQueryParams: result.current.setQueryParams, + setSelectedCases: result.current.setSelectedCases, + }); + }); + }); + + it('calls getCases with correct arguments', async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + await act(async () => { + const { waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetCases).toBeCalledWith({ + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + }); + }); + + it('fetch cases', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + data: allCases, + dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty, + filterOptions: DEFAULT_FILTER_OPTIONS, + isError: false, + loading: [], + queryParams: DEFAULT_QUERY_PARAMS, + refetchCases: result.current.refetchCases, + selectedCases: [], + setFilters: result.current.setFilters, + setQueryParams: result.current.setQueryParams, + setSelectedCases: result.current.setSelectedCases, + }); + }); + }); + it('dispatch update case property', async () => { + const spyOnPatchCase = jest.spyOn(api, 'patchCase'); + await act(async () => { + const updateCase = { + updateKey: 'description' as UpdateKey, + updateValue: 'description update', + caseId: basicCase.id, + refetchCasesStatus: jest.fn(), + version: '99999', + }; + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.dispatchUpdateCaseProperty(updateCase); + expect(result.current.loading).toEqual(['caseUpdate']); + expect(spyOnPatchCase).toBeCalledWith( + basicCase.id, + { [updateCase.updateKey]: updateCase.updateValue }, + updateCase.version, + abortCtrl.signal + ); + }); + }); + + it('refetch cases', async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchCases(); + expect(spyOnGetCases).toHaveBeenCalledTimes(2); + }); + }); + + it('set isLoading to true when refetching case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.refetchCases(); + + expect(result.current.loading).toEqual(['cases']); + }); + }); + + it('unhappy path', async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + spyOnGetCases.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + data: initialData, + dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty, + filterOptions: DEFAULT_FILTER_OPTIONS, + isError: true, + loading: [], + queryParams: DEFAULT_QUERY_PARAMS, + refetchCases: result.current.refetchCases, + selectedCases: [], + setFilters: result.current.setFilters, + setQueryParams: result.current.setQueryParams, + setSelectedCases: result.current.setSelectedCases, + }); + }); + }); + it('set filters', async () => { + await act(async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + const newFilters = { + search: 'new', + tags: ['new'], + status: CaseStatuses.closed, + }; + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.setFilters(newFilters); + await waitForNextUpdate(); + expect(spyOnGetCases.mock.calls[1][0]).toEqual({ + filterOptions: { ...DEFAULT_FILTER_OPTIONS, ...newFilters }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + }); + }); + it('set query params', async () => { + await act(async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + const newQueryParams = { + page: 2, + }; + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.setQueryParams(newQueryParams); + await waitForNextUpdate(); + expect(spyOnGetCases.mock.calls[1][0]).toEqual({ + filterOptions: DEFAULT_FILTER_OPTIONS, + queryParams: { ...DEFAULT_QUERY_PARAMS, ...newQueryParams }, + signal: abortCtrl.signal, + }); + }); + }); + it('set selected cases', async () => { + await act(async () => { + const selectedCases = [basicCase]; + const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.setSelectedCases(selectedCases); + expect(result.current.selectedCases).toEqual(selectedCases); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx new file mode 100644 index 0000000000000..e06a47954cdd4 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -0,0 +1,255 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; +import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case, UpdateByKey } from './types'; +import { errorToToaster, useStateToaster } from '../components/toasters'; +import * as i18n from './translations'; +import { getCases, patchCase } from './api'; +import { StatusAll } from '../components/status'; + +export interface UseGetCasesState { + data: AllCases; + filterOptions: FilterOptions; + isError: boolean; + loading: string[]; + queryParams: QueryParams; + selectedCases: Case[]; +} + +export interface UpdateCase extends Omit<UpdateByKey, 'caseData'> { + caseId: string; + version: string; + refetchCasesStatus: () => void; +} + +export type Action = + | { type: 'FETCH_INIT'; payload: string } + | { + type: 'FETCH_CASES_SUCCESS'; + payload: AllCases; + } + | { type: 'FETCH_FAILURE'; payload: string } + | { type: 'FETCH_UPDATE_CASE_SUCCESS' } + | { type: 'UPDATE_FILTER_OPTIONS'; payload: Partial<FilterOptions> } + | { type: 'UPDATE_QUERY_PARAMS'; payload: Partial<QueryParams> } + | { type: 'UPDATE_TABLE_SELECTIONS'; payload: Case[] }; + +const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isError: false, + loading: [...state.loading.filter((e) => e !== action.payload), action.payload], + }; + case 'FETCH_UPDATE_CASE_SUCCESS': + return { + ...state, + loading: state.loading.filter((e) => e !== 'caseUpdate'), + }; + case 'FETCH_CASES_SUCCESS': + return { + ...state, + data: action.payload, + isError: false, + loading: state.loading.filter((e) => e !== 'cases'), + }; + case 'FETCH_FAILURE': + return { + ...state, + isError: true, + loading: state.loading.filter((e) => e !== action.payload), + }; + case 'UPDATE_FILTER_OPTIONS': + return { + ...state, + filterOptions: { + ...state.filterOptions, + ...action.payload, + }, + }; + case 'UPDATE_QUERY_PARAMS': + return { + ...state, + queryParams: { + ...state.queryParams, + ...action.payload, + }, + }; + case 'UPDATE_TABLE_SELECTIONS': + return { + ...state, + selectedCases: action.payload, + }; + default: + return state; + } +}; + +export const DEFAULT_FILTER_OPTIONS: FilterOptions = { + search: '', + reporters: [], + status: StatusAll, + tags: [], + onlyCollectionType: false, +}; + +export const DEFAULT_QUERY_PARAMS: QueryParams = { + page: DEFAULT_TABLE_ACTIVE_PAGE, + perPage: DEFAULT_TABLE_LIMIT, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', +}; + +export const initialData: AllCases = { + cases: [], + countClosedCases: null, + countInProgressCases: null, + countOpenCases: null, + page: 0, + perPage: 0, + total: 0, +}; +export interface UseGetCases extends UseGetCasesState { + dispatchUpdateCaseProperty: ({ + updateKey, + updateValue, + caseId, + version, + refetchCasesStatus, + }: UpdateCase) => void; + refetchCases: () => void; + setFilters: (filters: Partial<FilterOptions>) => void; + setQueryParams: (queryParams: Partial<QueryParams>) => void; + setSelectedCases: (mySelectedCases: Case[]) => void; +} + +export const useGetCases = ( + initialQueryParams?: QueryParams, + initialFilterOptions?: FilterOptions +): UseGetCases => { + const [state, dispatch] = useReducer(dataFetchReducer, { + data: initialData, + filterOptions: initialFilterOptions ?? DEFAULT_FILTER_OPTIONS, + isError: false, + loading: [], + queryParams: initialQueryParams ?? DEFAULT_QUERY_PARAMS, + selectedCases: [], + }); + const [, dispatchToaster] = useStateToaster(); + const didCancelFetchCases = useRef(false); + const didCancelUpdateCases = useRef(false); + const abortCtrlFetchCases = useRef(new AbortController()); + const abortCtrlUpdateCases = useRef(new AbortController()); + + const setSelectedCases = useCallback((mySelectedCases: Case[]) => { + dispatch({ type: 'UPDATE_TABLE_SELECTIONS', payload: mySelectedCases }); + }, []); + + const setQueryParams = useCallback((newQueryParams: Partial<QueryParams>) => { + dispatch({ type: 'UPDATE_QUERY_PARAMS', payload: newQueryParams }); + }, []); + + const setFilters = useCallback((newFilters: Partial<FilterOptions>) => { + dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: newFilters }); + }, []); + + const fetchCases = useCallback(async (filterOptions: FilterOptions, queryParams: QueryParams) => { + try { + didCancelFetchCases.current = false; + abortCtrlFetchCases.current.abort(); + abortCtrlFetchCases.current = new AbortController(); + dispatch({ type: 'FETCH_INIT', payload: 'cases' }); + + const response = await getCases({ + filterOptions, + queryParams, + signal: abortCtrlFetchCases.current.signal, + }); + + if (!didCancelFetchCases.current) { + dispatch({ + type: 'FETCH_CASES_SUCCESS', + payload: response, + }); + } + } catch (error) { + if (!didCancelFetchCases.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const dispatchUpdateCaseProperty = useCallback( + async ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { + try { + didCancelUpdateCases.current = false; + abortCtrlUpdateCases.current.abort(); + abortCtrlUpdateCases.current = new AbortController(); + dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' }); + + await patchCase( + caseId, + { [updateKey]: updateValue }, + // saved object versions are typed as string | undefined, hope that's not true + version ?? '', + abortCtrlUpdateCases.current.signal + ); + + if (!didCancelUpdateCases.current) { + dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); + fetchCases(state.filterOptions, state.queryParams); + refetchCasesStatus(); + } + } catch (error) { + if (!didCancelUpdateCases.current) { + if (error.name !== 'AbortError') { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + } + dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [state.filterOptions, state.queryParams] + ); + + const refetchCases = useCallback(() => { + fetchCases(state.filterOptions, state.queryParams); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.filterOptions, state.queryParams]); + + useEffect(() => { + fetchCases(state.filterOptions, state.queryParams); + return () => { + didCancelFetchCases.current = true; + didCancelUpdateCases.current = true; + abortCtrlFetchCases.current.abort(); + abortCtrlUpdateCases.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.queryParams, state.filterOptions]); + + return { + ...state, + dispatchUpdateCaseProperty, + refetchCases, + setFilters, + setQueryParams, + setSelectedCases, + }; +}; diff --git a/x-pack/plugins/cases/public/containers/use_get_tags.test.tsx b/x-pack/plugins/cases/public/containers/use_get_tags.test.tsx new file mode 100644 index 0000000000000..8042e560df350 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_tags.test.tsx @@ -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 { renderHook, act } from '@testing-library/react-hooks'; +import { useGetTags, UseGetTags } from './use_get_tags'; +import { tags } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useGetTags', () => { + const abortCtrl = new AbortController(); + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + expect(result.current).toEqual({ + tags: [], + isLoading: true, + isError: false, + fetchTags: result.current.fetchTags, + }); + }); + }); + + it('calls getTags api', async () => { + const spyOnGetTags = jest.spyOn(api, 'getTags'); + await act(async () => { + const { waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetTags).toBeCalledWith(abortCtrl.signal); + }); + }); + + it('fetch tags', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + tags, + isLoading: false, + isError: false, + fetchTags: result.current.fetchTags, + }); + }); + }); + + it('refetch tags', async () => { + const spyOnGetTags = jest.spyOn(api, 'getTags'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.fetchTags(); + expect(spyOnGetTags).toHaveBeenCalledTimes(2); + }); + }); + + it('unhappy path', async () => { + const spyOnGetTags = jest.spyOn(api, 'getTags'); + spyOnGetTags.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseGetTags>(() => useGetTags()); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + tags: [], + isLoading: false, + isError: true, + fetchTags: result.current.fetchTags, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_get_tags.tsx b/x-pack/plugins/cases/public/containers/use_get_tags.tsx new file mode 100644 index 0000000000000..33b863fba5da3 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_tags.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useReducer, useRef, useCallback } from 'react'; +import { errorToToaster, useStateToaster } from '../components/toasters'; +import { getTags } from './api'; +import * as i18n from './translations'; + +export interface TagsState { + tags: string[]; + isLoading: boolean; + isError: boolean; +} +type Action = + | { type: 'FETCH_INIT' } + | { type: 'FETCH_SUCCESS'; payload: string[] } + | { type: 'FETCH_FAILURE' }; + +export interface UseGetTags extends TagsState { + fetchTags: () => void; +} + +const dataFetchReducer = (state: TagsState, action: Action): TagsState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS': + return { + ...state, + isLoading: false, + isError: false, + tags: action.payload, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + return state; + } +}; +const initialData: string[] = []; + +export const useGetTags = (): UseGetTags => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: true, + isError: false, + tags: initialData, + }); + const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const callFetch = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT' }); + + const response = await getTags(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + dispatch({ type: 'FETCH_FAILURE' }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + callFetch(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return { ...state, fetchTags: callFetch }; +}; diff --git a/x-pack/plugins/cases/public/containers/use_post_case.test.tsx b/x-pack/plugins/cases/public/containers/use_post_case.test.tsx new file mode 100644 index 0000000000000..72ea368f10317 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_post_case.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { usePostCase, UsePostCase } from './use_post_case'; +import * as api from './api'; +import { ConnectorTypes } from '../../common'; +import { basicCasePost } from './mock'; + +jest.mock('./api'); + +describe('usePostCase', () => { + const abortCtrl = new AbortController(); + const samplePost = { + description: 'description', + tags: ['tags'], + title: 'title', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + }; + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + postCase: result.current.postCase, + }); + }); + }); + + it('calls postCase with correct arguments', async () => { + const spyOnPostCase = jest.spyOn(api, 'postCase'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + + result.current.postCase(samplePost); + await waitForNextUpdate(); + expect(spyOnPostCase).toBeCalledWith(samplePost, abortCtrl.signal); + }); + }); + + it('calls postCase with correct result', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + + const postData = await result.current.postCase(samplePost); + expect(postData).toEqual(basicCasePost); + }); + }); + + it('post case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + result.current.postCase(samplePost); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + postCase: result.current.postCase, + }); + }); + }); + + it('set isLoading to true when posting case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + result.current.postCase(samplePost); + + expect(result.current.isLoading).toBe(true); + }); + }); + + it('unhappy path', async () => { + const spyOnPostCase = jest.spyOn(api, 'postCase'); + spyOnPostCase.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + result.current.postCase(samplePost); + + expect(result.current).toEqual({ + isLoading: false, + isError: true, + postCase: result.current.postCase, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_post_case.tsx b/x-pack/plugins/cases/public/containers/use_post_case.tsx new file mode 100644 index 0000000000000..503ac8bf0209d --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_post_case.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useReducer, useCallback, useRef, useEffect } from 'react'; +import { CasePostRequest } from '../../common'; +import { errorToToaster, useStateToaster } from '../components/toasters'; +import { postCase } from './api'; +import * as i18n from './translations'; +import { Case } from './types'; +interface NewCaseState { + isLoading: boolean; + isError: boolean; +} +type Action = { type: 'FETCH_INIT' } | { type: 'FETCH_SUCCESS' } | { type: 'FETCH_FAILURE' }; + +const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS': + return { + ...state, + isLoading: false, + isError: false, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + return state; + } +}; +export interface UsePostCase extends NewCaseState { + postCase: (data: CasePostRequest) => Promise<Case | undefined>; +} +export const usePostCase = (): UsePostCase => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + }); + const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const postMyCase = useCallback(async (data: CasePostRequest) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + dispatch({ type: 'FETCH_INIT' }); + const response = await postCase(data, abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS' }); + } + return response; + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + dispatch({ type: 'FETCH_FAILURE' }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, postCase: postMyCase }; +}; diff --git a/x-pack/plugins/cases/public/containers/use_post_push_to_service.test.tsx b/x-pack/plugins/cases/public/containers/use_post_push_to_service.test.tsx new file mode 100644 index 0000000000000..3d43180d60aff --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_post_push_to_service.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { usePostPushToService, UsePostPushToService } from './use_post_push_to_service'; +import { pushedCase } from './mock'; +import * as api from './api'; +import { CaseConnector, ConnectorTypes } from '../../common'; + +jest.mock('./api'); + +describe('usePostPushToService', () => { + const abortCtrl = new AbortController(); + const connector = { + id: '123', + name: 'connector name', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'Low', parent: null }, + } as CaseConnector; + const caseId = pushedCase.id; + + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + pushCaseToExternalService: result.current.pushCaseToExternalService, + }); + }); + }); + + it('calls pushCase with correct arguments', async () => { + const spyOnPushToService = jest.spyOn(api, 'pushCase'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + result.current.pushCaseToExternalService({ caseId, connector }); + await waitForNextUpdate(); + expect(spyOnPushToService).toBeCalledWith(caseId, connector.id, abortCtrl.signal); + }); + }); + + it('post push to service', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + result.current.pushCaseToExternalService({ caseId, connector }); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + isError: false, + pushCaseToExternalService: result.current.pushCaseToExternalService, + }); + }); + }); + + it('set isLoading to true when pushing case', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + result.current.pushCaseToExternalService({ caseId, connector }); + expect(result.current.isLoading).toBe(true); + }); + }); + + it('unhappy path', async () => { + const spyOnPushToService = jest.spyOn(api, 'pushCase'); + spyOnPushToService.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() => + usePostPushToService() + ); + await waitForNextUpdate(); + result.current.pushCaseToExternalService({ caseId, connector }); + + expect(result.current).toEqual({ + isLoading: false, + isError: true, + pushCaseToExternalService: result.current.pushCaseToExternalService, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_post_push_to_service.tsx b/x-pack/plugins/cases/public/containers/use_post_push_to_service.tsx new file mode 100644 index 0000000000000..636edd33b5e92 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_post_push_to_service.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useReducer, useCallback, useRef, useEffect } from 'react'; +import { CaseConnector } from '../../common'; +import { errorToToaster, useStateToaster, displaySuccessToast } from '../components/toasters'; + +import { pushCase } from './api'; +import * as i18n from './translations'; +import { Case } from './types'; + +interface PushToServiceState { + isLoading: boolean; + isError: boolean; +} +type Action = { type: 'FETCH_INIT' } | { type: 'FETCH_SUCCESS' } | { type: 'FETCH_FAILURE' }; + +const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS': + return { + ...state, + isLoading: false, + isError: false, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + return state; + } +}; + +interface PushToServiceRequest { + caseId: string; + connector: CaseConnector; +} + +export interface UsePostPushToService extends PushToServiceState { + pushCaseToExternalService: ({ + caseId, + connector, + }: PushToServiceRequest) => Promise<Case | undefined>; +} + +export const usePostPushToService = (): UsePostPushToService => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + }); + const [, dispatchToaster] = useStateToaster(); + const cancel = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const pushCaseToExternalService = useCallback( + async ({ caseId, connector }: PushToServiceRequest) => { + try { + abortCtrlRef.current.abort(); + cancel.current = false; + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT' }); + + const response = await pushCase(caseId, connector.id, abortCtrlRef.current.signal); + + if (!cancel.current) { + dispatch({ type: 'FETCH_SUCCESS' }); + displaySuccessToast( + i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE(connector.name), + dispatchToaster + ); + } + + return response; + } catch (error) { + if (!cancel.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + dispatch({ type: 'FETCH_FAILURE' }); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + return () => { + abortCtrlRef.current.abort(); + cancel.current = true; + }; + }, []); + + return { ...state, pushCaseToExternalService }; +}; diff --git a/x-pack/plugins/cases/public/containers/utils.test.ts b/x-pack/plugins/cases/public/containers/utils.test.ts new file mode 100644 index 0000000000000..6c1fb60298938 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/utils.test.ts @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + valueToUpdateIsSettings, + valueToUpdateIsStatus, + createUpdateSuccessToaster, +} from './utils'; + +import { Case } from './types'; + +const caseBeforeUpdate = { + comments: [ + { + type: 'alert', + }, + ], + settings: { + syncAlerts: true, + }, +} as Case; + +const caseAfterUpdate = { title: 'My case' } as Case; + +describe('utils', () => { + describe('valueToUpdateIsSettings', () => { + it('returns true if key is settings', () => { + expect(valueToUpdateIsSettings('settings', 'value')).toBe(true); + }); + + it('returns false if key is NOT settings', () => { + expect(valueToUpdateIsSettings('tags', 'value')).toBe(false); + }); + }); + + describe('valueToUpdateIsStatus', () => { + it('returns true if key is status', () => { + expect(valueToUpdateIsStatus('status', 'value')).toBe(true); + }); + + it('returns false if key is NOT status', () => { + expect(valueToUpdateIsStatus('tags', 'value')).toBe(false); + }); + }); + + describe('createUpdateSuccessToaster', () => { + it('creates the correct toast when sync alerts is turned on and case has alerts', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + caseBeforeUpdate, + caseAfterUpdate, + 'settings', + { + syncAlerts: true, + } + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Alerts in "My case" have been synced', + }); + }); + + it('creates the correct toast when sync alerts is turned on and case does NOT have alerts', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + { ...caseBeforeUpdate, comments: [] }, + caseAfterUpdate, + 'settings', + { + syncAlerts: true, + } + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + + it('creates the correct toast when sync alerts is turned off and case has alerts', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + caseBeforeUpdate, + caseAfterUpdate, + 'settings', + { + syncAlerts: false, + } + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + + it('creates the correct toast when the status change, case has alerts, and sync alerts is on', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + caseBeforeUpdate, + caseAfterUpdate, + 'status', + 'closed' + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + text: 'Alerts in this case have been also had their status updated', + }); + }); + + it('creates the correct toast when the status change, case has alerts, and sync alerts is off', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + { ...caseBeforeUpdate, settings: { syncAlerts: false } }, + caseAfterUpdate, + 'status', + 'closed' + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + + it('creates the correct toast when the status change, case does NOT have alerts, and sync alerts is on', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + { ...caseBeforeUpdate, comments: [] }, + caseAfterUpdate, + 'status', + 'closed' + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + + it('creates the correct toast if not a status or a setting', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + caseBeforeUpdate, + caseAfterUpdate, + 'title', + 'My new title' + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts new file mode 100644 index 0000000000000..a7eeaff1c2637 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; +import { set } from '@elastic/safer-lodash-set'; +import { camelCase, isArray, isObject } from 'lodash'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { + CasesFindResponse, + CasesFindResponseRt, + CaseResponse, + CaseResponseRt, + CasesResponse, + CasesResponseRt, + CasesStatusResponseRt, + CasesStatusResponse, + throwErrors, + CasesConfigureResponse, + CaseConfigureResponseRt, + CaseUserActionsResponse, + CaseUserActionsResponseRt, + CommentType, + CasePatchRequest, +} from '../../common'; +import { AppToast, ToasterError } from '../components/toasters'; +import { AllCases, Case, UpdateByKey } from './types'; +import * as i18n from './translations'; + +export const getTypedPayload = <T>(a: unknown): T => a as T; + +export const parseString = (params: string) => { + try { + return JSON.parse(params); + } catch { + return null; + } +}; + +export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => + arrayOfSnakes.reduce((acc: unknown[], value) => { + if (isArray(value)) { + return [...acc, convertArrayToCamelCase(value)]; + } else if (isObject(value)) { + return [...acc, convertToCamelCase(value)]; + } else { + return [...acc, value]; + } + }, []); + +export const convertToCamelCase = <T, U extends {}>(snakeCase: T): U => + Object.entries(snakeCase).reduce((acc, [key, value]) => { + if (isArray(value)) { + set(acc, camelCase(key), convertArrayToCamelCase(value)); + } else if (isObject(value)) { + set(acc, camelCase(key), convertToCamelCase(value)); + } else { + set(acc, camelCase(key), value); + } + return acc; + }, {} as U); + +export const convertAllCasesToCamel = (snakeCases: CasesFindResponse): AllCases => ({ + cases: snakeCases.cases.map((snakeCase) => convertToCamelCase<CaseResponse, Case>(snakeCase)), + countOpenCases: snakeCases.count_open_cases, + countInProgressCases: snakeCases.count_in_progress_cases, + countClosedCases: snakeCases.count_closed_cases, + page: snakeCases.page, + perPage: snakeCases.per_page, + total: snakeCases.total, +}); + +export const decodeCasesStatusResponse = (respCase?: CasesStatusResponse) => + pipe( + CasesStatusResponseRt.decode(respCase), + fold(throwErrors(createToasterPlainError), identity) + ); + +export const createToasterPlainError = (message: string) => new ToasterError([message]); + +export const decodeCaseResponse = (respCase?: CaseResponse) => + pipe(CaseResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity)); + +export const decodeCasesResponse = (respCase?: CasesResponse) => + pipe(CasesResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity)); + +export const decodeCasesFindResponse = (respCases?: CasesFindResponse) => + pipe(CasesFindResponseRt.decode(respCases), fold(throwErrors(createToasterPlainError), identity)); + +export const decodeCaseConfigureResponse = (respCase?: CasesConfigureResponse) => + pipe( + CaseConfigureResponseRt.decode(respCase), + fold(throwErrors(createToasterPlainError), identity) + ); + +export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsResponse) => + pipe( + CaseUserActionsResponseRt.decode(respUserActions), + fold(throwErrors(createToasterPlainError), identity) + ); + +export const valueToUpdateIsSettings = ( + key: UpdateByKey['updateKey'], + value: UpdateByKey['updateValue'] +): value is CasePatchRequest['settings'] => key === 'settings'; + +export const valueToUpdateIsStatus = ( + key: UpdateByKey['updateKey'], + value: UpdateByKey['updateValue'] +): value is CasePatchRequest['status'] => key === 'status'; + +export const createUpdateSuccessToaster = ( + caseBeforeUpdate: Case, + caseAfterUpdate: Case, + key: UpdateByKey['updateKey'], + value: UpdateByKey['updateValue'] +): AppToast => { + const caseHasAlerts = caseBeforeUpdate.comments.some( + (comment) => comment.type === CommentType.alert + ); + + const toast: AppToast = { + id: uuid.v4(), + color: 'success', + iconType: 'check', + title: i18n.UPDATED_CASE(caseAfterUpdate.title), + }; + + if (valueToUpdateIsSettings(key, value) && value?.syncAlerts && caseHasAlerts) { + return { + ...toast, + title: i18n.SYNC_CASE(caseAfterUpdate.title), + }; + } + + if (valueToUpdateIsStatus(key, value) && caseHasAlerts && caseBeforeUpdate.settings.syncAlerts) { + return { + ...toast, + text: i18n.STATUS_CHANGED_TOASTER_TEXT, + }; + } + + return toast; +}; diff --git a/x-pack/plugins/cases/public/get_create_case.tsx b/x-pack/plugins/cases/public/get_create_case.tsx new file mode 100644 index 0000000000000..ec13d9ae9e305 --- /dev/null +++ b/x-pack/plugins/cases/public/get_create_case.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { CreateCaseProps } from './components/create'; + +export const getCreateCaseLazy = (props: CreateCaseProps) => { + const CreateCaseLazy = lazy(() => import('./components/create')); + return ( + <Suspense fallback={<EuiLoadingSpinner />}> + <CreateCaseLazy {...props} /> + </Suspense> + ); +}; diff --git a/x-pack/plugins/cases/public/index.tsx b/x-pack/plugins/cases/public/index.tsx new file mode 100644 index 0000000000000..1cf2d2e8d7067 --- /dev/null +++ b/x-pack/plugins/cases/public/index.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 { PluginInitializerContext } from 'kibana/public'; +import React from 'react'; +import { CasesUiPlugin } from './plugin'; + +export const TestComponent = () => <div>{'Hello from cases plugin!'}</div>; + +export function plugin(initializerContext: PluginInitializerContext) { + return new CasesUiPlugin(initializerContext); +} + +export { CasesUiPlugin }; +export * from './plugin'; +export * from './types'; diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts new file mode 100644 index 0000000000000..c594e8677a086 --- /dev/null +++ b/x-pack/plugins/cases/public/plugin.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 { CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { TestComponent } from '.'; +import { CasesUiStart, SetupPlugins, StartPlugins } from './types'; +import { getCreateCaseLazy } from './get_create_case'; +import { KibanaServices } from './common/lib/kibana'; + +export class CasesUiPlugin implements Plugin<void, CasesUiStart, SetupPlugins, StartPlugins> { + private kibanaVersion: string; + + constructor(initializerContext: PluginInitializerContext) { + this.kibanaVersion = initializerContext.env.packageInfo.version; + } + public setup() {} + + public start(core: CoreStart, plugins: StartPlugins): CasesUiStart { + KibanaServices.init({ ...core, ...plugins, kibanaVersion: this.kibanaVersion }); + return { + casesComponent: TestComponent, + getCreateCase: (props) => { + return getCreateCaseLazy(props); + }, + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts new file mode 100644 index 0000000000000..07a0b2c723914 --- /dev/null +++ b/x-pack/plugins/cases/public/types.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from 'kibana/public'; +import { ReactElement } from 'react'; +import { + TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, + TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, +} from '../../triggers_actions_ui/public'; +import { CreateCaseProps } from './components/create'; + +export interface SetupPlugins { + triggersActionsUi: TriggersActionsSetup; +} + +export interface StartPlugins { + triggersActionsUi: TriggersActionsStart; +} + +export type StartServices = CoreStart & StartPlugins; + +export interface CasesUiStart { + casesComponent: () => JSX.Element; + getCreateCase: (props: CreateCaseProps) => ReactElement<CreateCaseProps>; +} diff --git a/x-pack/plugins/cases/server/client/alerts/update_status.test.ts b/x-pack/plugins/cases/server/client/alerts/update_status.test.ts index 5dfe6060da1db..d6456cb3183ef 100644 --- a/x-pack/plugins/cases/server/client/alerts/update_status.test.ts +++ b/x-pack/plugins/cases/server/client/alerts/update_status.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseStatuses } from '../../../common/api'; +import { CaseStatuses } from '../../../common'; import { createMockSavedObjectsRepository } from '../../routes/api/__fixtures__'; import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts index fe301dcca37ac..9cbe2a448d3b4 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -5,12 +5,7 @@ * 2.0. */ -import { - ConnectorTypes, - CaseStatuses, - CaseType, - CasesClientPostRequest, -} from '../../../common/api'; +import { ConnectorTypes, CaseStatuses, CaseType, CasesClientPostRequest } from '../../../common'; import { isCaseError } from '../../common/error'; import { diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 59f9688836341..1dbb2dc496a99 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -22,7 +22,7 @@ import { CasePostRequest, CaseType, User, -} from '../../../common/api'; +} from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { getConnectorFromConfiguration, diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index fa556986ee8d3..e230e665da865 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -7,7 +7,7 @@ import { SavedObjectsClientContract, Logger } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; -import { CaseResponseRt, CaseResponse } from '../../../common/api'; +import { CaseResponseRt, CaseResponse } from '../../../common'; import { CaseServiceSetup } from '../../services'; import { countAlertsForID } from '../../common'; import { createCaseError } from '../../common/error'; diff --git a/x-pack/plugins/cases/server/client/cases/mock.ts b/x-pack/plugins/cases/server/client/cases/mock.ts index 490519187f49e..0e589b901c8d1 100644 --- a/x-pack/plugins/cases/server/client/cases/mock.ts +++ b/x-pack/plugins/cases/server/client/cases/mock.ts @@ -12,7 +12,7 @@ import { CaseUserActionsResponse, AssociationType, CommentResponseAlertsType, -} from '../../../common/api'; +} from '../../../common'; import { BasicParams } from './types'; diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 3217178768f89..eeaf91b13fa89 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -29,7 +29,7 @@ import { User, ESCasesConfigureAttributes, CaseType, -} from '../../../common/api'; +} from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { createIncident, getCommentContextFromAttributes } from './utils'; diff --git a/x-pack/plugins/cases/server/client/cases/types.ts b/x-pack/plugins/cases/server/client/cases/types.ts index f1d56e7132bd1..fb400675136ef 100644 --- a/x-pack/plugins/cases/server/client/cases/types.ts +++ b/x-pack/plugins/cases/server/client/cases/types.ts @@ -19,7 +19,7 @@ import { PushToServiceApiParamsSIR as ServiceNowSIRPushToServiceApiParams, ServiceNowITSMIncident, } from '../../../../actions/server/builtin_action_types/servicenow/types'; -import { CaseResponse, ConnectorMappingsAttributes } from '../../../common/api'; +import { CaseResponse, ConnectorMappingsAttributes } from '../../../common'; export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident; export type PushToServiceApiParams = diff --git a/x-pack/plugins/cases/server/client/cases/update.test.ts b/x-pack/plugins/cases/server/client/cases/update.test.ts index 79c3b2838c3b2..18b4e8d9d7b66 100644 --- a/x-pack/plugins/cases/server/client/cases/update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/update.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common/api'; +import { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common'; import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index ff3c0a62407a1..6a59bf60a4ece 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -38,7 +38,7 @@ import { AssociationType, CommentAttributes, User, -} from '../../../common/api'; +} from '../../../common'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { getCaseToUpdate, diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index 859114a5e8fb0..c24812048376e 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -539,7 +539,7 @@ describe('utils', () => { commentId: 'comment-user-1', }, { - comment: 'Elastic Security Alerts attached to the case: 3', + comment: 'Elastic Alerts attached to the case: 3', commentId: 'mock-id-1-total-alerts', }, ]); @@ -569,7 +569,7 @@ describe('utils', () => { commentId: 'comment-user-1', }, { - comment: 'Elastic Security Alerts attached to the case: 4', + comment: 'Elastic Alerts attached to the case: 4', commentId: 'mock-id-1-total-alerts', }, ]); diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index 7e77bf4ac84cc..7749bce8042eb 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -20,7 +20,7 @@ import { CommentAttributes, CommentRequestUserType, CommentRequestAlertType, -} from '../../../common/api'; +} from '../../../common'; import { ActionsClient } from '../../../../actions/server'; import { externalServiceFormatters, FormatterConnectorTypes } from '../../connectors'; import { CasesClientGetAlertsResponse } from '../../client/alerts/types'; @@ -184,7 +184,7 @@ export const createIncident = async ({ if (totalAlerts > 0) { comments.push({ - comment: `Elastic Security Alerts attached to the case: ${totalAlerts}`, + comment: `Elastic Alerts attached to the case: ${totalAlerts}`, commentId: `${theCase.id}-total-alerts`, }); } diff --git a/x-pack/plugins/cases/server/client/client.ts b/x-pack/plugins/cases/server/client/client.ts index 8f9058654d6fd..3bd25b6b61bc5 100644 --- a/x-pack/plugins/cases/server/client/client.ts +++ b/x-pack/plugins/cases/server/client/client.ts @@ -31,7 +31,7 @@ import { CaseUserActionServiceSetup, AlertServiceContract, } from '../services'; -import { CasesPatchRequest, CasePostRequest, User } from '../../common/api'; +import { CasesPatchRequest, CasePostRequest, User } from '../../common'; import { get } from './cases/get'; import { get as getUserActions } from './user_actions/get'; import { get as getAlerts } from './alerts/get'; diff --git a/x-pack/plugins/cases/server/client/comments/add.test.ts b/x-pack/plugins/cases/server/client/comments/add.test.ts index 23b7bc37dc814..bd04e0ea6ef14 100644 --- a/x-pack/plugins/cases/server/client/comments/add.test.ts +++ b/x-pack/plugins/cases/server/client/comments/add.test.ts @@ -6,7 +6,7 @@ */ import { omit } from 'lodash/fp'; -import { CommentType } from '../../../common/api'; +import { CommentType } from '../../../common'; import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, diff --git a/x-pack/plugins/cases/server/client/comments/add.ts b/x-pack/plugins/cases/server/client/comments/add.ts index 45746613dc1d4..98b914fb7486b 100644 --- a/x-pack/plugins/cases/server/client/comments/add.ts +++ b/x-pack/plugins/cases/server/client/comments/add.ts @@ -25,7 +25,7 @@ import { User, CommentRequestAlertType, AlertCommentRequestRt, -} from '../../../common/api'; +} from '../../../common'; import { buildCaseUserActionItem, buildCommentUserActionItem, @@ -36,7 +36,7 @@ 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 { MAX_GENERATED_ALERTS_PER_SUB_CASE } from '../../../common'; async function getSubCase({ caseService, diff --git a/x-pack/plugins/cases/server/client/configure/get_fields.test.ts b/x-pack/plugins/cases/server/client/configure/get_fields.test.ts index 2e2973516d0fd..c474361293da4 100644 --- a/x-pack/plugins/cases/server/client/configure/get_fields.test.ts +++ b/x-pack/plugins/cases/server/client/configure/get_fields.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypes } from '../../../common/api'; +import { ConnectorTypes } from '../../../common'; import { createMockSavedObjectsRepository, mockCaseMappings } from '../../routes/api/__fixtures__'; import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; diff --git a/x-pack/plugins/cases/server/client/configure/get_fields.ts b/x-pack/plugins/cases/server/client/configure/get_fields.ts index deabae33810b2..8d899f0df1a76 100644 --- a/x-pack/plugins/cases/server/client/configure/get_fields.ts +++ b/x-pack/plugins/cases/server/client/configure/get_fields.ts @@ -7,7 +7,7 @@ import Boom from '@hapi/boom'; -import { GetFieldsResponse } from '../../../common/api'; +import { GetFieldsResponse } from '../../../common'; import { ConfigureFields } from '../types'; import { createDefaultMapping, formatFields } from './utils'; diff --git a/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts b/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts index 0ec2fc8b4621d..7d9593899bb2e 100644 --- a/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts +++ b/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypes } from '../../../common/api'; +import { ConnectorTypes } from '../../../common'; import { createMockSavedObjectsRepository, mockCaseMappings } from '../../routes/api/__fixtures__'; import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; diff --git a/x-pack/plugins/cases/server/client/configure/get_mappings.ts b/x-pack/plugins/cases/server/client/configure/get_mappings.ts index 558c961f89e5b..1f767ea682843 100644 --- a/x-pack/plugins/cases/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/cases/server/client/configure/get_mappings.ts @@ -7,7 +7,7 @@ import { SavedObjectsClientContract, Logger } from 'src/core/server'; import { ActionsClient } from '../../../../actions/server'; -import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; +import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; import { ConnectorMappingsServiceSetup } from '../../services'; diff --git a/x-pack/plugins/cases/server/client/configure/mock.ts b/x-pack/plugins/cases/server/client/configure/mock.ts index ee214de9b51d4..ad982a5cc1243 100644 --- a/x-pack/plugins/cases/server/client/configure/mock.ts +++ b/x-pack/plugins/cases/server/client/configure/mock.ts @@ -5,11 +5,7 @@ * 2.0. */ -import { - ConnectorField, - ConnectorMappingsAttributes, - ConnectorTypes, -} from '../../../common/api/connectors'; +import { ConnectorField, ConnectorMappingsAttributes, ConnectorTypes } from '../../../common'; import { JiraGetFieldsResponse, ResilientGetFieldsResponse, diff --git a/x-pack/plugins/cases/server/client/configure/utils.test.ts b/x-pack/plugins/cases/server/client/configure/utils.test.ts index 403854693e36c..bf571388994c0 100644 --- a/x-pack/plugins/cases/server/client/configure/utils.test.ts +++ b/x-pack/plugins/cases/server/client/configure/utils.test.ts @@ -11,7 +11,7 @@ export { ServiceNowGetFieldsResponse, } from '../../../../actions/server/types'; import { createDefaultMapping, formatFields } from './utils'; -import { ConnectorTypes } from '../../../common/api/connectors'; +import { ConnectorTypes } from '../../../common'; import { mappings, formatFieldsTestData } from './mock'; describe('client/configure/utils', () => { diff --git a/x-pack/plugins/cases/server/client/configure/utils.ts b/x-pack/plugins/cases/server/client/configure/utils.ts index 80e6c7a3b886c..b9ef813735e25 100644 --- a/x-pack/plugins/cases/server/client/configure/utils.ts +++ b/x-pack/plugins/cases/server/client/configure/utils.ts @@ -5,11 +5,7 @@ * 2.0. */ -import { - ConnectorField, - ConnectorMappingsAttributes, - ConnectorTypes, -} from '../../../common/api/connectors'; +import { ConnectorField, ConnectorMappingsAttributes, ConnectorTypes } from '../../../common'; import { JiraGetFieldsResponse, ResilientGetFieldsResponse, diff --git a/x-pack/plugins/cases/server/client/types.ts b/x-pack/plugins/cases/server/client/types.ts index c62b3913da763..3311b7ac6f921 100644 --- a/x-pack/plugins/cases/server/client/types.ts +++ b/x-pack/plugins/cases/server/client/types.ts @@ -18,7 +18,7 @@ import { GetFieldsResponse, CaseUserActionsResponse, User, -} from '../../common/api'; +} from '../../common'; import { AlertInfo } from '../common'; import { CaseConfigureServiceSetup, diff --git a/x-pack/plugins/cases/server/client/user_actions/get.ts b/x-pack/plugins/cases/server/client/user_actions/get.ts index f6371b8e8b1e7..79b8ef25ab0f6 100644 --- a/x-pack/plugins/cases/server/client/user_actions/get.ts +++ b/x-pack/plugins/cases/server/client/user_actions/get.ts @@ -11,7 +11,7 @@ import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT, } from '../../saved_object_types'; -import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; +import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common'; import { CaseUserActionServiceSetup } from '../../services'; interface GetParams { diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index 1ff5b7beadcaf..3daccf87bdc19 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -27,7 +27,7 @@ import { ESCaseAttributes, SubCaseAttributes, User, -} from '../../../common/api'; +} from '../../../common'; import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; import { flattenCommentSavedObjects, diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 5e6a86358de25..df16fe4f0a67d 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -6,7 +6,7 @@ */ import { SavedObjectsFindResponse } from 'kibana/server'; -import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; +import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common'; import { transformNewComment } from '../routes/api/utils'; import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index dce26f3d5998a..d3bc3850e4210 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -6,13 +6,7 @@ */ import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; -import { - CaseStatuses, - CommentAttributes, - CommentRequest, - CommentType, - User, -} from '../../common/api'; +import { CaseStatuses, CommentAttributes, CommentRequest, CommentType, User } from '../../common'; import { UpdateAlertRequest } from '../client/types'; import { getAlertInfoFromComments } from '../routes/api/utils'; 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 122f6bd77c693..b8c80a101f4c4 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.test.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.test.ts @@ -18,7 +18,7 @@ import { AssociationType, CaseResponse, CasesResponse, -} from '../../../common/api'; +} from '../../../common'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -171,6 +171,34 @@ describe('case connector', () => { }, }, }, + { + test: 'servicenow-sir', + params: { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'servicenow-sir', + name: 'Servicenow SIR', + type: '.servicenow-sir', + fields: { + destIp: true, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + category: 'ddos', + subcategory: '15', + priority: '1', + }, + }, + settings: { + syncAlerts: true, + }, + }, + }, + }, { test: 'none', params: { @@ -474,7 +502,7 @@ describe('case connector', () => { }); }); - it('succeeds when servicenow fields are valid', () => { + it('succeeds when servicenow ITMSM fields are valid', () => { const params: Record<string, unknown> = { subAction: 'update', subActionParams: { @@ -508,6 +536,42 @@ describe('case connector', () => { }); }); + it('succeeds when servicenow SIR fields are valid', () => { + const params: Record<string, unknown> = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'servicenow-sir', + name: 'Servicenow SIR', + type: '.servicenow-sir', + fields: { + destIp: true, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + category: 'ddos', + subcategory: '15', + priority: '1', + }, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual({ + ...params, + subActionParams: { + description: null, + tags: null, + title: null, + status: null, + settings: null, + ...(params.subActionParams as Record<string, unknown>), + }, + }); + }); + it('set fields to null if they are missing', () => { const params: Record<string, unknown> = { subAction: 'update', diff --git a/x-pack/plugins/cases/server/connectors/case/index.ts b/x-pack/plugins/cases/server/connectors/case/index.ts index da993faf0ef5c..d223c70221e37 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.ts @@ -8,12 +8,7 @@ import { curry } from 'lodash'; import { Logger } from 'src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; -import { - CasePatchRequest, - CasePostRequest, - CommentRequest, - CommentType, -} from '../../../common/api'; +import { CasePatchRequest, CasePostRequest, CommentRequest, CommentType } from '../../../common'; import { createExternalCasesClient } from '../../client'; import { CaseExecutorParamsSchema, CaseConfigurationSchema, CommentSchemaType } from './schema'; import { diff --git a/x-pack/plugins/cases/server/connectors/case/schema.ts b/x-pack/plugins/cases/server/connectors/case/schema.ts index ac34ad40cfa13..803b01cbbdc57 100644 --- a/x-pack/plugins/cases/server/connectors/case/schema.ts +++ b/x-pack/plugins/cases/server/connectors/case/schema.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentType } from '../../../common/api'; +import { CommentType } from '../../../common'; import { validateConnector } from './validators'; // Reserved for future implementation @@ -56,7 +56,7 @@ const ResilientFieldsSchema = schema.object({ severityCode: schema.nullable(schema.string()), }); -const ServiceNowFieldsSchema = schema.object({ +const ServiceNowITSMFieldsSchema = schema.object({ impact: schema.nullable(schema.string()), severity: schema.nullable(schema.string()), urgency: schema.nullable(schema.string()), @@ -64,11 +64,22 @@ const ServiceNowFieldsSchema = schema.object({ subcategory: schema.nullable(schema.string()), }); +const ServiceNowSIRFieldsSchema = schema.object({ + destIp: schema.nullable(schema.boolean()), + sourceIp: schema.nullable(schema.boolean()), + malwareHash: schema.nullable(schema.boolean()), + malwareUrl: schema.nullable(schema.boolean()), + priority: schema.nullable(schema.string()), + category: schema.nullable(schema.string()), + subcategory: schema.nullable(schema.string()), +}); + const NoneFieldsSchema = schema.nullable(schema.object({})); const ReducedConnectorFieldsSchema: { [x: string]: any } = { '.jira': JiraFieldsSchema, '.resilient': ResilientFieldsSchema, + '.servicenow-sir': ServiceNowSIRFieldsSchema, }; export const ConnectorProps = { @@ -78,6 +89,7 @@ export const ConnectorProps = { schema.literal('.servicenow'), schema.literal('.jira'), schema.literal('.resilient'), + schema.literal('.servicenow-sir'), schema.literal('.none'), ]), // Chain of conditional schemes @@ -92,7 +104,7 @@ export const ConnectorProps = { schema.conditional( schema.siblingRef('type'), '.servicenow', - ServiceNowFieldsSchema, + ServiceNowITSMFieldsSchema, NoneFieldsSchema ) ), diff --git a/x-pack/plugins/cases/server/connectors/case/types.ts b/x-pack/plugins/cases/server/connectors/case/types.ts index 6a7dfd9c2e687..a71007f0b4946 100644 --- a/x-pack/plugins/cases/server/connectors/case/types.ts +++ b/x-pack/plugins/cases/server/connectors/case/types.ts @@ -16,7 +16,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse } from '../../../common'; export type CaseConfiguration = TypeOf<typeof CaseConfigurationSchema>; export type Connector = TypeOf<typeof ConnectorSchema>; diff --git a/x-pack/plugins/cases/server/connectors/index.ts b/x-pack/plugins/cases/server/connectors/index.ts index a6b6e193361be..ecf04e4f7b0f1 100644 --- a/x-pack/plugins/cases/server/connectors/index.ts +++ b/x-pack/plugins/cases/server/connectors/index.ts @@ -17,7 +17,7 @@ import { serviceNowITSMExternalServiceFormatter } from './servicenow/itsm_format import { serviceNowSIRExternalServiceFormatter } from './servicenow/sir_formatter'; import { jiraExternalServiceFormatter } from './jira/external_service_formatter'; import { resilientExternalServiceFormatter } from './resilient/external_service_formatter'; -import { CommentRequest, CommentType } from '../../common/api'; +import { CommentRequest, CommentType } from '../../common'; export * from './types'; export { transformConnectorComment } from './case'; diff --git a/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.test.ts b/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.test.ts index 0bfaf7cdbd9e3..f5d76aeddf313 100644 --- a/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.test.ts +++ b/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseResponse } from '../../../common/api'; +import { CaseResponse } from '../../../common'; import { jiraExternalServiceFormatter } from './external_service_formatter'; describe('Jira formatter', () => { diff --git a/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.ts b/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.ts index 74376d295fea5..15ee2fd468dda 100644 --- a/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.ts +++ b/x-pack/plugins/cases/server/connectors/jira/external_service_formatter.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { JiraFieldsType, ConnectorJiraTypeFields } from '../../../common/api'; +import { JiraFieldsType, ConnectorJiraTypeFields } from '../../../common'; import { ExternalServiceFormatter } from '../types'; interface ExternalServiceParams extends JiraFieldsType { diff --git a/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.test.ts b/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.test.ts index 01280e9692b5e..b7096179b0fab 100644 --- a/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.test.ts +++ b/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseResponse } from '../../../common/api'; +import { CaseResponse } from '../../../common'; import { resilientExternalServiceFormatter } from './external_service_formatter'; describe('IBM Resilient formatter', () => { diff --git a/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.ts b/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.ts index 76554dce32797..6dea452565d7c 100644 --- a/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.ts +++ b/x-pack/plugins/cases/server/connectors/resilient/external_service_formatter.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ResilientFieldsType, ConnectorResillientTypeFields } from '../../../common/api'; +import { ResilientFieldsType, ConnectorResillientTypeFields } from '../../../common'; import { ExternalServiceFormatter } from '../types'; const format: ExternalServiceFormatter<ResilientFieldsType>['format'] = (theCase) => { diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_formatter.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_formatter.ts index b49eed6a4ad26..a4fa8a198fea7 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_formatter.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_formatter.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../../../common/api'; +import { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../../../common'; import { ExternalServiceFormatter } from '../types'; const format: ExternalServiceFormatter<ServiceNowITSMFieldsType>['format'] = (theCase) => { diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_formmater.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_formmater.test.ts index ea3a4e41e17b8..78242e4c3848a 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_formmater.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_formmater.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseResponse } from '../../../common/api'; +import { CaseResponse } from '../../../common'; import { serviceNowITSMExternalServiceFormatter } from './itsm_formatter'; describe('ITSM formatter', () => { diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.test.ts index 4faca62c6e706..1f7716424cfa9 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseResponse } from '../../../common/api'; +import { CaseResponse } from '../../../common'; import { serviceNowSIRExternalServiceFormatter } from './sir_formatter'; describe('ITSM formatter', () => { diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.ts index d2458e6c7ae53..1c528cd2b47bf 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_formatter.ts @@ -5,7 +5,7 @@ * 2.0. */ import { get } from 'lodash/fp'; -import { ConnectorServiceNowSIRTypeFields } from '../../../common/api'; +import { ConnectorServiceNowSIRTypeFields } from '../../../common'; import { ExternalServiceFormatter } from '../types'; interface ExternalServiceParams { dest_ip: string | null; diff --git a/x-pack/plugins/cases/server/connectors/types.ts b/x-pack/plugins/cases/server/connectors/types.ts index f6c284b74667b..fae1ec2976bc0 100644 --- a/x-pack/plugins/cases/server/connectors/types.ts +++ b/x-pack/plugins/cases/server/connectors/types.ts @@ -13,7 +13,7 @@ import { ActionType, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../actions/server/types'; -import { CaseResponse, ConnectorTypes } from '../../common/api'; +import { CaseResponse, ConnectorTypes } from '../../common'; import { CasesClientGetAlertsResponse } from '../client/alerts/types'; import { CaseServiceSetup, diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 0c661cc18c21b..82e2e0b10e771 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 } from '../common'; import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts index f2318c45e6ed3..c9d7ac4125141 100644 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -17,7 +17,7 @@ import { ConnectorTypes, ESCaseAttributes, ESCasesConfigureAttributes, -} from '../../../../common/api'; +} from '../../../../common'; import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, diff --git a/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts index ae14b44e7dffe..9df94cd0923c9 100644 --- a/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts @@ -10,7 +10,7 @@ import { CasePostRequest, CasesConfigureRequest, ConnectorTypes, -} from '../../../../common/api'; +} from '../../../../common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../actions/server/types'; 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..77db06680fd59 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 @@ -10,8 +10,7 @@ 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 { AssociationType } from '../../../../../common/api'; +import { AssociationType, CASE_COMMENTS_URL } from '../../../../../common'; export function initDeleteAllCommentsApi({ caseService, diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts index dcbcd7b9e246d..d0968c3232459 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts @@ -16,7 +16,7 @@ import { mockCaseComments, } from '../../__fixtures__'; import { initDeleteCommentApi } from './delete_comment'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../../common'; describe('DELETE comment', () => { let routeHandler: RequestHandler<any, any, any>; 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..3ba93142bdcce 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 } from '../../../../../common'; export function initDeleteCommentApi({ caseService, 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..75d0f9f59657a 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 @@ -19,10 +19,10 @@ import { CommentsResponseRt, SavedObjectFindOptionsRt, throwErrors, -} from '../../../../../common/api'; +} from '../../../../../common'; import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL } from '../../../../../common'; import { defaultPage, defaultPerPage } from '../..'; const FindQueryParamsRt = rt.partial({ 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..a400f944dddfa 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 @@ -8,10 +8,10 @@ import { schema } from '@kbn/config-schema'; import { SavedObjectsFindResponse } from 'kibana/server'; -import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common/api'; +import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common'; import { RouteDeps } from '../../types'; import { flattenCommentSavedObjects, wrapError } from '../../utils'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL } from '../../../../../common'; import { defaultSortField } from '../../../../common'; export function initGetAllCommentsApi({ caseService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts index 8ee43eaba8a82..46accdc58d460 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts @@ -17,7 +17,7 @@ import { } from '../../__fixtures__'; import { flattenCommentSavedObject } from '../../utils'; import { initGetCommentApi } from './get_comment'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../../common'; describe('GET comment', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts index 9dedfccd3a250..f86f733306043 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts @@ -7,10 +7,10 @@ import { schema } from '@kbn/config-schema'; -import { CommentResponseRt } from '../../../../../common/api'; +import { CommentResponseRt } from '../../../../../common'; import { RouteDeps } from '../../types'; import { flattenCommentSavedObject, wrapError } from '../../utils'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../../common'; export function initGetCommentApi({ caseService, router, logger }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts index 9cc0575f9bb94..32a0133d455c2 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts @@ -17,8 +17,8 @@ import { mockCases, } from '../../__fixtures__'; import { initPatchCommentApi } from './patch_comment'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -import { CommentType } from '../../../../../common/api'; +import { CASE_COMMENTS_URL } from '../../../../../common'; +import { CommentType } from '../../../../../common'; describe('PATCH comment', () => { let routeHandler: RequestHandler<any, any, any>; 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..b47236f4693cf 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 @@ -14,12 +14,12 @@ import Boom from '@hapi/boom'; import { SavedObjectsClientContract, Logger } from 'kibana/server'; import { CommentableCase } from '../../../../common'; -import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/api'; +import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; 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 } from '../../../../../common'; import { CaseServiceSetup } from '../../../../services'; interface CombinedCaseParams { diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts index 807ec0d089a52..27d5c47d47399 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts @@ -17,8 +17,8 @@ import { mockCaseComments, } from '../../__fixtures__'; import { initPostCommentApi } from './post_comment'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -import { CommentType } from '../../../../../common/api'; +import { CASE_COMMENTS_URL } from '../../../../../common'; +import { CommentType } from '../../../../../common'; describe('POST comment', () => { let routeHandler: RequestHandler<any, any, any>; 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..47d41b60165d7 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 @@ -8,8 +8,8 @@ import { schema } from '@kbn/config-schema'; import { escapeHatch, wrapError } from '../../utils'; import { RouteDeps } from '../../types'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -import { CommentRequest } from '../../../../../common/api'; +import { CASE_COMMENTS_URL } from '../../../../../common'; +import { CommentRequest } from '../../../../../common'; export function initPostCommentApi({ router, logger }: RouteDeps) { router.post( diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts index f328844acfd00..626f53cdf4263 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts @@ -17,9 +17,8 @@ import { } from '../../__fixtures__'; import { initGetCaseConfigure } from './get_configure'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_URL, ConnectorTypes } from '../../../../../common'; import { mappings } from '../../../../client/configure/mock'; -import { ConnectorTypes } from '../../../../../common/api/connectors'; import { CasesClient } from '../../../../client'; describe('GET configuration', () => { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts index c916bd8f4140b..03ac3dd8b13b3 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts @@ -6,10 +6,10 @@ */ import Boom from '@hapi/boom'; -import { CaseConfigureResponseRt, ConnectorMappingsAttributes } from '../../../../../common/api'; +import { CaseConfigureResponseRt, ConnectorMappingsAttributes } from '../../../../../common'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_URL } from '../../../../../common'; import { transformESConnectorToCaseConnector } from '../helpers'; export function initGetCaseConfigure({ caseConfigureService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts index 3fa0fe2f83f79..082adf7b4803f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts @@ -17,7 +17,7 @@ import { } from '../../__fixtures__'; import { initCaseConfigureGetActionConnector } from './get_connectors'; -import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common'; import { getActions } from '../../__mocks__/request_responses'; describe('GET connectors', () => { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts index 81ffc06355ff5..7aec7e4f086b4 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts @@ -12,10 +12,7 @@ import { ActionType } from '../../../../../../actions/common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../../actions/server/types'; -import { - CASE_CONFIGURE_CONNECTORS_URL, - SUPPORTED_CONNECTORS, -} from '../../../../../common/constants'; +import { CASE_CONFIGURE_CONNECTORS_URL, SUPPORTED_CONNECTORS } from '../../../../../common'; const isConnectorSupported = ( action: FindActionResult, diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts index 48d88e0f622f5..c4e2b6af1cd6b 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts @@ -17,8 +17,7 @@ import { import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; import { initPatchCaseConfigure } from './patch_configure'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -import { ConnectorTypes } from '../../../../../common/api/connectors'; +import { CASE_CONFIGURE_URL, ConnectorTypes } from '../../../../../common'; import { CasesClient } from '../../../../client'; describe('PATCH configuration', () => { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts index ba0ea6eb17936..5fe38cf0efe48 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts @@ -15,10 +15,10 @@ import { CaseConfigureResponseRt, throwErrors, ConnectorMappingsAttributes, -} from '../../../../../common/api'; +} from '../../../../../common'; import { RouteDeps } from '../../types'; import { wrapError, escapeHatch } from '../../utils'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_URL } from '../../../../../common'; import { transformCaseConnectorToEsConnector, transformESConnectorToCaseConnector, diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts index 882a10742d733..35b662078fe9c 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts @@ -18,8 +18,7 @@ import { import { initPostCaseConfigure } from './post_configure'; import { newConfiguration } from '../../__mocks__/request_responses'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -import { ConnectorTypes } from '../../../../../common/api/connectors'; +import { CASE_CONFIGURE_URL, ConnectorTypes } from '../../../../../common'; import { CasesClient } from '../../../../client'; describe('POST configuration', () => { diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts index 469151a126898..74ad02f47e178 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts @@ -15,10 +15,10 @@ import { CaseConfigureResponseRt, throwErrors, ConnectorMappingsAttributes, -} from '../../../../../common/api'; +} from '../../../../../common'; import { RouteDeps } from '../../types'; import { wrapError, escapeHatch } from '../../utils'; -import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { CASE_CONFIGURE_URL } from '../../../../../common'; import { transformCaseConnectorToEsConnector, transformESConnectorToCaseConnector, diff --git a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts index a441a027769bf..7748a079ceb4d 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts @@ -17,7 +17,7 @@ import { mockCaseComments, } from '../__fixtures__'; import { initDeleteCasesApi } from './delete_cases'; -import { CASES_URL } from '../../../../common/constants'; +import { CASES_URL } from '../../../../common'; describe('DELETE case', () => { let routeHandler: RequestHandler<any, any, any>; 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..43710dfab93eb 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 } from '../../../../common'; import { CaseServiceSetup } from '../../../services'; async function deleteSubCases({ diff --git a/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts index ca9f731ca5010..75586896390fc 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts @@ -15,7 +15,7 @@ import { mockCases, } from '../__fixtures__'; import { initFindCasesApi } from './find_cases'; -import { CASES_URL } from '../../../../common/constants'; +import { CASES_URL } from '../../../../common'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; describe('FIND all cases', () => { 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..97455e9e08f7b 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 @@ -16,10 +16,10 @@ import { CasesFindRequestRt, throwErrors, caseStatuses, -} from '../../../../common/api'; +} from '../../../../common'; import { transformCases, wrapError, escapeHatch } from '../utils'; import { RouteDeps } from '../types'; -import { CASES_URL } from '../../../../common/constants'; +import { CASES_URL } from '../../../../common'; import { constructQueryOptions } from './helpers'; export function initFindCasesApi({ caseService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts index b9312331b4df2..768bbca62f3fe 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts @@ -8,7 +8,7 @@ import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; -import { ConnectorTypes, ESCaseAttributes } from '../../../../common/api'; +import { ConnectorTypes, ESCaseAttributes } from '../../../../common'; import { createMockSavedObjectsRepository, createRoute, @@ -21,7 +21,7 @@ import { } from '../__fixtures__'; import { flattenCaseSavedObject } from '../utils'; import { initGetCaseApi } from './get_case'; -import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { CASE_DETAILS_URL } from '../../../../common'; describe('GET case', () => { let routeHandler: RequestHandler<any, any, any>; 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..e2d08dcd23f2e 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 @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { CASE_DETAILS_URL } from '../../../../common'; export function initGetCaseApi({ router, logger }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts b/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts index f7cfebeaea749..a1d25aa295799 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts @@ -11,7 +11,7 @@ import { ConnectorTypes, ESCaseConnector, ESCasesConfigureAttributes, -} from '../../../../common/api'; +} from '../../../../common'; import { mockCaseConfigure } from '../__fixtures__'; import { transformCaseConnectorToEsConnector, 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..5f51c9b1f8d8c 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/helpers.ts @@ -11,14 +11,15 @@ import deepEqual from 'fast-deep-equal'; import { SavedObjectsFindResponse } from 'kibana/server'; import { CaseConnector, - ESCaseConnector, - ESCasesConfigureAttributes, - ConnectorTypes, CaseStatuses, CaseType, + ConnectorTypeFields, + ConnectorTypes, + ESCaseConnector, + ESCasesConfigureAttributes, + ESConnectorFields, SavedObjectFindOptions, -} from '../../../../common/api'; -import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; +} from '../../../../common'; 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/patch_cases.test.ts b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts index b3f87211c9547..96a891441ea5f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts @@ -17,7 +17,7 @@ import { } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -import { CaseStatuses } from '../../../../common/api'; +import { CaseStatuses } from '../../../../common'; describe('PATCH cases', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts index 8e779087bcafe..092f88c1a8a20 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts @@ -7,8 +7,8 @@ import { escapeHatch, wrapError } from '../utils'; import { RouteDeps } from '../types'; -import { CASES_URL } from '../../../../common/constants'; -import { CasesPatchRequest } from '../../../../common/api'; +import { CASES_URL } from '../../../../common'; +import { CasesPatchRequest } from '../../../../common'; export function initPatchCasesApi({ router, logger }: RouteDeps) { router.patch( diff --git a/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts index e1669203d3ded..669d3a5e58874 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts @@ -15,9 +15,9 @@ import { mockCases, } from '../__fixtures__'; import { initPostCaseApi } from './post_case'; -import { CASES_URL } from '../../../../common/constants'; +import { CASES_URL } from '../../../../common'; import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; -import { ConnectorTypes, CaseStatuses } from '../../../../common/api'; +import { ConnectorTypes, CaseStatuses } from '../../../../common'; describe('POST cases', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/post_case.ts b/x-pack/plugins/cases/server/routes/api/cases/post_case.ts index e2d71c5837353..a7951a1a71344 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/post_case.ts @@ -8,8 +8,8 @@ import { wrapError, escapeHatch } from '../utils'; import { RouteDeps } from '../types'; -import { CASES_URL } from '../../../../common/constants'; -import { CasePostRequest } from '../../../../common/api'; +import { CASES_URL } from '../../../../common'; +import { CasePostRequest } from '../../../../common'; export function initPostCaseApi({ router, logger }: RouteDeps) { router.post( diff --git a/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts index fb0ba5e3b5d9a..378d092c8be0b 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts @@ -20,7 +20,7 @@ import { } from '../__fixtures__'; import { initPushCaseApi } from './push_case'; import { CasesRequestHandlerContext } from '../../../types'; -import { getCasePushUrl } from '../../../../common/api/helpers'; +import { getCasePushUrl } from '../../../../common'; describe('Push case', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/push_case.ts b/x-pack/plugins/cases/server/routes/api/cases/push_case.ts index 7395758210cf4..9bfb30e0d63ad 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/push_case.ts @@ -12,9 +12,9 @@ import { identity } from 'fp-ts/lib/function'; import { wrapError, escapeHatch } from '../utils'; -import { throwErrors, CasePushRequestParamsRt } from '../../../../common/api'; +import { throwErrors, CasePushRequestParamsRt } from '../../../../common'; import { RouteDeps } from '../types'; -import { CASE_PUSH_URL } from '../../../../common/constants'; +import { CASE_PUSH_URL } from '../../../../common'; export function initPushCaseApi({ router, logger }: RouteDeps) { router.post( diff --git a/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts index e5433f4972239..53fdc298ef267 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { UsersRt } from '../../../../../common/api'; +import { UsersRt } from '../../../../../common'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_REPORTERS_URL } from '../../../../../common/constants'; +import { CASE_REPORTERS_URL } from '../../../../../common'; export function initGetReportersApi({ caseService, router, logger }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts index 1c399a415e470..60ad0c60f944f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts @@ -15,8 +15,8 @@ import { mockCases, } from '../../__fixtures__'; import { initGetCasesStatusApi } from './get_status'; -import { CASE_STATUS_URL } from '../../../../../common/constants'; -import { CaseType } from '../../../../../common/api'; +import { CASE_STATUS_URL } from '../../../../../common'; +import { CaseType } from '../../../../../common'; describe('GET status', () => { let routeHandler: RequestHandler<any, any, any>; diff --git a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts index d0addfff09124..73642fdee0eac 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts @@ -8,8 +8,8 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; -import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { CasesStatusResponseRt, caseStatuses } from '../../../../../common'; +import { CASE_STATUS_URL } from '../../../../../common'; import { constructQueryOptions } from '../helpers'; export function initGetCasesStatusApi({ caseService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts index fd33afbd7df8e..ef60c743ec822 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts @@ -10,7 +10,7 @@ import { schema } from '@kbn/config-schema'; import { buildCaseUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; export function initDeleteSubCasesApi({ 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..81d5517b8ce59 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 @@ -17,10 +17,10 @@ import { SubCasesFindRequestRt, SubCasesFindResponseRt, throwErrors, -} from '../../../../../common/api'; +} from '../../../../../common'; import { RouteDeps } from '../../types'; import { escapeHatch, transformSubCases, wrapError } from '../../utils'; -import { SUB_CASES_URL } from '../../../../../common/constants'; +import { SUB_CASES_URL } from '../../../../../common'; import { constructQueryOptions } from '../helpers'; import { defaultPage, defaultPerPage } from '../..'; diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts index 32dcc924e1a08..b5ebfb4de348b 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts @@ -7,10 +7,10 @@ import { schema } from '@kbn/config-schema'; -import { SubCaseResponseRt } from '../../../../../common/api'; +import { SubCaseResponseRt } from '../../../../../common'; import { RouteDeps } from '../../types'; import { flattenSubCaseSavedObject, wrapError } from '../../utils'; -import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; +import { SUB_CASE_DETAILS_URL } from '../../../../../common'; import { countAlertsForID } from '../../../../common'; export function initGetSubCaseApi({ caseService, router, logger }: RouteDeps) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts index 08836615e1d39..0b142fb5279e5 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -35,8 +35,8 @@ import { SubCasesResponseRt, User, CommentAttributes, -} from '../../../../../common/api'; -import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +} from '../../../../../common'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common'; import { RouteDeps } from '../../types'; import { escapeHatch, diff --git a/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts b/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts index f066aa70ec472..d70d6e0b57ee9 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts @@ -7,7 +7,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_TAGS_URL } from '../../../../../common/constants'; +import { CASE_TAGS_URL } from '../../../../../common'; export function initGetTagsApi({ caseService, router }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts index b5c564648c185..48393b6af34ae 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common/constants'; +import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common'; export function initGetAllCaseUserActionsApi({ router, logger }: RouteDeps) { router.get( diff --git a/x-pack/plugins/cases/server/routes/api/utils.test.ts b/x-pack/plugins/cases/server/routes/api/utils.test.ts index f6bc1e4f71897..2df17e3abacfa 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.test.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.test.ts @@ -30,7 +30,7 @@ import { AssociationType, CaseType, CaseResponse, -} from '../../../common/api'; +} from '../../../common'; describe('Utils', () => { describe('transformNewCase', () => { diff --git a/x-pack/plugins/cases/server/routes/api/utils.ts b/x-pack/plugins/cases/server/routes/api/utils.ts index 8e8862f4157f1..9234472c13f5d 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.ts @@ -41,7 +41,7 @@ import { SubCasesFindResponse, User, AlertCommentRequestRt, -} from '../../../common/api'; +} from '../../../common'; import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase } from './types'; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations.ts b/x-pack/plugins/cases/server/saved_object_types/migrations.ts index bf9694d7e6bb0..8bbc481124870 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations.ts @@ -14,7 +14,7 @@ import { CaseType, AssociationType, ESConnectorFields, -} from '../../common/api'; +} from '../../common'; interface UnsanitizedCaseConnector { connector_id: string; diff --git a/x-pack/plugins/cases/server/scripts/sub_cases/index.ts b/x-pack/plugins/cases/server/scripts/sub_cases/index.ts index ba3bcaa65091c..56f842c10e8f5 100644 --- a/x-pack/plugins/cases/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/cases/server/scripts/sub_cases/index.ts @@ -8,9 +8,7 @@ import yargs from 'yargs'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; -import { CaseResponse, CaseType, ConnectorTypes } from '../../../common/api'; -import { CommentType } from '../../../common/api/cases/comment'; -import { CASES_URL } from '../../../common/constants'; +import { CaseResponse, CaseType, CommentType, ConnectorTypes, CASES_URL } from '../../../common'; import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; import { ContextTypeGeneratedAlertType, createAlertsString } from '../../connectors'; diff --git a/x-pack/plugins/cases/server/services/alerts/index.test.ts b/x-pack/plugins/cases/server/services/alerts/index.test.ts index 042e415b77e43..28c3a6278d544 100644 --- a/x-pack/plugins/cases/server/services/alerts/index.test.ts +++ b/x-pack/plugins/cases/server/services/alerts/index.test.ts @@ -6,7 +6,7 @@ */ import { KibanaRequest } from 'kibana/server'; -import { CaseStatuses } from '../../../common/api'; +import { CaseStatuses } from '../../../common'; import { AlertService, AlertServiceContract } from '.'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; diff --git a/x-pack/plugins/cases/server/services/alerts/index.ts b/x-pack/plugins/cases/server/services/alerts/index.ts index 6ce4db61ab956..876814719442c 100644 --- a/x-pack/plugins/cases/server/services/alerts/index.ts +++ b/x-pack/plugins/cases/server/services/alerts/index.ts @@ -10,7 +10,7 @@ import { isEmpty } from 'lodash'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { ElasticsearchClient, Logger } from 'kibana/server'; -import { MAX_ALERTS_PER_SUB_CASE } from '../../../common/constants'; +import { MAX_ALERTS_PER_SUB_CASE } from '../../../common'; import { UpdateAlertRequest } from '../../client/types'; import { AlertInfo } from '../../common'; import { createCaseError } from '../../common/error'; diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 46dca4d9a0d0e..0ca63bce2d1d0 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -13,7 +13,7 @@ import { SavedObjectsUpdateResponse, } from 'kibana/server'; -import { ESCasesConfigureAttributes, SavedObjectFindOptions } from '../../../common/api'; +import { ESCasesConfigureAttributes, SavedObjectFindOptions } from '../../../common'; import { CASE_CONFIGURE_SAVED_OBJECT } from '../../saved_object_types'; interface ClientArgs { diff --git a/x-pack/plugins/cases/server/services/connector_mappings/index.ts b/x-pack/plugins/cases/server/services/connector_mappings/index.ts index d4fda10276d2b..82f37190b4ecc 100644 --- a/x-pack/plugins/cases/server/services/connector_mappings/index.ts +++ b/x-pack/plugins/cases/server/services/connector_mappings/index.ts @@ -13,7 +13,7 @@ import { SavedObjectsFindResponse, } from 'kibana/server'; -import { ConnectorMappings, SavedObjectFindOptions } from '../../../common/api'; +import { ConnectorMappings, SavedObjectFindOptions } from '../../../common'; import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../saved_object_types'; interface ClientArgs { diff --git a/x-pack/plugins/cases/server/services/index.ts b/x-pack/plugins/cases/server/services/index.ts index 11ceb48d11e9f..18b78300e6632 100644 --- a/x-pack/plugins/cases/server/services/index.ts +++ b/x-pack/plugins/cases/server/services/index.ts @@ -33,7 +33,7 @@ import { CaseResponse, caseTypeField, CasesFindRequest, -} from '../../common/api'; +} from '../../common'; import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; import { defaultPage, defaultPerPage } from '../routes/api'; import { diff --git a/x-pack/plugins/cases/server/services/reporters/read_reporters.ts b/x-pack/plugins/cases/server/services/reporters/read_reporters.ts index d2708780b2ccf..b47fa185ff78e 100644 --- a/x-pack/plugins/cases/server/services/reporters/read_reporters.ts +++ b/x-pack/plugins/cases/server/services/reporters/read_reporters.ts @@ -7,7 +7,7 @@ import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; -import { CaseAttributes, User } from '../../../common/api'; +import { CaseAttributes, User } from '../../../common'; import { CASE_SAVED_OBJECT } from '../../saved_object_types'; export const convertToReporters = (caseObjects: Array<SavedObject<CaseAttributes>>): User[] => diff --git a/x-pack/plugins/cases/server/services/tags/read_tags.ts b/x-pack/plugins/cases/server/services/tags/read_tags.ts index 4c4a948453730..a00b0b6f26fb7 100644 --- a/x-pack/plugins/cases/server/services/tags/read_tags.ts +++ b/x-pack/plugins/cases/server/services/tags/read_tags.ts @@ -7,7 +7,7 @@ import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; -import { CaseAttributes } from '../../../common/api'; +import { CaseAttributes } from '../../../common'; import { CASE_SAVED_OBJECT } from '../../saved_object_types'; export const convertToTags = (tagObjects: Array<SavedObject<CaseAttributes>>): string[] => diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.ts index c600a96234b3d..be32717039d9d 100644 --- a/x-pack/plugins/cases/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/cases/server/services/user_actions/helpers.ts @@ -17,7 +17,7 @@ import { User, UserActionFieldType, SubCaseAttributes, -} from '../../../common/api'; +} from '../../../common'; import { isTwoArraysDifference, transformESConnectorToCaseConnector, diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index 785c81021b584..a038d843a5331 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -12,7 +12,7 @@ import { SavedObjectReference, } from 'kibana/server'; -import { CaseUserActionAttributes } from '../../../common/api'; +import { CaseUserActionAttributes } from '../../../common'; import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engines.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engines.mock.ts new file mode 100644 index 0000000000000..4076af058d6b7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engines.mock.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EngineTypes } from '../components/engine/types'; + +export const defaultEngine = { + id: 'e1', + name: 'engine1', + type: EngineTypes.default, + language: null, + result_fields: {}, +}; + +export const indexedEngine = { + id: 'e2', + name: 'engine2', + type: EngineTypes.indexed, + language: null, + result_fields: {}, +}; + +export const engines = [defaultEngine, indexedEngine]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts index 44416b596e6ef..5f7dc683d93b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts @@ -13,7 +13,7 @@ import { ConfiguredLimits, Account, Role } from './types'; import { getRoleAbilities } from './utils/role'; -interface AppValues { +export interface AppValues { ilmEnabled: boolean; configuredLimits: ConfiguredLimits; account: Account; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/curation_result.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/curation_result.test.tsx index 5c417d308636e..460c0f4dfa44c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/curation_result.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/curation_result.test.tsx @@ -8,6 +8,7 @@ import { setMockValues } from '../../../../../__mocks__'; import React from 'react'; +import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'; import { shallow, ShallowWrapper } from 'enzyme'; @@ -29,12 +30,15 @@ describe('CurationResult', () => { { title: 'add', iconType: 'plus', onClick: () => {} }, { title: 'remove', iconType: 'minus', onClick: () => {} }, ]; + const mockDragging = {} as DraggableProvidedDragHandleProps; // Passed from EuiDraggable let wrapper: ShallowWrapper; beforeAll(() => { setMockValues(values); - wrapper = shallow(<CurationResult result={mockResult} actions={mockActions} />); + wrapper = shallow( + <CurationResult result={mockResult} actions={mockActions} dragHandleProps={mockDragging} /> + ); }); it('passes EngineLogic state', () => { @@ -42,8 +46,9 @@ describe('CurationResult', () => { expect(wrapper.find(Result).prop('schemaForTypeHighlights')).toEqual('some mock schema'); }); - it('passes result and actions props', () => { + it('passes result, actions, and dragHandleProps props', () => { expect(wrapper.find(Result).prop('result')).toEqual(mockResult); expect(wrapper.find(Result).prop('actions')).toEqual(mockActions); + expect(wrapper.find(Result).prop('dragHandleProps')).toEqual(mockDragging); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/curation_result.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/curation_result.tsx index 3be11bcd65956..c737d93ce1823 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/curation_result.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/curation_result.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'; import { useValues } from 'kea'; @@ -18,9 +19,10 @@ import { Result as ResultType, ResultAction } from '../../../result/types'; interface Props { result: ResultType; actions: ResultAction[]; + dragHandleProps?: DraggableProvidedDragHandleProps; } -export const CurationResult: React.FC<Props> = ({ result, actions }) => { +export const CurationResult: React.FC<Props> = ({ result, actions, dragHandleProps }) => { const { isMetaEngine, engine: { schema }, @@ -33,6 +35,7 @@ export const CurationResult: React.FC<Props> = ({ result, actions }) => { actions={actions} isMetaEngine={isMetaEngine} schemaForTypeHighlights={schema} + dragHandleProps={dragHandleProps} /> <EuiSpacer size="m" /> </> 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 3f72199d12805..594584d9ba101 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 @@ -14,6 +14,9 @@ import { EuiTitle, EuiPageContentBody, EuiPageContent, + EuiDragDropContext, + EuiDroppable, + EuiDraggable, } from '@elastic/eui'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; @@ -228,6 +231,28 @@ export const Library: React.FC = () => { <Result {...props} actions={actions} shouldLinkToDetailPage /> <EuiSpacer /> + <EuiSpacer /> + <EuiTitle size="s"> + <h3>With a drag handle</h3> + </EuiTitle> + <EuiSpacer /> + <EuiDragDropContext onDragEnd={() => {}}> + <EuiDroppable spacing="m" droppableId="DraggableResultsTest"> + {[1, 2, 3].map((_, i) => ( + <EuiDraggable + spacing="m" + key={`draggable-${i}`} + index={i} + draggableId={`draggable-${i}`} + customDragHandle + > + {(provided) => <Result {...props} dragHandleProps={provided.dragHandleProps} />} + </EuiDraggable> + ))} + </EuiDroppable> + </EuiDragDropContext> + <EuiSpacer /> + <EuiSpacer /> <EuiTitle size="s"> <h3>With field value type highlights</h3> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss index f69acbdaba150..5f1b165f2c362 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss @@ -1,10 +1,10 @@ .appSearchResult { display: grid; - grid-template-columns: 1fr auto; - grid-template-rows: 1fr auto; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto 1fr auto; grid-template-areas: - 'content actions' - 'toggle actions'; + 'drag content actions' + 'drag toggle actions'; overflow: hidden; // Prevents child background-colors from clipping outside of panel border-radius &__content { @@ -52,6 +52,15 @@ background-color: $euiPageBackgroundColor; } } + + &__dragHandle { + grid-area: drag; + display: flex; + justify-content: center; + align-items: center; + width: $euiSizeXL; + border-right: $euiBorderThin; + } } /** diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx index 86b71229f3785..15c9ee2967d3e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'; import { shallow, ShallowWrapper } from 'enzyme'; @@ -129,6 +130,20 @@ describe('Result', () => { }); }); + describe('dragging', () => { + // In the real world, the drag library sets data attributes, role, tabIndex, etc. + const mockDragHandleProps = ({ + someMockProp: true, + } as unknown) as DraggableProvidedDragHandleProps; + + it('will render a drag handle with the passed props', () => { + const wrapper = shallow(<Result {...props} dragHandleProps={mockDragHandleProps} />); + + expect(wrapper.find('.appSearchResult__dragHandle')).toHaveLength(1); + expect(wrapper.find('.appSearchResult__dragHandle').prop('someMockProp')).toEqual(true); + }); + }); + it('will render field details with type highlights if schemaForTypeHighlights has been provided', () => { const wrapper = shallow( <Result {...props} shouldLinkToDetailPage schemaForTypeHighlights={schema} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx index 2812b596e87fa..89208a041af35 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx @@ -6,6 +6,7 @@ */ import React, { useState, useMemo } from 'react'; +import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'; import classNames from 'classnames'; @@ -31,6 +32,7 @@ interface Props { shouldLinkToDetailPage?: boolean; schemaForTypeHighlights?: Schema; actions?: ResultAction[]; + dragHandleProps?: DraggableProvidedDragHandleProps; } const RESULT_CUTOFF = 5; @@ -42,6 +44,7 @@ export const Result: React.FC<Props> = ({ shouldLinkToDetailPage = false, schemaForTypeHighlights, actions = [], + dragHandleProps, }) => { const [isOpen, setIsOpen] = useState(false); @@ -87,6 +90,11 @@ export const Result: React.FC<Props> = ({ values: { id: result[ID].raw }, })} > + {dragHandleProps && ( + <div {...dragHandleProps} className="appSearchResult__dragHandle"> + <EuiIcon type="grab" /> + </div> + )} {conditionallyLinkedArticle( <> <ResultHeader diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts index 74b8c6e640db1..6232ba0fb4668 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts @@ -11,3 +11,32 @@ export const ROLE_MAPPINGS_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.roleMappings.title', { defaultMessage: 'Role Mappings' } ); + +export const DELETE_ROLE_MAPPING_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.deleteRoleMappingMessage', + { + defaultMessage: + 'Are you sure you want to permanently delete this mapping? This action is not reversible and some users might lose access.', + } +); + +export const ROLE_MAPPING_DELETED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.roleMappingDeletedMessage', + { + defaultMessage: 'Successfully deleted role mapping', + } +); + +export const ROLE_MAPPING_CREATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.roleMappingCreatedMessage', + { + defaultMessage: 'Role mapping successfully created.', + } +); + +export const ROLE_MAPPING_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.roleMappingUpdatedMessage', + { + defaultMessage: 'Role mapping successfully updated.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts new file mode 100644 index 0000000000000..fa51c0036d0db --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts @@ -0,0 +1,455 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues } from '../../../__mocks__'; +import { LogicMounter } from '../../../__mocks__/kea.mock'; + +import { engines } from '../../__mocks__/engines.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { asRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; + +import { RoleMappingsLogic } from './role_mappings_logic'; + +describe('RoleMappingsLogic', () => { + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; + const { mount } = new LogicMounter(RoleMappingsLogic); + const DEFAULT_VALUES = { + attributes: [], + availableAuthProviders: [], + elasticsearchRoles: [], + roleMapping: null, + roleMappings: [], + roleType: 'owner', + attributeValue: '', + attributeName: 'username', + dataLoading: true, + hasAdvancedRoles: false, + multipleAuthProvidersConfig: false, + availableEngines: [], + selectedEngines: new Set(), + accessAllEngines: true, + selectedAuthProviders: [ANY_AUTH_PROVIDER], + }; + + const mappingsServerProps = { multipleAuthProvidersConfig: true, roleMappings: [asRoleMapping] }; + const mappingServerProps = { + attributes: ['email', 'metadata', 'username', 'role'], + authProviders: [ANY_AUTH_PROVIDER], + availableEngines: engines, + elasticsearchRoles: [], + hasAdvancedRoles: false, + multipleAuthProvidersConfig: false, + roleMapping: asRoleMapping, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(RoleMappingsLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('setRoleMappingsData', () => { + it('sets data based on server response from the `mappings` (plural) endpoint', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + + expect(RoleMappingsLogic.values.roleMappings).toEqual([asRoleMapping]); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + expect(RoleMappingsLogic.values.multipleAuthProvidersConfig).toEqual(true); + }); + }); + + describe('setRoleMappingData', () => { + it('sets state based on server response from the `mapping` (singular) endpoint', () => { + RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + roleMapping: asRoleMapping, + dataLoading: false, + attributes: mappingServerProps.attributes, + availableAuthProviders: mappingServerProps.authProviders, + availableEngines: mappingServerProps.availableEngines, + accessAllEngines: true, + attributeName: 'role', + attributeValue: 'superuser', + elasticsearchRoles: mappingServerProps.elasticsearchRoles, + selectedEngines: new Set(engines.map((e) => e.name)), + }); + }); + + it('will remove all selected engines if no roleMapping was returned from the server', () => { + RoleMappingsLogic.actions.setRoleMappingData({ + ...mappingServerProps, + roleMapping: undefined, + }); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: false, + selectedEngines: new Set(), + attributes: mappingServerProps.attributes, + availableAuthProviders: mappingServerProps.authProviders, + availableEngines: mappingServerProps.availableEngines, + }); + }); + }); + + it('handleRoleChange', () => { + RoleMappingsLogic.actions.handleRoleChange('dev'); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + roleType: 'dev', + accessAllEngines: false, + }); + }); + + describe('handleEngineSelectionChange', () => { + const engine = engines[0]; + const otherEngine = engines[1]; + const mountedValues = { + ...mappingServerProps, + roleMapping: { + ...asRoleMapping, + engines: [engine, otherEngine], + }, + selectedEngines: new Set([engine.name]), + }; + + beforeEach(() => { + mount(mountedValues); + }); + + it('handles adding an engine to selected engines', () => { + RoleMappingsLogic.actions.handleEngineSelectionChange(otherEngine.name, true); + + expect(RoleMappingsLogic.values.selectedEngines).toEqual( + new Set([engine.name, otherEngine.name]) + ); + }); + it('handles removing an engine from selected engines', () => { + RoleMappingsLogic.actions.handleEngineSelectionChange(otherEngine.name, false); + + expect(RoleMappingsLogic.values.selectedEngines).toEqual(new Set([engine.name])); + }); + }); + + it('handleAccessAllEnginesChange', () => { + RoleMappingsLogic.actions.handleAccessAllEnginesChange(); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + accessAllEngines: false, + }); + }); + + describe('handleAttributeSelectorChange', () => { + const elasticsearchRoles = ['foo', 'bar']; + + it('sets values correctly', () => { + mount({ + ...mappingServerProps, + elasticsearchRoles, + }); + RoleMappingsLogic.actions.handleAttributeSelectorChange('role', elasticsearchRoles[0]); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + attributeValue: elasticsearchRoles[0], + roleMapping: asRoleMapping, + attributes: mappingServerProps.attributes, + availableEngines: mappingServerProps.availableEngines, + accessAllEngines: true, + attributeName: 'role', + elasticsearchRoles, + selectedEngines: new Set(), + }); + }); + + it('correctly handles "role" fallback', () => { + RoleMappingsLogic.actions.handleAttributeSelectorChange('username', elasticsearchRoles[0]); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + attributeValue: '', + }); + }); + }); + + it('handleAttributeValueChange', () => { + RoleMappingsLogic.actions.handleAttributeValueChange('changed_value'); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + attributeValue: 'changed_value', + }); + }); + + describe('handleAuthProviderChange', () => { + beforeEach(() => { + mount({ + ...mappingServerProps, + roleMapping: { + ...asRoleMapping, + authProvider: ['foo'], + }, + }); + }); + const providers = ['bar', 'baz']; + const providerWithAny = [ANY_AUTH_PROVIDER, providers[1]]; + it('handles empty state', () => { + RoleMappingsLogic.actions.handleAuthProviderChange([]); + + expect(RoleMappingsLogic.values.selectedAuthProviders).toEqual([ANY_AUTH_PROVIDER]); + }); + + it('handles single value', () => { + RoleMappingsLogic.actions.handleAuthProviderChange([providers[0]]); + + expect(RoleMappingsLogic.values.selectedAuthProviders).toEqual([providers[0]]); + }); + + it('handles multiple values', () => { + RoleMappingsLogic.actions.handleAuthProviderChange(providers); + + expect(RoleMappingsLogic.values.selectedAuthProviders).toEqual(providers); + }); + + it('handles "any" auth in previous state', () => { + mount({ + ...mappingServerProps, + roleMapping: { + ...asRoleMapping, + authProvider: [ANY_AUTH_PROVIDER], + }, + }); + RoleMappingsLogic.actions.handleAuthProviderChange(providerWithAny); + + expect(RoleMappingsLogic.values.selectedAuthProviders).toEqual([providers[1]]); + }); + }); + + it('resetState', () => { + mount(mappingsServerProps); + mount(mappingServerProps); + RoleMappingsLogic.actions.resetState(); + + expect(RoleMappingsLogic.values).toEqual(DEFAULT_VALUES); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + }); + + describe('listeners', () => { + describe('initializeRoleMappings', () => { + it('calls API and sets values', async () => { + const setRoleMappingsDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingsData'); + http.get.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.initializeRoleMappings(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/role_mappings'); + await nextTick(); + expect(setRoleMappingsDataSpy).toHaveBeenCalledWith(mappingsServerProps); + }); + + it('handles error', async () => { + http.get.mockReturnValue(Promise.reject('this is an error')); + RoleMappingsLogic.actions.initializeRoleMappings(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('initializeRoleMapping', () => { + it('calls API and sets values for new mapping', async () => { + const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingData'); + http.get.mockReturnValue(Promise.resolve(mappingServerProps)); + RoleMappingsLogic.actions.initializeRoleMapping(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/role_mappings/new'); + await nextTick(); + expect(setRoleMappingDataSpy).toHaveBeenCalledWith(mappingServerProps); + }); + + it('calls API and sets values for existing mapping', async () => { + const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingData'); + http.get.mockReturnValue(Promise.resolve(mappingServerProps)); + RoleMappingsLogic.actions.initializeRoleMapping('123'); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/role_mappings/123'); + await nextTick(); + expect(setRoleMappingDataSpy).toHaveBeenCalledWith(mappingServerProps); + }); + + it('handles error', async () => { + http.get.mockReturnValue(Promise.reject('this is an error')); + RoleMappingsLogic.actions.initializeRoleMapping(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + + it('redirects when there is a 404 status', async () => { + http.get.mockReturnValue(Promise.reject({ status: 404 })); + RoleMappingsLogic.actions.initializeRoleMapping(); + await nextTick(); + + expect(navigateToUrl).toHaveBeenCalled(); + }); + }); + + describe('handleResetMappings', () => { + const callback = jest.fn(); + it('calls API and executes callback', async () => { + http.post.mockReturnValue(Promise.resolve({})); + RoleMappingsLogic.actions.handleResetMappings(callback); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/role_mappings/reset'); + await nextTick(); + expect(callback).toHaveBeenCalled(); + }); + + it('handles error', async () => { + http.post.mockReturnValue(Promise.reject('this is an error')); + RoleMappingsLogic.actions.handleResetMappings(callback); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('handleSaveMapping', () => { + const body = { + roleType: 'owner', + accessAllEngines: true, + authProvider: [ANY_AUTH_PROVIDER], + rules: { + username: '', + }, + engines: [], + }; + + it('calls API and navigates when new mapping', async () => { + mount(mappingsServerProps); + + http.post.mockReturnValue(Promise.resolve(mappingServerProps)); + RoleMappingsLogic.actions.handleSaveMapping(); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/role_mappings', { + body: JSON.stringify(body), + }); + await nextTick(); + + expect(navigateToUrl).toHaveBeenCalled(); + }); + + it('calls API and navigates when existing mapping', async () => { + mount(mappingServerProps); + + http.put.mockReturnValue(Promise.resolve(mappingServerProps)); + RoleMappingsLogic.actions.handleSaveMapping(); + + expect(http.put).toHaveBeenCalledWith(`/api/app_search/role_mappings/${asRoleMapping.id}`, { + body: JSON.stringify(body), + }); + await nextTick(); + + expect(navigateToUrl).toHaveBeenCalled(); + expect(setSuccessMessage).toHaveBeenCalled(); + }); + + it('sends array when "accessAllEngines" is false', () => { + const engine = engines[0]; + + mount({ + ...mappingServerProps, + accessAllEngines: false, + selectedEngines: new Set([engine.name]), + }); + + http.put.mockReturnValue(Promise.resolve(mappingServerProps)); + RoleMappingsLogic.actions.handleSaveMapping(); + + expect(http.put).toHaveBeenCalledWith(`/api/app_search/role_mappings/${asRoleMapping.id}`, { + body: JSON.stringify({ + ...body, + accessAllEngines: false, + engines: [engine.name], + }), + }); + }); + + it('handles error', async () => { + http.post.mockReturnValue(Promise.reject('this is an error')); + RoleMappingsLogic.actions.handleSaveMapping(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('handleDeleteMapping', () => { + let confirmSpy: any; + + beforeEach(() => { + confirmSpy = jest.spyOn(window, 'confirm'); + confirmSpy.mockImplementation(jest.fn(() => true)); + }); + + afterEach(() => { + confirmSpy.mockRestore(); + }); + + it('returns when no mapping', () => { + RoleMappingsLogic.actions.handleDeleteMapping(); + + expect(http.delete).not.toHaveBeenCalled(); + }); + + it('calls API and navigates', async () => { + mount(mappingServerProps); + http.delete.mockReturnValue(Promise.resolve({})); + RoleMappingsLogic.actions.handleDeleteMapping(); + + expect(http.delete).toHaveBeenCalledWith( + `/api/app_search/role_mappings/${asRoleMapping.id}` + ); + await nextTick(); + + expect(navigateToUrl).toHaveBeenCalled(); + expect(setSuccessMessage).toHaveBeenCalled(); + }); + + it('handles error', async () => { + mount(mappingServerProps); + http.delete.mockReturnValue(Promise.reject('this is an error')); + RoleMappingsLogic.actions.handleDeleteMapping(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + + it('will do nothing if not confirmed', () => { + mount(mappingServerProps); + jest.spyOn(window, 'confirm').mockReturnValueOnce(false); + RoleMappingsLogic.actions.handleDeleteMapping(); + + expect(http.delete).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts new file mode 100644 index 0000000000000..f1b81a59779ac --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts @@ -0,0 +1,356 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { + clearFlashMessages, + flashAPIErrors, + setSuccessMessage, +} from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { KibanaLogic } from '../../../shared/kibana'; +import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; +import { AttributeName } from '../../../shared/types'; +import { ROLE_MAPPINGS_PATH } from '../../routes'; +import { ASRoleMapping, RoleTypes } from '../../types'; +import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; +import { Engine } from '../engine/types'; + +import { + DELETE_ROLE_MAPPING_MESSAGE, + ROLE_MAPPING_DELETED_MESSAGE, + ROLE_MAPPING_CREATED_MESSAGE, + ROLE_MAPPING_UPDATED_MESSAGE, +} from './constants'; + +interface RoleMappingsServerDetails { + roleMappings: ASRoleMapping[]; + multipleAuthProvidersConfig: boolean; +} + +interface RoleMappingServerDetails { + attributes: string[]; + authProviders: string[]; + availableEngines: Engine[]; + elasticsearchRoles: string[]; + hasAdvancedRoles: boolean; + multipleAuthProvidersConfig: boolean; + roleMapping?: ASRoleMapping; +} + +const getFirstAttributeName = (roleMapping: ASRoleMapping) => + Object.entries(roleMapping.rules)[0][0] as AttributeName; +const getFirstAttributeValue = (roleMapping: ASRoleMapping) => + Object.entries(roleMapping.rules)[0][1] as AttributeName; + +export interface RoleMappingsActions { + handleAccessAllEnginesChange(): void; + handleAuthProviderChange(value: string[]): { value: string[] }; + handleAttributeSelectorChange( + value: AttributeName, + firstElasticsearchRole: string + ): { value: AttributeName; firstElasticsearchRole: string }; + handleAttributeValueChange(value: string): { value: string }; + handleDeleteMapping(): void; + handleEngineSelectionChange( + engineName: string, + selected: boolean + ): { + engineName: string; + selected: boolean; + }; + handleResetMappings(callback: () => void): Function; + handleRoleChange(roleType: RoleTypes): { roleType: RoleTypes }; + handleSaveMapping(): void; + initializeRoleMapping(roleId?: string): { roleId?: string }; + initializeRoleMappings(): void; + resetState(): void; + setRoleMappingData(data: RoleMappingServerDetails): RoleMappingServerDetails; + setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; +} + +export interface RoleMappingsValues { + accessAllEngines: boolean; + attributeName: AttributeName; + attributeValue: string; + attributes: string[]; + availableAuthProviders: string[]; + availableEngines: Engine[]; + dataLoading: boolean; + elasticsearchRoles: string[]; + hasAdvancedRoles: boolean; + multipleAuthProvidersConfig: boolean; + roleMapping: ASRoleMapping | null; + roleMappings: ASRoleMapping[]; + roleType: RoleTypes; + selectedAuthProviders: string[]; + selectedEngines: Set<string>; +} + +export const RoleMappingsLogic = kea<MakeLogicType<RoleMappingsValues, RoleMappingsActions>>({ + path: ['enterprise_search', 'app_search', 'role_mappings'], + actions: { + setRoleMappingsData: (data: RoleMappingsServerDetails) => data, + setRoleMappingData: (data: RoleMappingServerDetails) => data, + handleAuthProviderChange: (value: string) => ({ value }), + handleRoleChange: (roleType: RoleTypes) => ({ roleType }), + handleEngineSelectionChange: (engineName: string, selected: boolean) => ({ + engineName, + selected, + }), + handleAttributeSelectorChange: (value: string, firstElasticsearchRole: string) => ({ + value, + firstElasticsearchRole, + }), + handleAttributeValueChange: (value: string) => ({ value }), + handleAccessAllEnginesChange: true, + resetState: true, + initializeRoleMappings: true, + initializeRoleMapping: (roleId) => ({ roleId }), + handleDeleteMapping: true, + handleResetMappings: (callback) => callback, + handleSaveMapping: true, + }, + reducers: { + dataLoading: [ + true, + { + setRoleMappingsData: () => false, + setRoleMappingData: () => false, + resetState: () => true, + }, + ], + roleMappings: [ + [], + { + setRoleMappingsData: (_, { roleMappings }) => roleMappings, + resetState: () => [], + }, + ], + multipleAuthProvidersConfig: [ + false, + { + setRoleMappingsData: (_, { multipleAuthProvidersConfig }) => multipleAuthProvidersConfig, + setRoleMappingData: (_, { multipleAuthProvidersConfig }) => multipleAuthProvidersConfig, + resetState: () => false, + }, + ], + hasAdvancedRoles: [ + false, + { + setRoleMappingData: (_, { hasAdvancedRoles }) => hasAdvancedRoles, + }, + ], + availableEngines: [ + [], + { + setRoleMappingData: (_, { availableEngines }) => availableEngines, + resetState: () => [], + }, + ], + attributes: [ + [], + { + setRoleMappingData: (_, { attributes }) => attributes, + resetState: () => [], + }, + ], + elasticsearchRoles: [ + [], + { + setRoleMappingData: (_, { elasticsearchRoles }) => elasticsearchRoles, + }, + ], + roleMapping: [ + null, + { + setRoleMappingData: (_, { roleMapping }) => roleMapping || null, + resetState: () => null, + }, + ], + roleType: [ + 'owner', + { + setRoleMappingData: (_, { roleMapping }) => + roleMapping ? (roleMapping.roleType as RoleTypes) : 'owner', + handleRoleChange: (_, { roleType }) => roleType, + }, + ], + accessAllEngines: [ + true, + { + setRoleMappingData: (_, { roleMapping }) => + roleMapping ? roleMapping.accessAllEngines : true, + handleRoleChange: (_, { roleType }) => !roleHasScopedEngines(roleType), + handleAccessAllEnginesChange: (accessAllEngines) => !accessAllEngines, + }, + ], + attributeValue: [ + '', + { + setRoleMappingData: (_, { roleMapping }) => + roleMapping ? getFirstAttributeValue(roleMapping) : '', + handleAttributeSelectorChange: (_, { value, firstElasticsearchRole }) => + value === 'role' ? firstElasticsearchRole : '', + handleAttributeValueChange: (_, { value }) => value, + resetState: () => '', + }, + ], + attributeName: [ + 'username', + { + setRoleMappingData: (_, { roleMapping }) => + roleMapping ? getFirstAttributeName(roleMapping) : 'username', + handleAttributeSelectorChange: (_, { value }) => value, + resetState: () => 'username', + }, + ], + selectedEngines: [ + new Set(), + { + setRoleMappingData: (_, { roleMapping }) => + roleMapping ? new Set(roleMapping.engines.map((engine) => engine.name)) : new Set(), + handleAccessAllEnginesChange: () => new Set(), + handleEngineSelectionChange: (engines, { engineName, selected }) => { + const newSelectedEngineNames = new Set(engines as Set<string>); + if (selected) { + newSelectedEngineNames.add(engineName); + } else { + newSelectedEngineNames.delete(engineName); + } + + return newSelectedEngineNames; + }, + }, + ], + availableAuthProviders: [ + [], + { + setRoleMappingData: (_, { authProviders }) => authProviders, + }, + ], + selectedAuthProviders: [ + [ANY_AUTH_PROVIDER], + { + handleAuthProviderChange: (previous, { value }) => { + const previouslyContainedAny = previous.includes(ANY_AUTH_PROVIDER); + const newSelectionsContainAny = value.includes(ANY_AUTH_PROVIDER); + const hasItems = value.length > 0; + + if (value.length === 1) return value; + if (!newSelectionsContainAny && hasItems) return value; + if (previouslyContainedAny && hasItems) + return value.filter((v) => v !== ANY_AUTH_PROVIDER); + return [ANY_AUTH_PROVIDER]; + }, + setRoleMappingData: (_, { roleMapping }) => + roleMapping ? roleMapping.authProvider : [ANY_AUTH_PROVIDER], + }, + ], + }, + listeners: ({ actions, values }) => ({ + initializeRoleMappings: async () => { + const { http } = HttpLogic.values; + const route = '/api/app_search/role_mappings'; + + try { + const response = await http.get(route); + actions.setRoleMappingsData(response); + } catch (e) { + flashAPIErrors(e); + } + }, + initializeRoleMapping: async ({ roleId }) => { + const { http } = HttpLogic.values; + const { navigateToUrl } = KibanaLogic.values; + const route = roleId + ? `/api/app_search/role_mappings/${roleId}` + : '/api/app_search/role_mappings/new'; + + try { + const response = await http.get(route); + actions.setRoleMappingData(response); + } catch (e) { + navigateToUrl(ROLE_MAPPINGS_PATH); + flashAPIErrors(e); + } + }, + handleDeleteMapping: async () => { + const { roleMapping } = values; + if (!roleMapping) return; + + const { http } = HttpLogic.values; + const { navigateToUrl } = KibanaLogic.values; + const route = `/api/app_search/role_mappings/${roleMapping.id}`; + + if (window.confirm(DELETE_ROLE_MAPPING_MESSAGE)) { + try { + await http.delete(route); + navigateToUrl(ROLE_MAPPINGS_PATH); + setSuccessMessage(ROLE_MAPPING_DELETED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + } + }, + handleResetMappings: async (callback) => { + const { http } = HttpLogic.values; + try { + await http.post('/api/app_search/role_mappings/reset'); + actions.initializeRoleMappings(); + } catch (e) { + flashAPIErrors(e); + } finally { + callback(); + } + }, + handleSaveMapping: async () => { + const { http } = HttpLogic.values; + const { navigateToUrl } = KibanaLogic.values; + + const { + attributeName, + attributeValue, + roleType, + roleMapping, + accessAllEngines, + selectedEngines, + selectedAuthProviders: authProvider, + } = values; + + const body = JSON.stringify({ + roleType, + accessAllEngines, + authProvider, + rules: { + [attributeName]: attributeValue, + }, + engines: accessAllEngines ? [] : Array.from(selectedEngines), + }); + + const request = !roleMapping + ? http.post('/api/app_search/role_mappings', { body }) + : http.put(`/api/app_search/role_mappings/${roleMapping.id}`, { body }); + + const SUCCESS_MESSAGE = !roleMapping + ? ROLE_MAPPING_CREATED_MESSAGE + : ROLE_MAPPING_UPDATED_MESSAGE; + + try { + await request; + navigateToUrl(ROLE_MAPPINGS_PATH); + setSuccessMessage(SUCCESS_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + resetState: () => { + clearFlashMessages(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts index 1576fa178cfa9..15dec753351ba 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts @@ -5,19 +5,21 @@ * 2.0. */ +import { engines } from '../../../app_search/__mocks__/engines.mock'; + import { AttributeName } from '../../types'; export const asRoleMapping = { - id: null, + id: 'sdgfasdgadf123', attributeName: 'role' as AttributeName, - attributeValue: ['superuser'], + attributeValue: 'superuser', authProvider: ['*'], roleType: 'owner', rules: { role: 'superuser', }, accessAllEngines: true, - engines: [], + engines, toolTip: { content: 'Elasticsearch superusers will always be able to log in as the owner', }, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx index 9c47378302890..5589309d00ef8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -81,7 +81,7 @@ describe('RoleMappingsTable', () => { }); it('handles display when no items present', () => { - const noItemsRoleMapping = { ...asRoleMapping }; + const noItemsRoleMapping = { ...asRoleMapping, engines: [] }; noItemsRoleMapping.accessAllEngines = false; const wrapper = shallow( diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index b0649fb56d9b7..5ea997d217888 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -274,6 +274,7 @@ export interface RegistryElasticsearch { 'index_template.mappings'?: object; } +export type RegistryVarType = 'integer' | 'bool' | 'password' | 'text' | 'yaml' | 'string'; export enum RegistryVarsEntryKeys { name = 'name', title = 'title', @@ -286,7 +287,6 @@ export enum RegistryVarsEntryKeys { os = 'os', } -export type RegistryVarType = 'integer' | 'bool' | 'password' | 'text' | 'yaml' | 'string'; // EPR types this as `[]map[string]interface{}` // which means the official/possible type is Record<string, any> // but we effectively only see this shape diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/validate_package_policy.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/validate_package_policy.ts index 1874a458d8541..e36a4b46039f4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/validate_package_policy.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/validate_package_policy.ts @@ -229,11 +229,7 @@ export const validatePackagePolicyConfig = ( }) ); } - if ( - (varDef.type === 'text' || varDef.type === 'string') && - parsedValue && - Array.isArray(parsedValue) - ) { + if (varDef.type === 'text' && parsedValue && Array.isArray(parsedValue)) { const invalidStrings = parsedValue.filter((cand) => /^[*&]/.test(cand)); // only show one error if multiple strings in array are invalid if (invalidStrings.length > 0) { @@ -247,11 +243,7 @@ export const validatePackagePolicyConfig = ( } } - if ( - (varDef.type === 'text' || varDef.type === 'string') && - parsedValue && - !Array.isArray(parsedValue) - ) { + if (varDef.type === 'text' && parsedValue && !Array.isArray(parsedValue)) { if (/^[*&]/.test(parsedValue)) { errors.push( i18n.translate('xpack.fleet.packagePolicyValidation.quoteStringErrorMessage', { diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 3289a762e57cb..a62da8eb41a99 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -80,8 +80,8 @@ import { import { getAgentStatusById, authenticateAgentWithAccessToken, - listAgents, - getAgent, + getAgentsByKuery, + getAgentById, } from './services/agents'; import { agentCheckinState } from './services/agents/checkin/state'; import { registerFleetUsageCollector } from './collectors/register'; @@ -322,8 +322,8 @@ export class FleetPlugin }, }, agentService: { - getAgent, - listAgents, + getAgent: getAgentById, + listAgents: getAgentsByKuery, getAgentStatusById, authenticateAgentWithAccessToken, }, diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index 3745304334719..e6188a83c49e9 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -44,8 +44,7 @@ export const getAgentHandler: RequestHandler< const esClient = context.core.elasticsearch.client.asCurrentUser; try { - const agent = await AgentService.getAgent(esClient, request.params.agentId); - + const agent = await AgentService.getAgentById(esClient, request.params.agentId); const body: GetOneAgentResponse = { item: { ...agent, @@ -134,8 +133,7 @@ export const updateAgentHandler: RequestHandler< await AgentService.updateAgent(esClient, request.params.agentId, { user_provided_metadata: request.body.user_provided_metadata, }); - const agent = await AgentService.getAgent(esClient, request.params.agentId); - + const agent = await AgentService.getAgentById(esClient, request.params.agentId); const body = { item: { ...agent, @@ -245,7 +243,7 @@ export const getAgentsHandler: RequestHandler< const esClient = context.core.elasticsearch.client.asCurrentUser; try { - const { agents, total, page, perPage } = await AgentService.listAgents(esClient, { + const { agents, total, page, perPage } = await AgentService.getAgentsByKuery(esClient, { page: request.query.page, perPage: request.query.perPage, showInactive: request.query.showInactive, @@ -310,6 +308,7 @@ export const postBulkAgentsReassignHandler: RequestHandler< const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asInternalUser; + try { const results = await AgentService.reassignAgents( soClient, diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index 6236808a5378e..ec75768e816fe 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -125,7 +125,7 @@ export const registerAPIRoutes = (router: IRouter, config: FleetConfigType) => { options: { tags: [`access:${PLUGIN_ID}-all`] }, }, postNewAgentActionHandlerBuilder({ - getAgent: AgentService.getAgent, + getAgent: AgentService.getAgentById, createAgentAction: AgentService.createAgentAction, }) ); diff --git a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts index f3267c95b0181..279018ef4212c 100644 --- a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts @@ -15,7 +15,7 @@ import * as AgentService from '../../services/agents'; import { appContextService } from '../../services'; import { defaultIngestErrorHandler } from '../../errors'; import { isAgentUpgradeable } from '../../../common/services'; -import { getAgent } from '../../services/agents'; +import { getAgentById } from '../../services/agents'; export const postAgentUpgradeHandler: RequestHandler< TypeOf<typeof PostAgentUpgradeRequestSchema.params>, @@ -36,7 +36,7 @@ export const postAgentUpgradeHandler: RequestHandler< }, }); } - const agent = await getAgent(esClient, request.params.agentId); + const agent = await getAgentById(esClient, request.params.agentId); if (agent.unenrollment_started_at || agent.unenrolled_at) { return response.customError({ statusCode: 400, diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts index e68b5ce66c4a9..0d37979ef9acb 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts @@ -11,7 +11,7 @@ import bluebird from 'bluebird'; import { fullAgentPolicyToYaml } from '../../../common/services'; import { appContextService, agentPolicyService, packagePolicyService } from '../../services'; -import { listAgents } from '../../services/agents'; +import { getAgentsByKuery } from '../../services/agents'; import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; import type { GetAgentPoliciesRequestSchema, @@ -58,7 +58,7 @@ export const getAgentPoliciesHandler: RequestHandler< await bluebird.map( items, (agentPolicy: GetAgentPoliciesResponseItem) => - listAgents(esClient, { + getAgentsByKuery(esClient, { showInactive: false, perPage: 0, page: 1, diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 31c184c598b12..2cafe2fe57c01 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -43,7 +43,7 @@ import { } from '../errors'; import { getFullAgentPolicyKibanaConfig } from '../../common/services/full_agent_policy_kibana_config'; -import { createAgentPolicyAction, listAgents } from './agents'; +import { createAgentPolicyAction, getAgentsByKuery } from './agents'; import { packagePolicyService } from './package_policy'; import { outputService } from './output'; import { agentPolicyUpdateEventHandler } from './agent_policy_update'; @@ -520,7 +520,7 @@ class AgentPolicyService { throw new Error('The default agent policy cannot be deleted'); } - const { total } = await listAgents(esClient, { + const { total } = await getAgentsByKuery(esClient, { showInactive: false, perPage: 0, page: 1, 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 37320cb24fe3f..7dc19f63a5adb 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 { getAgent, updateAgent } from '../crud'; +import { getAgentById, updateAgent } from '../crud'; import { toPromiseAbortable, AbortError, createRateLimiter } from './rxjs_utils'; @@ -266,7 +266,7 @@ export function agentCheckinStateNewActionsFactory() { (action) => action.type === 'INTERNAL_POLICY_REASSIGN' ); if (hasConfigReassign) { - return from(getAgent(esClient, agent.id)).pipe( + return from(getAgentById(esClient, agent.id)).pipe( concatMap((refreshedAgent) => { if (!refreshedAgent.policy_id) { throw new Error('Agent does not have a policy assigned'); diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index 06353d831c60f..52a6b98bd0c41 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -6,7 +6,7 @@ */ import Boom from '@hapi/boom'; -import type { SearchResponse } from 'elasticsearch'; +import type { SearchResponse, MGetResponse, GetResponse } from 'elasticsearch'; import type { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; import type { AgentSOAttributes, Agent, ListWithKuery } from '../../types'; @@ -14,7 +14,6 @@ import { appContextService, agentPolicyService } from '../../services'; import type { FleetServerAgent } from '../../../common'; import { isAgentUpgradeable, SO_SEARCH_LIMIT } from '../../../common'; import { AGENT_SAVED_OBJECT_TYPE, AGENTS_INDEX } from '../../constants'; -import type { ESSearchHit } from '../../../../../../typings/elasticsearch'; import { escapeSearchQueryPhrase, normalizeKuery } from '../saved_object'; import type { KueryNode } from '../../../../../../src/plugins/data/server'; import { esKuery } from '../../../../../../src/plugins/data/server'; @@ -59,7 +58,35 @@ export function removeSOAttributes(kuery: string) { return kuery.replace(/attributes\./g, '').replace(/fleet-agents\./g, ''); } -export async function listAgents( +export type GetAgentsOptions = + | { + agentIds: string[]; + } + | { + kuery: string; + showInactive?: boolean; + }; + +export async function getAgents(esClient: ElasticsearchClient, options: GetAgentsOptions) { + let initialResults = []; + + if ('agentIds' in options) { + initialResults = await getAgentsById(esClient, options.agentIds); + } else if ('kuery' in options) { + initialResults = ( + await getAllAgentsByKuery(esClient, { + kuery: options.kuery, + showInactive: options.showInactive ?? false, + }) + ).agents; + } else { + throw new IngestManagerError('Cannot get agents'); + } + + return initialResults; +} + +export async function getAgentsByKuery( esClient: ElasticsearchClient, options: ListWithKuery & { showInactive: boolean; @@ -91,8 +118,7 @@ export async function listAgents( const kueryNode = _joinFilters(filters); const body = kueryNode ? { query: esKuery.toElasticsearchQuery(kueryNode) } : {}; - - const res = await esClient.search({ + const res = await esClient.search<SearchResponse<FleetServerAgent>>({ index: AGENTS_INDEX, from: (page - 1) * perPage, size: perPage, @@ -101,27 +127,24 @@ export async function listAgents( body, }); - let agentResults: Agent[] = res.body.hits.hits.map(searchHitToAgent); - let total = res.body.hits.total.value; - + let agents = res.body.hits.hits.map(searchHitToAgent); // filtering for a range on the version string will not work, // nor does filtering on a flattened field (local_metadata), so filter here if (showUpgradeable) { - agentResults = agentResults.filter((agent) => + agents = agents.filter((agent) => isAgentUpgradeable(agent, appContextService.getKibanaVersion()) ); - total = agentResults.length; } return { - agents: res.body.hits.hits.map(searchHitToAgent), - total, + agents, + total: agents.length, page, perPage, }; } -export async function listAllAgents( +export async function getAllAgentsByKuery( esClient: ElasticsearchClient, options: Omit<ListWithKuery, 'page' | 'perPage'> & { showInactive: boolean; @@ -130,7 +153,7 @@ export async function listAllAgents( agents: Agent[]; total: number; }> { - const res = await listAgents(esClient, { ...options, page: 1, perPage: SO_SEARCH_LIMIT }); + const res = await getAgentsByKuery(esClient, { ...options, page: 1, perPage: SO_SEARCH_LIMIT }); return { agents: res.agents, @@ -161,34 +184,51 @@ export async function countInactiveAgents( return res.body.hits.total.value; } -export async function getAgent(esClient: ElasticsearchClient, agentId: string) { +export async function getAgentById(esClient: ElasticsearchClient, agentId: string) { + const agentNotFoundError = new AgentNotFoundError(`Agent ${agentId} not found`); try { - const agentHit = await esClient.get<ESSearchHit<FleetServerAgent>>({ + const agentHit = await esClient.get<GetResponse<FleetServerAgent>>({ index: AGENTS_INDEX, id: agentId, }); + + if (agentHit.body.found === false) { + throw agentNotFoundError; + } const agent = searchHitToAgent(agentHit.body); return agent; } catch (err) { if (isESClientError(err) && err.meta.statusCode === 404) { - throw new AgentNotFoundError(`Agent ${agentId} not found`); + throw agentNotFoundError; } throw err; } } -export async function getAgents( +async function getAgentDocuments( esClient: ElasticsearchClient, agentIds: string[] -): Promise<Agent[]> { - const body = { docs: agentIds.map((_id) => ({ _id })) }; - - const res = await esClient.mget({ - body, +): Promise<Array<GetResponse<FleetServerAgent>>> { + const res = await esClient.mget<MGetResponse<FleetServerAgent>>({ index: AGENTS_INDEX, + body: { docs: agentIds.map((_id) => ({ _id })) }, }); - const agents = res.body.docs.map(searchHitToAgent); + + return res.body.docs || []; +} + +export async function getAgentsById( + esClient: ElasticsearchClient, + agentIds: string[], + options: { includeMissing?: boolean } = { includeMissing: false } +): Promise<Agent[]> { + 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)); + return agents; } @@ -201,7 +241,7 @@ export async function getAgentByAccessAPIKeyId( q: `access_api_key_id:${escapeSearchQueryPhrase(accessAPIKeyId)}`, }); - const [agent] = res.body.hits.hits.map(searchHitToAgent); + const agent = searchHitToAgent(res.body.hits.hits[0]); if (!agent) { throw new AgentNotFoundError('Agent not found'); @@ -288,7 +328,7 @@ export async function getAgentPolicyForAgent( esClient: ElasticsearchClient, agentId: string ) { - const agent = await getAgent(esClient, agentId); + const agent = await getAgentById(esClient, agentId); if (!agent.policy_id) { return; } diff --git a/x-pack/plugins/fleet/server/services/agents/helpers.ts b/x-pack/plugins/fleet/server/services/agents/helpers.ts index 3fdb347ed246b..bcc065badcd50 100644 --- a/x-pack/plugins/fleet/server/services/agents/helpers.ts +++ b/x-pack/plugins/fleet/server/services/agents/helpers.ts @@ -5,10 +5,15 @@ * 2.0. */ -import type { ESSearchHit } from '../../../../../../typings/elasticsearch'; +import type { GetResponse, SearchResponse } from 'elasticsearch'; + import type { Agent, AgentSOAttributes, FleetServerAgent } from '../../types'; -export function searchHitToAgent(hit: ESSearchHit<FleetServerAgent>): Agent { +type FleetServerAgentESResponse = + | GetResponse<FleetServerAgent> + | SearchResponse<FleetServerAgent>['hits']['hits'][0]; + +export function searchHitToAgent(hit: FleetServerAgentESResponse): Agent { return { id: hit._id, ...hit._source, 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 29e09312dcd16..987f461587233 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts @@ -121,7 +121,7 @@ function createClientsMock() { case unmanagedAgentPolicySO2.id: return unmanagedAgentPolicySO2; default: - throw new Error('Not found'); + throw new Error(`${id} not found`); } }); soClientMock.bulkGet.mockImplementation(async (options) => { @@ -147,7 +147,7 @@ function createClientsMock() { case agentInUnmanagedDoc._id: return { body: agentInUnmanagedDoc }; default: - throw new Error('Not found'); + throw new Error(`${id} not found`); } }); // @ts-expect-error diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index b221be55cd460..74e60c42b9973 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -8,16 +8,12 @@ import type { SavedObjectsClientContract, ElasticsearchClient } from 'kibana/server'; import Boom from '@hapi/boom'; +import type { Agent } from '../../types'; import { agentPolicyService } from '../agent_policy'; import { AgentReassignmentError } from '../../errors'; -import { - getAgents, - getAgentPolicyForAgent, - listAllAgents, - updateAgent, - bulkUpdateAgents, -} from './crud'; +import { getAgents, getAgentPolicyForAgent, updateAgent, bulkUpdateAgents } from './crud'; +import type { GetAgentsOptions } from './index'; import { createAgentAction, bulkCreateAgentActions } from './actions'; export async function reassignAgent( @@ -71,13 +67,7 @@ export async function reassignAgentIsAllowed( export async function reassignAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - options: - | { - agentIds: string[]; - } - | { - kuery: string; - }, + options: { agents: Agent[] } | GetAgentsOptions, newAgentPolicyId: string ): Promise<{ items: Array<{ id: string; success: boolean; error?: Error }> }> { const agentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); @@ -85,25 +75,29 @@ export async function reassignAgents( throw Boom.notFound(`Agent policy not found: ${newAgentPolicyId}`); } - // Filter to agents that do not already use the new agent policy ID - const agents = - 'agentIds' in options - ? await getAgents(esClient, options.agentIds) - : ( - await listAllAgents(esClient, { - kuery: options.kuery, - showInactive: false, - }) - ).agents; - // And which are allowed to unenroll + const allResults = 'agents' in options ? options.agents : await getAgents(esClient, options); + // which are allowed to unenroll const settled = await Promise.allSettled( - agents.map((agent) => + allResults.map((agent) => reassignAgentIsAllowed(soClient, esClient, agent.id, newAgentPolicyId).then((_) => agent) ) ); - const agentsToUpdate = agents.filter( - (agent, index) => settled[index].status === 'fulfilled' && agent.policy_id !== 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 res = await bulkUpdateAgents( esClient, diff --git a/x-pack/plugins/fleet/server/services/agents/status.ts b/x-pack/plugins/fleet/server/services/agents/status.ts index 930f3ca22ccb1..f3fb01655974e 100644 --- a/x-pack/plugins/fleet/server/services/agents/status.ts +++ b/x-pack/plugins/fleet/server/services/agents/status.ts @@ -14,13 +14,13 @@ import { AgentStatusKueryHelper } from '../../../common/services'; import { esKuery } from '../../../../../../src/plugins/data/server'; import type { KueryNode } from '../../../../../../src/plugins/data/server'; -import { getAgent, listAgents, removeSOAttributes } from './crud'; +import { getAgentById, getAgentsByKuery, removeSOAttributes } from './crud'; export async function getAgentStatusById( esClient: ElasticsearchClient, agentId: string ): Promise<AgentStatus> { - const agent = await getAgent(esClient, agentId); + const agent = await getAgentById(esClient, agentId); return AgentStatusKueryHelper.getAgentStatus(agent); } @@ -64,7 +64,7 @@ export async function getAgentStatusForAgentPolicy( AgentStatusKueryHelper.buildKueryForUpdatingAgents(), ], (kuery) => - listAgents(esClient, { + getAgentsByKuery(esClient, { showInactive: false, perPage: 0, page: 1, diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 96ac11c89f687..14f9aa46e9fa6 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -11,12 +11,12 @@ import * as APIKeyService from '../api_keys'; import { AgentUnenrollmentError } from '../../errors'; import { createAgentAction, bulkCreateAgentActions } from './actions'; +import type { GetAgentsOptions } from './crud'; import { - getAgent, + getAgentById, + getAgents, updateAgent, getAgentPolicyForAgent, - getAgents, - listAllAgents, bulkUpdateAgents, } from './crud'; @@ -56,23 +56,9 @@ export async function unenrollAgent( export async function unenrollAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - options: - | { - agentIds: string[]; - } - | { - kuery: string; - } + options: GetAgentsOptions ) { - const agents = - 'agentIds' in options - ? await getAgents(esClient, options.agentIds) - : ( - await listAllAgents(esClient, { - kuery: options.kuery, - showInactive: false, - }) - ).agents; + const agents = await getAgents(esClient, options); // Filter to agents that are not already unenrolled, or unenrolling const agentsEnrolled = agents.filter( @@ -116,7 +102,7 @@ export async function forceUnenrollAgent( esClient: ElasticsearchClient, agentId: string ) { - const agent = await getAgent(esClient, agentId); + const agent = await getAgentById(esClient, agentId); await Promise.all([ agent.access_api_key_id @@ -136,24 +122,10 @@ export async function forceUnenrollAgent( export async function forceUnenrollAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - options: - | { - agentIds: string[]; - } - | { - kuery: string; - } + options: GetAgentsOptions ) { // Filter to agents that are not already unenrolled - const agents = - 'agentIds' in options - ? await getAgents(esClient, options.agentIds) - : ( - await listAllAgents(esClient, { - kuery: options.kuery, - showInactive: false, - }) - ).agents; + const agents = await getAgents(esClient, options); const agentsToUpdate = agents.filter((agent) => !agent.unenrolled_at); const now = new Date().toISOString(); const apiKeys: string[] = []; diff --git a/x-pack/plugins/fleet/server/services/agents/update.ts b/x-pack/plugins/fleet/server/services/agents/update.ts index 28f1788f3f9b8..74386efe65613 100644 --- a/x-pack/plugins/fleet/server/services/agents/update.ts +++ b/x-pack/plugins/fleet/server/services/agents/update.ts @@ -9,7 +9,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/s import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; -import { listAgents } from './crud'; +import { getAgentsByKuery } from './crud'; import { unenrollAgent } from './unenroll'; export async function unenrollForAgentPolicyId( @@ -20,7 +20,7 @@ export async function unenrollForAgentPolicyId( let hasMore = true; let page = 1; while (hasMore) { - const { agents } = await listAgents(esClient, { + const { agents } = await getAgentsByKuery(esClient, { kuery: `${AGENT_SAVED_OBJECT_TYPE}.policy_id:"${policyId}"`, page: page++, perPage: 1000, diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index c45b161a79366..12623be0ed044 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -15,13 +15,8 @@ import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; import { bulkCreateAgentActions, createAgentAction } from './actions'; -import { - getAgents, - listAllAgents, - updateAgent, - bulkUpdateAgents, - getAgentPolicyForAgent, -} from './crud'; +import type { GetAgentsOptions } from './crud'; +import { getAgents, updateAgent, bulkUpdateAgents, getAgentPolicyForAgent } from './crud'; export async function sendUpgradeAgentAction({ soClient, @@ -82,31 +77,15 @@ export async function ackAgentUpgraded( export async function sendUpgradeAgentsActions( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - options: - | { - agentIds: string[]; - sourceUri: string | undefined; - version: string; - force?: boolean; - } - | { - kuery: string; - sourceUri: string | undefined; - version: string; - force?: boolean; - } + options: GetAgentsOptions & { + sourceUri: string | undefined; + version: string; + force?: boolean; + } ) { const kibanaVersion = appContextService.getKibanaVersion(); // Filter out agents currently unenrolling, agents unenrolled, and agents not upgradeable - const agents = - 'agentIds' in options - ? await getAgents(esClient, options.agentIds) - : ( - await listAllAgents(esClient, { - kuery: options.kuery, - showInactive: false, - }) - ).agents; + const agents = await getAgents(esClient, options); // upgradeable if they pass the version check const upgradeableAgents = options.force diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts index a76a8b9672d21..dde9f1733dfe3 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts @@ -193,14 +193,14 @@ my-package: {{{ search }}} | streamstats`; const vars = { - asteriskOnly: { value: '"*"', type: 'string' }, - startsWithAsterisk: { value: '"*lala"', type: 'string' }, - numeric: { value: '100', type: 'string' }, - mixed: { value: '1s', type: 'string' }, - a: { value: '/opt/package/*', type: 'string' }, - b: { value: '/logs/my.log*', type: 'string' }, - c: { value: '/opt/*/package/', type: 'string' }, - d: { value: 'logs/*my.log', type: 'string' }, + asteriskOnly: { value: '"*"', type: 'text' }, + startsWithAsterisk: { value: '"*lala"', type: 'text' }, + numeric: { value: '100', type: 'text' }, + mixed: { value: '1s', type: 'text' }, + a: { value: '/opt/package/*', type: 'text' }, + b: { value: '/logs/my.log*', type: 'text' }, + c: { value: '/opt/*/package/', type: 'text' }, + d: { value: 'logs/*my.log', type: 'text' }, search: { value: 'search sourcetype="access*"', type: 'text' }, }; diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index 2783ab36cda44..e9a8024a032e5 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -10,7 +10,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/ser import type { AgentStatus, Agent, EsAssetReference } from '../types'; -import type { getAgent, listAgents } from './agents'; +import type { getAgentById, getAgentsByKuery } from './agents'; import type { agentPolicyService } from './agent_policy'; import * as settingsService from './settings'; @@ -46,7 +46,7 @@ export interface AgentService { /** * Get an Agent by id */ - getAgent: typeof getAgent; + getAgent: typeof getAgentById; /** * Authenticate an agent with access toekn */ @@ -61,7 +61,7 @@ export interface AgentService { /** * List agents */ - listAgents: typeof listAgents; + listAgents: typeof getAgentsByKuery; } export interface AgentPolicyServiceInterface { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts index 44e03564cb89a..a570c817cfe1b 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts @@ -67,6 +67,46 @@ describe('<EditPolicy /> searchable snapshots', () => { expect(actions.hot.searchableSnapshotsExists()).toBeTruthy(); }); + test('should set the repository from previously defined repository', async () => { + const { actions } = testBed; + + const repository = 'myRepo'; + await actions.hot.setSearchableSnapshot(repository); + await actions.cold.enable(true); + await actions.cold.toggleSearchableSnapshot(true); + await actions.frozen.enable(true); + + await actions.savePolicy(); + const latestRequest = server.requests[server.requests.length - 1]; + expect(latestRequest.method).toBe('POST'); + expect(latestRequest.url).toBe('/api/index_lifecycle_management/policies'); + const reqBody = JSON.parse(JSON.parse(latestRequest.requestBody).body); + + expect(reqBody.phases.hot.actions.searchable_snapshot.snapshot_repository).toBe(repository); + expect(reqBody.phases.cold.actions.searchable_snapshot.snapshot_repository).toBe(repository); + expect(reqBody.phases.frozen.actions.searchable_snapshot.snapshot_repository).toBe(repository); + }); + + test('should update the repository in all searchable snapshot actions', async () => { + const { actions } = testBed; + + await actions.hot.setSearchableSnapshot('myRepo'); + await actions.cold.enable(true); + await actions.cold.toggleSearchableSnapshot(true); + await actions.frozen.enable(true); + + // We update the repository in one phase + await actions.frozen.setSearchableSnapshot('changed'); + await actions.savePolicy(); + const latestRequest = server.requests[server.requests.length - 1]; + const reqBody = JSON.parse(JSON.parse(latestRequest.requestBody).body); + + // And all phases should be updated + expect(reqBody.phases.hot.actions.searchable_snapshot.snapshot_repository).toBe('changed'); + expect(reqBody.phases.cold.actions.searchable_snapshot.snapshot_repository).toBe('changed'); + expect(reqBody.phases.frozen.actions.searchable_snapshot.snapshot_repository).toBe('changed'); + }); + describe('on cloud', () => { describe('new policy', () => { beforeEach(async () => { @@ -86,6 +126,7 @@ describe('<EditPolicy /> searchable snapshots', () => { const { component } = testBed; component.update(); }); + test('defaults searchable snapshot to true on cloud', async () => { const { find, actions } = testBed; await actions.cold.enable(true); @@ -112,14 +153,17 @@ describe('<EditPolicy /> searchable snapshots', () => { const { component } = testBed; component.update(); }); + test('correctly sets snapshot repository default to "found-snapshots"', async () => { const { actions } = testBed; await actions.cold.enable(true); await actions.cold.toggleSearchableSnapshot(true); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; - const request = JSON.parse(JSON.parse(latestRequest.requestBody).body); - expect(request.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( + expect(latestRequest.method).toBe('POST'); + expect(latestRequest.url).toBe('/api/index_lifecycle_management/policies'); + const reqBody = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(reqBody.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( 'found-snapshots' ); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index bc22516e6c996..72651778f403e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -8,7 +8,7 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; -import { useConfigurationIssues } from '../../../form'; +import { useConfiguration } from '../../../form'; import { DataTierAllocationField, SearchableSnapshotField, @@ -29,7 +29,7 @@ const i18nTexts = { }; export const ColdPhase: FunctionComponent = () => { - const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); + const { isUsingSearchableSnapshotInHotPhase } = useConfiguration(); return ( <Phase phase="cold" topLevelSettings={<SearchableSnapshotField phase="cold" />}> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx index 6c96178c86b5b..7b613757fa474 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx @@ -20,18 +20,16 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { useFormData } from '../../../../../../shared_imports'; - import { i18nTexts } from '../../../i18n_texts'; - -import { usePhaseTimings } from '../../../form'; - -import { MinAgeField, SnapshotPoliciesField } from '../shared_fields'; -import './delete_phase.scss'; +import { usePhaseTimings, globalFields } from '../../../form'; import { PhaseIcon } from '../../phase_icon'; +import { MinAgeField, SnapshotPoliciesField } from '../shared_fields'; import { PhaseErrorIndicator } from '../phase/phase_error_indicator'; +import './delete_phase.scss'; + const formFieldPaths = { - enabled: '_meta.delete.enabled', + enabled: globalFields.deleteEnabled.path, }; export const DeletePhase: FunctionComponent = () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index 6d4e2750bb2e8..ea345009b230b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -23,7 +23,7 @@ import { useFormData, SelectField, NumericField } from '../../../../../../shared import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION, useConfigurationIssues, UseField } from '../../../form'; +import { ROLLOVER_EMPTY_VALIDATION, useConfiguration, UseField } from '../../../form'; import { useEditPolicyContext } from '../../../edit_policy_context'; @@ -47,7 +47,7 @@ export const HotPhase: FunctionComponent = () => { const [formData] = useFormData({ watch: isUsingDefaultRolloverPath, }); - const { isUsingRollover } = useConfigurationIssues(); + const { isUsingRollover } = useConfiguration(); const isUsingDefaultRollover: boolean = get(formData, isUsingDefaultRolloverPath); const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx index 04b756dc23559..3fe2f08cb4066 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx @@ -22,7 +22,7 @@ import { import { getFieldValidityAndErrorMessage } from '../../../../../../../shared_imports'; -import { UseField, useConfigurationIssues } from '../../../../form'; +import { UseField, useConfiguration } from '../../../../form'; import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; @@ -81,7 +81,7 @@ interface Props { } export const MinAgeField: FunctionComponent<Props> = ({ phase }): React.ReactElement => { - const { isUsingRollover } = useConfigurationIssues(); + const { isUsingRollover } = useConfiguration(); return ( <UseField path={`phases.${phase}.min_age`}> {(field) => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/repository_combobox_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/repository_combobox_field.tsx new file mode 100644 index 0000000000000..a5a9d8d492682 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/repository_combobox_field.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, { useEffect, useRef } from 'react'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; + +import { ComboBoxField, FieldHook } from '../../../../../../../shared_imports'; +import { useGlobalFields } from '../../../../form'; + +interface PropsRepositoryCombobox { + field: FieldHook; + isLoading: boolean; + repos: string[]; + noSuggestions: boolean; + globalRepository: string; +} + +export const RepositoryComboBoxField = ({ + field, + isLoading, + repos, + noSuggestions, + globalRepository, +}: PropsRepositoryCombobox) => { + const isMounted = useRef(false); + const { setValue } = field; + const { + searchableSnapshotRepo: { setValue: setSearchableSnapshotRepository }, + } = useGlobalFields(); + + useEffect(() => { + // We keep our phase searchable action field in sync + // with the default repository field declared globally for the policy + if (isMounted.current) { + setValue(Boolean(globalRepository.trim()) ? [globalRepository] : []); + } + isMounted.current = true; + }, [setValue, globalRepository]); + + return ( + <ComboBoxField + field={field} + fullWidth={false} + euiFieldProps={{ + 'data-test-subj': 'searchableSnapshotCombobox', + options: repos.map((repo) => ({ label: repo, value: repo })), + singleSelection: { asPlainText: true }, + isLoading, + noSuggestions, + onCreateOption: (newOption: string) => { + setSearchableSnapshotRepository(newOption); + }, + onChange: (options: EuiComboBoxOptionOption[]) => { + if (options.length > 0) { + setSearchableSnapshotRepository(options[0].label); + } else { + setSearchableSnapshotRepository(''); + } + }, + }} + /> + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx index 816e1aaec31d7..4cef7615a2d8d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx @@ -5,24 +5,18 @@ * 2.0. */ +import React, { FunctionComponent, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; -import React, { FunctionComponent, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiComboBoxOptionOption, - EuiTextColor, - EuiSpacer, - EuiCallOut, - EuiLink, -} from '@elastic/eui'; - -import { ComboBoxField, useKibana, useFormData } from '../../../../../../../shared_imports'; +import { EuiTextColor, EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; +import { useKibana, useFormData } from '../../../../../../../shared_imports'; import { useEditPolicyContext } from '../../../../edit_policy_context'; -import { useConfigurationIssues, UseField, searchableSnapshotFields } from '../../../../form'; +import { useConfiguration, UseField, globalFields } from '../../../../form'; import { FieldLoadingError, DescribedFormRow, LearnMoreLink } from '../../../'; import { SearchableSnapshotDataProvider } from './searchable_snapshot_data_provider'; +import { RepositoryComboBoxField } from './repository_combobox_field'; import './_searchable_snapshot_field.scss'; @@ -31,12 +25,6 @@ export interface Props { canBeDisabled?: boolean; } -/** - * This repository is provisioned by Elastic Cloud and will always - * exist as a "managed" repository. - */ -const CLOUD_DEFAULT_REPO = 'found-snapshots'; - const geti18nTexts = (phase: Props['phase']) => ({ title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotFieldTitle', { defaultMessage: 'Searchable snapshot', @@ -71,13 +59,15 @@ export const SearchableSnapshotField: FunctionComponent<Props> = ({ services: { cloud }, } = useKibana(); const { getUrlForApp, policy, license, isNewPolicy } = useEditPolicyContext(); - const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); + const { isUsingSearchableSnapshotInHotPhase } = useConfiguration(); const searchableSnapshotRepoPath = `phases.${phase}.actions.searchable_snapshot.snapshot_repository`; - const [formData] = useFormData({ watch: searchableSnapshotRepoPath }); - const searchableSnapshotRepo = get(formData, searchableSnapshotRepoPath); + const [formData] = useFormData({ + watch: globalFields.searchableSnapshotRepo.path, + }); + const searchableSnapshotGlobalRepo = get(formData, globalFields.searchableSnapshotRepo.path); const isColdPhase = phase === 'cold'; const isFrozenPhase = phase === 'frozen'; const isColdOrFrozenPhase = isColdPhase || isFrozenPhase; @@ -164,7 +154,10 @@ export const SearchableSnapshotField: FunctionComponent<Props> = ({ /> </EuiCallOut> ); - } else if (searchableSnapshotRepo && !repos.includes(searchableSnapshotRepo)) { + } else if ( + searchableSnapshotGlobalRepo && + !repos.includes(searchableSnapshotGlobalRepo) + ) { calloutContent = ( <EuiCallOut title={i18n.translate( @@ -201,49 +194,17 @@ export const SearchableSnapshotField: FunctionComponent<Props> = ({ return ( <div className="ilmSearchableSnapshotField"> - <UseField<string> - config={{ - ...searchableSnapshotFields.snapshot_repository, - defaultValue: cloud?.isCloudEnabled ? CLOUD_DEFAULT_REPO : undefined, - }} + <UseField path={searchableSnapshotRepoPath} - > - {(field) => { - const singleSelectionArray: [selectedSnapshot?: string] = field.value - ? [field.value] - : []; - - return ( - <ComboBoxField - field={ - { - ...field, - value: singleSelectionArray, - } as any - } - label={field.label} - fullWidth={false} - euiFieldProps={{ - 'data-test-subj': 'searchableSnapshotCombobox', - options: repos.map((repo) => ({ label: repo, value: repo })), - singleSelection: { asPlainText: true }, - isLoading, - noSuggestions: !!(error || repos.length === 0), - onCreateOption: (newOption: string) => { - field.setValue(newOption); - }, - onChange: (options: EuiComboBoxOptionOption[]) => { - if (options.length > 0) { - field.setValue(options[0].label); - } else { - field.setValue(''); - } - }, - }} - /> - ); + defaultValue={!!searchableSnapshotGlobalRepo ? [searchableSnapshotGlobalRepo] : []} + component={RepositoryComboBoxField} + componentProps={{ + globalRepository: searchableSnapshotGlobalRepo, + isLoading, + repos, + noSuggestions: !!(error || repos.length === 0), }} - </UseField> + /> {calloutContent && ( <> <EuiSpacer size="s" /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index 577dab6804147..d082489c4b918 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -8,7 +8,7 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; -import { useConfigurationIssues } from '../../../form'; +import { useConfiguration } from '../../../form'; import { ForcemergeField, @@ -30,7 +30,7 @@ const i18nTexts = { }; export const WarmPhase: FunctionComponent = () => { - const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); + const { isUsingSearchableSnapshotInHotPhase } = useConfiguration(); return ( <Phase phase="warm"> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx index 88d9d2de03d89..d5cbb267c77c3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx @@ -11,7 +11,7 @@ import { useFormData } from '../../../../../shared_imports'; import { formDataToAbsoluteTimings } from '../../lib'; -import { useConfigurationIssues } from '../../form'; +import { useConfiguration } from '../../form'; import { FormInternal } from '../../types'; @@ -20,7 +20,7 @@ import { Timeline as ViewComponent } from './timeline'; export const Timeline: FunctionComponent = () => { const [formData] = useFormData<FormInternal>(); const timings = formDataToAbsoluteTimings(formData); - const { isUsingRollover } = useConfigurationIssues(); + const { isUsingRollover } = useConfiguration(); return ( <ViewComponent hotPhaseMinAge={timings.hot.min_age} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/constants.ts index c4cbc0f0baadf..817ff9baf806d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/constants.ts @@ -18,3 +18,9 @@ export const ROLLOVER_FORM_PATHS = { maxAge: 'phases.hot.actions.rollover.max_age', maxSize: 'phases.hot.actions.rollover.max_size', }; + +/** + * This repository is provisioned by Elastic Cloud and will always + * exist as a "managed" repository. + */ +export const CLOUD_DEFAULT_REPO = 'found-snapshots'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index ed165e8638843..d7368249d76e8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -30,15 +30,11 @@ import { EuiTitle, } from '@elastic/eui'; -import { TextField, useForm, useFormData } from '../../../shared_imports'; - +import { TextField, useForm, useFormData, useKibana } from '../../../shared_imports'; import { toasts } from '../../services/notification'; import { createDocLink } from '../../services/documentation'; - import { UseField } from './form'; - import { savePolicy } from './save_policy'; - import { ColdPhase, DeletePhase, @@ -49,11 +45,14 @@ import { Timeline, FormErrorsCallout, } from './components'; - -import { createPolicyNameValidations, createSerializer, deserializer, Form, schema } from './form'; - +import { + createPolicyNameValidations, + createSerializer, + createDeserializer, + Form, + getSchema, +} from './form'; import { useEditPolicyContext } from './edit_policy_context'; - import { FormInternal } from './types'; export interface Props { @@ -76,20 +75,38 @@ export const EditPolicy: React.FunctionComponent<Props> = ({ history }) => { license, } = useEditPolicyContext(); - const serializer = useMemo(() => { - return createSerializer(isNewPolicy ? undefined : currentPolicy); - }, [isNewPolicy, currentPolicy]); + const { + services: { cloud }, + } = useKibana(); const [saveAsNew, setSaveAsNew] = useState(false); const originalPolicyName: string = isNewPolicy ? '' : policyName!; const isAllowedByLicense = license.canUseSearchableSnapshot(); + const isCloudEnabled = Boolean(cloud?.isCloudEnabled); - const { form } = useForm({ - schema, - defaultValue: { + const serializer = useMemo(() => { + return createSerializer(isNewPolicy ? undefined : currentPolicy); + }, [isNewPolicy, currentPolicy]); + + const deserializer = useMemo(() => { + return createDeserializer(isCloudEnabled); + }, [isCloudEnabled]); + + const defaultValue = useMemo( + () => ({ ...currentPolicy, name: originalPolicyName, - }, + }), + [currentPolicy, originalPolicyName] + ); + + const schema = useMemo(() => { + return getSchema(isCloudEnabled); + }, [isCloudEnabled]); + + const { form } = useForm({ + schema, + defaultValue, deserializer, serializer, }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx index be8243cab289f..5d1add85bf9f4 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx @@ -9,20 +9,25 @@ import React, { FunctionComponent } from 'react'; import { Form as LibForm, FormHook } from '../../../../../shared_imports'; -import { ConfigurationIssuesProvider } from '../configuration_issues_context'; +import { ConfigurationProvider } from '../configuration_context'; import { FormErrorsProvider } from '../form_errors_context'; import { PhaseTimingsProvider } from '../phase_timings_context'; +import { GlobalFieldsProvider } from '../global_fields_context'; interface Props { form: FormHook; } -export const Form: FunctionComponent<Props> = ({ form, children }) => ( - <LibForm form={form}> - <ConfigurationIssuesProvider> - <FormErrorsProvider> - <PhaseTimingsProvider>{children}</PhaseTimingsProvider> - </FormErrorsProvider> - </ConfigurationIssuesProvider> - </LibForm> -); +export const Form: FunctionComponent<Props> = ({ form, children }) => { + return ( + <LibForm form={form}> + <ConfigurationProvider> + <FormErrorsProvider> + <GlobalFieldsProvider> + <PhaseTimingsProvider>{children}</PhaseTimingsProvider> + </GlobalFieldsProvider> + </FormErrorsProvider> + </ConfigurationProvider> + </LibForm> + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/configuration_issues_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/configuration_context.tsx similarity index 66% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/configuration_issues_context.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/configuration_context.tsx index c2e55f7aa6e61..97952a3a212c7 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/configuration_issues_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/configuration_context.tsx @@ -12,7 +12,7 @@ import { useFormData } from '../../../../shared_imports'; import { isUsingDefaultRolloverPath, isUsingCustomRolloverPath } from '../constants'; -export interface ConfigurationIssues { +export interface Configuration { /** * Whether the serialized policy will use rollover. This blocks certain actions in * the form such as hot phase (forcemerge, shrink) and cold phase (searchable snapshot). @@ -28,7 +28,7 @@ export interface ConfigurationIssues { isUsingSearchableSnapshotInColdPhase: boolean; } -const ConfigurationIssuesContext = createContext<ConfigurationIssues>(null as any); +const ConfigurationContext = createContext<Configuration>(null as any); const pathToHotPhaseSearchableSnapshot = 'phases.hot.actions.searchable_snapshot.snapshot_repository'; @@ -36,7 +36,7 @@ const pathToHotPhaseSearchableSnapshot = const pathToColdPhaseSearchableSnapshot = 'phases.cold.actions.searchable_snapshot.snapshot_repository'; -export const ConfigurationIssuesProvider: FunctionComponent = ({ children }) => { +export const ConfigurationProvider: FunctionComponent = ({ children }) => { const [formData] = useFormData({ watch: [ pathToHotPhaseSearchableSnapshot, @@ -49,25 +49,18 @@ export const ConfigurationIssuesProvider: FunctionComponent = ({ children }) => // Provide default value, as path may become undefined if removed from the DOM const isUsingCustomRollover = get(formData, isUsingCustomRolloverPath, true); - return ( - <ConfigurationIssuesContext.Provider - value={{ - isUsingRollover: isUsingDefaultRollover === false ? isUsingCustomRollover : true, - isUsingSearchableSnapshotInHotPhase: - get(formData, pathToHotPhaseSearchableSnapshot) != null, - isUsingSearchableSnapshotInColdPhase: - get(formData, pathToColdPhaseSearchableSnapshot) != null, - }} - > - {children} - </ConfigurationIssuesContext.Provider> - ); + const context: Configuration = { + isUsingRollover: isUsingDefaultRollover === false ? isUsingCustomRollover : true, + isUsingSearchableSnapshotInHotPhase: get(formData, pathToHotPhaseSearchableSnapshot) != null, + isUsingSearchableSnapshotInColdPhase: get(formData, pathToColdPhaseSearchableSnapshot) != null, + }; + + return <ConfigurationContext.Provider value={context}>{children}</ConfigurationContext.Provider>; }; -export const useConfigurationIssues = () => { - const ctx = useContext(ConfigurationIssuesContext); - if (!ctx) - throw new Error('Cannot use configuration issues outside of configuration issues context'); +export const useConfiguration = () => { + const ctx = useContext(ConfigurationContext); + if (!ctx) throw new Error('Cannot use configuration outside of configuration context'); return ctx; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index 227f135ca7b72..d8cffb974dfd1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -8,18 +8,29 @@ import { produce } from 'immer'; import { SerializedPolicy } from '../../../../../common/types'; - import { splitSizeAndUnits } from '../../../lib/policies'; - import { determineDataTierAllocationType, isUsingDefaultRollover } from '../../../lib'; - +import { getDefaultRepository } from '../lib'; import { FormInternal } from '../types'; +import { CLOUD_DEFAULT_REPO } from '../constants'; -export const deserializer = (policy: SerializedPolicy): FormInternal => { +export const createDeserializer = (isCloudEnabled: boolean) => ( + policy: SerializedPolicy +): FormInternal => { const { phases: { hot, warm, cold, frozen, delete: deletePhase }, } = policy; + let defaultRepository = getDefaultRepository([ + hot?.actions.searchable_snapshot, + cold?.actions.searchable_snapshot, + frozen?.actions.searchable_snapshot, + ]); + + if (!defaultRepository && isCloudEnabled) { + defaultRepository = CLOUD_DEFAULT_REPO; + } + const _meta: FormInternal['_meta'] = { hot: { isUsingDefaultRollover: isUsingDefaultRollover(policy), @@ -49,6 +60,9 @@ export const deserializer = (policy: SerializedPolicy): FormInternal => { delete: { enabled: Boolean(deletePhase), }, + searchableSnapshot: { + repository: defaultRepository, + }, }; return produce<FormInternal>( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts index ab60a631dacc5..bdb915ba62d44 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts @@ -9,7 +9,7 @@ import { setAutoFreeze } from 'immer'; import { cloneDeep } from 'lodash'; import { SerializedPolicy } from '../../../../../common/types'; import { defaultRolloverAction } from '../../../constants'; -import { deserializer } from './deserializer'; +import { createDeserializer } from './deserializer'; import { createSerializer } from './serializer'; import { FormInternal } from '../types'; @@ -18,6 +18,8 @@ const isObject = (v: unknown): v is { [key: string]: any } => const unknownValue = { some: 'value' }; +const deserializer = createDeserializer(false); + const populateWithUnknownEntries = (v: unknown) => { if (isObject(v)) { for (const key of Object.keys(v)) { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx new file mode 100644 index 0000000000000..30a00390a18cc --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, FunctionComponent, useContext } from 'react'; +import { UseMultiFields, FieldHook, FieldConfig } from '../../../../shared_imports'; + +/** + * Those are the fields that we always want present in our form. + */ +interface GlobalFieldsTypes { + deleteEnabled: boolean; + searchableSnapshotRepo: string; +} + +type GlobalFields = { + [K in keyof GlobalFieldsTypes]: FieldHook<GlobalFieldsTypes[K]>; +}; + +const GlobalFieldsContext = createContext<GlobalFields | null>(null); + +export const globalFields: Record< + keyof GlobalFields, + { path: string; config?: FieldConfig<any> } +> = { + deleteEnabled: { + path: '_meta.delete.enabled', + }, + searchableSnapshotRepo: { + path: '_meta.searchableSnapshot.repository', + }, +}; + +export const GlobalFieldsProvider: FunctionComponent = ({ children }) => { + return ( + <UseMultiFields<GlobalFieldsTypes> fields={globalFields}> + {(fields) => { + return ( + <GlobalFieldsContext.Provider value={fields}>{children}</GlobalFieldsContext.Provider> + ); + }} + </UseMultiFields> + ); +}; + +export const useGlobalFields = () => { + const ctx = useContext(GlobalFieldsContext); + if (!ctx) throw new Error('Cannot use global fields outside of global fields context'); + + return ctx; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts index 6deb4d7fd4711..f31fedfac6681 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts @@ -5,20 +5,17 @@ * 2.0. */ -export { deserializer } from './deserializer'; +export { createDeserializer } from './deserializer'; export { createSerializer } from './serializer'; -export { schema, searchableSnapshotFields } from './schema'; +export { getSchema } from './schema'; export * from './validations'; export { Form, EnhancedUseField as UseField } from './components'; -export { - ConfigurationIssuesProvider, - useConfigurationIssues, -} from './configuration_issues_context'; +export { ConfigurationProvider, useConfiguration } from './configuration_context'; export { FormErrorsProvider, useFormErrorsContext } from './form_errors_context'; @@ -27,3 +24,5 @@ export { usePhaseTimings, PhaseTimingConfiguration, } from './phase_timings_context'; + +export { useGlobalFields, globalFields } from './global_fields_context'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/phase_timings_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/phase_timings_context.tsx index 98ffb7e2dd7af..0cbee8832c55b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/phase_timings_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/phase_timings_context.tsx @@ -8,7 +8,7 @@ import React, { createContext, FunctionComponent, useContext } from 'react'; import { useFormData } from '../../../../shared_imports'; import { FormInternal } from '../types'; -import { UseField } from './index'; +import { useGlobalFields } from './index'; export interface PhaseTimingConfiguration { /** @@ -48,6 +48,7 @@ export interface PhaseTimings { const PhaseTimingsContext = createContext<PhaseTimings>(null as any); export const PhaseTimingsProvider: FunctionComponent = ({ children }) => { + const { deleteEnabled } = useGlobalFields(); const [formData] = useFormData<FormInternal>({ watch: [ '_meta.warm.enabled', @@ -58,21 +59,15 @@ export const PhaseTimingsProvider: FunctionComponent = ({ children }) => { }); return ( - <UseField path="_meta.delete.enabled"> - {(field) => { - return ( - <PhaseTimingsContext.Provider - value={{ - ...getPhaseTimingConfiguration(formData), - isDeletePhaseEnabled: formData?._meta?.delete?.enabled, - setDeletePhaseEnabled: field.setValue, - }} - > - {children} - </PhaseTimingsContext.Provider> - ); + <PhaseTimingsContext.Provider + value={{ + ...getPhaseTimingConfiguration(formData), + isDeletePhaseEnabled: deleteEnabled.value, + setDeletePhaseEnabled: deleteEnabled.setValue, }} - </UseField> + > + {children} + </PhaseTimingsContext.Provider> ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index 5861c7b320de1..c0e489042586c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -9,12 +9,8 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../../../shared_imports'; import { defaultIndexPriority } from '../../../constants'; -import { ROLLOVER_FORM_PATHS } from '../constants'; - -import { FormInternal } from '../types'; - -const rolloverFormPaths = Object.values(ROLLOVER_FORM_PATHS); - +import { ROLLOVER_FORM_PATHS, CLOUD_DEFAULT_REPO } from '../constants'; +import { i18nTexts } from '../i18n_texts'; import { ifExistsNumberGreaterThanZero, ifExistsNumberNonNegative, @@ -22,7 +18,7 @@ import { minAgeValidator, } from './validations'; -import { i18nTexts } from '../i18n_texts'; +const rolloverFormPaths = Object.values(ROLLOVER_FORM_PATHS); const { emptyField, numberGreaterThanField } = fieldValidators; @@ -54,6 +50,13 @@ export const searchableSnapshotFields = { validations: [ { validator: emptyField(i18nTexts.editPolicy.errors.searchableSnapshotRepoRequired) }, ], + // TODO: update text copy + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.searchableSnapshot.repositoryHelpText', + { + defaultMessage: 'Each phase uses the same snapshot repository.', + } + ), }, storage: { label: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.searchableSnapshot.storageLabel', { @@ -114,7 +117,7 @@ const getPriorityField = (phase: 'hot' | 'warm' | 'cold' | 'frozen') => ({ serializer: serializers.stringToNumber, }); -export const schema: FormSchema<FormInternal> = { +export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ _meta: { hot: { isUsingDefaultRollover: { @@ -230,6 +233,11 @@ export const schema: FormSchema<FormInternal> = { defaultValue: 'd', }, }, + searchableSnapshot: { + repository: { + defaultValue: isCloudEnabled ? CLOUD_DEFAULT_REPO : '', + }, + }, }, phases: { hot: { @@ -288,6 +296,7 @@ export const schema: FormSchema<FormInternal> = { set_priority: { priority: getPriorityField('hot'), }, + searchable_snapshot: searchableSnapshotFields, }, }, warm: { @@ -375,4 +384,4 @@ export const schema: FormSchema<FormInternal> = { }, }, }, -}; +}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts index b21545ce1739c..57112b0e1cb16 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts @@ -124,7 +124,12 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( /** * HOT PHASE SEARCHABLE SNAPSHOT */ - if (!updatedPolicy.phases.hot!.actions?.searchable_snapshot) { + if (updatedPolicy.phases.hot!.actions?.searchable_snapshot) { + hotPhaseActions.searchable_snapshot = { + ...hotPhaseActions.searchable_snapshot, + snapshot_repository: _meta.searchableSnapshot.repository, + }; + } else { delete hotPhaseActions.searchable_snapshot; } } @@ -234,7 +239,12 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( /** * COLD PHASE SEARCHABLE SNAPSHOT */ - if (!updatedPolicy.phases.cold?.actions?.searchable_snapshot) { + if (updatedPolicy.phases.cold?.actions?.searchable_snapshot) { + coldPhase.actions.searchable_snapshot = { + ...coldPhase.actions.searchable_snapshot, + snapshot_repository: _meta.searchableSnapshot.repository, + }; + } else { delete coldPhase.actions.searchable_snapshot; } } else { @@ -251,7 +261,12 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( /** * FROZEN PHASE SEARCHABLE SNAPSHOT */ - if (!updatedPolicy.phases.frozen?.actions?.searchable_snapshot) { + if (updatedPolicy.phases.frozen?.actions?.searchable_snapshot) { + frozenPhase.actions.searchable_snapshot = { + ...frozenPhase.actions.searchable_snapshot, + snapshot_repository: _meta.searchableSnapshot.repository, + }; + } else { delete frozenPhase.actions.searchable_snapshot; } } else { @@ -271,7 +286,7 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( deletePhase.actions.delete = deletePhase.actions.delete ?? {}; /** - * DELETE PHASE SEARCHABLE SNAPSHOT + * DELETE PHASE MIN AGE */ if (updatedPolicy.phases.delete?.min_age) { deletePhase.min_age = `${updatedPolicy.phases.delete!.min_age}${_meta.delete.minAgeUnit}`; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts index 8a9635e2db219..d4a26924385f0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts @@ -6,13 +6,15 @@ */ import { flow } from 'fp-ts/function'; -import { deserializer } from '../form'; +import { createDeserializer } from '../form'; import { formDataToAbsoluteTimings, calculateRelativeFromAbsoluteMilliseconds, } from './absolute_timing_to_relative_timing'; +const deserializer = createDeserializer(false); + export const calculateRelativeTimingMs = flow( formDataToAbsoluteTimings, calculateRelativeFromAbsoluteMilliseconds diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/get_default_repository.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/get_default_repository.ts new file mode 100644 index 0000000000000..43e911333e357 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/get_default_repository.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SearchableSnapshotAction } from '../../../../../common/types'; + +export const getDefaultRepository = ( + configs: Array<SearchableSnapshotAction | undefined> +): string => { + if (configs.length === 0) { + return ''; + } + if (Boolean(configs[0]?.snapshot_repository)) { + return configs[0]!.snapshot_repository; + } + return getDefaultRepository(configs.slice(1)); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts index af4757a7b7105..19d87532f2bfe 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts @@ -12,3 +12,5 @@ export { PhaseAgeInMilliseconds, RelativePhaseTimingInMs, } from './absolute_timing_to_relative_timing'; + +export { getDefaultRepository } from './get_default_repository'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts index 4330cde378b6d..977554f12da42 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { SerializedPolicy } from '../../../../common/types'; export type DataTierAllocationType = 'node_roles' | 'node_attrs' | 'none'; @@ -76,5 +75,8 @@ export interface FormInternal extends SerializedPolicy { cold: ColdPhaseMetaFields; frozen: FrozenPhaseMetaFields; delete: DeletePhaseMetaFields; + searchableSnapshot: { + repository: string; + }; }; } diff --git a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts index cf2d5d5efc0f8..a8e0182ada77b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts @@ -23,6 +23,7 @@ export { useFormContext, FormSchema, ValidationConfig, + UseMultiFields, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; 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 557831780008a..2cb00644f56d4 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 @@ -5,6 +5,13 @@ * 2.0. */ +import { + IndicesExistsAlias, + IndicesGet, + MlGetBuckets, + Msearch, +} from '@elastic/elasticsearch/api/requestParams'; +import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; import { InfraRouteConfig, InfraTSVBResponse, @@ -134,10 +141,58 @@ export class KibanaFramework { } : {}; - return elasticsearch.legacy.client.callAsCurrentUser(endpoint, { - ...params, - ...frozenIndicesParams, - }); + let apiResult; + switch (endpoint) { + case 'search': + apiResult = elasticsearch.client.asCurrentUser.search({ + ...params, + ...frozenIndicesParams, + }); + break; + case 'msearch': + apiResult = elasticsearch.client.asCurrentUser.msearch({ + ...params, + ...frozenIndicesParams, + } as Msearch<any>); + break; + case 'fieldCaps': + apiResult = elasticsearch.client.asCurrentUser.fieldCaps({ + ...params, + ...frozenIndicesParams, + }); + break; + case 'indices.existsAlias': + apiResult = elasticsearch.client.asCurrentUser.indices.existsAlias({ + ...params, + ...frozenIndicesParams, + } as IndicesExistsAlias); + break; + case 'indices.getAlias': + apiResult = elasticsearch.client.asCurrentUser.indices.getAlias({ + ...params, + ...frozenIndicesParams, + }); + break; + case 'indices.get': + apiResult = elasticsearch.client.asCurrentUser.indices.get({ + ...params, + ...frozenIndicesParams, + } as IndicesGet); + break; + case 'transport.request': + apiResult = elasticsearch.client.asCurrentUser.transport.request({ + ...params, + ...frozenIndicesParams, + } as TransportRequestParams); + break; + case 'ml.getBuckets': + apiResult = elasticsearch.client.asCurrentUser.ml.getBuckets({ + ...params, + ...frozenIndicesParams, + } as MlGetBuckets<any>); + break; + } + return apiResult ? (await apiResult).body : undefined; } public getIndexPatternsService( 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 6b6cf5f1d563c..615de182662f1 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 @@ -7,6 +7,7 @@ import { mapValues, last, first } from 'lodash'; import moment from 'moment'; +import { ElasticsearchClient } from 'kibana/server'; import { SnapshotCustomMetricInput } from '../../../../common/http_api/snapshot_api'; import { isTooManyBucketsPreviewException, @@ -17,7 +18,6 @@ import { CallWithRequestParams, } from '../../adapters/framework/adapter_types'; import { Comparator, InventoryMetricConditions } from './types'; -import { AlertServices } from '../../../../../alerting/server'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { InfraTimerangeInput, SnapshotRequest } from '../../../../common/http_api/snapshot_api'; import { InfraSource } from '../../sources'; @@ -36,7 +36,7 @@ export const evaluateCondition = async ( condition: InventoryMetricConditions, nodeType: InventoryItemType, source: InfraSource, - callCluster: AlertServices['callCluster'], + esClient: ElasticsearchClient, filterQuery?: string, lookbackSize?: number ): Promise<Record<string, ConditionResult>> => { @@ -53,7 +53,7 @@ export const evaluateCondition = async ( } const currentValues = await getData( - callCluster, + esClient, nodeType, metric, timerange, @@ -96,7 +96,7 @@ const getCurrentValue: (value: any) => number = (value) => { type DataValue = number | null | Array<number | string | null | undefined>; const getData = async ( - callCluster: AlertServices['callCluster'], + esClient: ElasticsearchClient, nodeType: InventoryItemType, metric: SnapshotMetricType, timerange: InfraTimerangeInput, @@ -104,9 +104,10 @@ const getData = async ( filterQuery?: string, customMetric?: SnapshotCustomMetricInput ) => { - const client = <Hit = {}, Aggregation = undefined>( + const client = async <Hit = {}, Aggregation = undefined>( options: CallWithRequestParams - ): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> => callCluster('search', options); + ): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> => + (await esClient.search(options)).body as InfraDatabaseSearchResponse<Hit, Aggregation>; const metrics = [ metric === 'custom' ? (customMetric as SnapshotCustomMetricInput) : { type: metric }, 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 f4fadd09efdf5..632ba9cd6f282 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 @@ -69,7 +69,15 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = ); const results = await Promise.all( - criteria.map((c) => evaluateCondition(c, nodeType, source, services.callCluster, filterQuery)) + criteria.map((c) => + evaluateCondition( + c, + nodeType, + source, + services.scopedClusterClient.asCurrentUser, + filterQuery + ) + ) ); const inventoryItems = Object.keys(first(results)!); 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 6f3299a2cc126..472f9d408694c 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 @@ -13,7 +13,7 @@ import { TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, isTooManyBucketsPreviewException, } from '../../../../common/alerting/metrics'; -import { ILegacyScopedClusterClient } from '../../../../../../../src/core/server'; +import { ElasticsearchClient } from '../../../../../../../src/core/server'; import { InfraSource } from '../../../../common/http_api/source_api'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { InventoryItemType } from '../../../../common/inventory_models/types'; @@ -27,7 +27,7 @@ interface InventoryMetricThresholdParams { } interface PreviewInventoryMetricThresholdAlertParams { - callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; + esClient: ElasticsearchClient; params: InventoryMetricThresholdParams; source: InfraSource; lookback: Unit; @@ -40,7 +40,7 @@ interface PreviewInventoryMetricThresholdAlertParams { export const previewInventoryMetricThresholdAlert: ( params: PreviewInventoryMetricThresholdAlertParams ) => Promise<PreviewResult[]> = async ({ - callCluster, + esClient, params, source, lookback, @@ -68,7 +68,7 @@ export const previewInventoryMetricThresholdAlert: ( try { const results = await Promise.all( criteria.map((c) => - evaluateCondition(c, nodeType, source, callCluster, filterQuery, lookbackSize) + evaluateCondition(c, nodeType, source, esClient, filterQuery, lookbackSize) ) ); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index 565dfa6d6d2aa..0dff7e1070971 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { AlertExecutorOptions, AlertServices, @@ -67,7 +68,7 @@ const checkValueAgainstComparatorMap: { export const createLogThresholdExecutor = (libs: InfraBackendLibs) => async function ({ services, params }: LogThresholdAlertExecutorOptions) { - const { alertInstanceFactory, savedObjectsClient, callCluster } = services; + const { alertInstanceFactory, savedObjectsClient, scopedClusterClient } = services; const { sources } = libs; const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default'); @@ -82,7 +83,7 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => validatedParams, timestampField, indexPattern, - callCluster, + scopedClusterClient.asCurrentUser, alertInstanceFactory ); } else { @@ -90,7 +91,7 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => validatedParams, timestampField, indexPattern, - callCluster, + scopedClusterClient.asCurrentUser, alertInstanceFactory ); } @@ -103,7 +104,7 @@ async function executeAlert( alertParams: CountAlertParams, timestampField: string, indexPattern: string, - callCluster: LogThresholdAlertServices['callCluster'], + esClient: ElasticsearchClient, alertInstanceFactory: LogThresholdAlertServices['alertInstanceFactory'] ) { const query = getESQuery(alertParams, timestampField, indexPattern); @@ -114,14 +115,14 @@ async function executeAlert( if (hasGroupBy(alertParams)) { processGroupByResults( - await getGroupedResults(query, callCluster), + await getGroupedResults(query, esClient), alertParams, alertInstanceFactory, updateAlertInstance ); } else { processUngroupedResults( - await getUngroupedResults(query, callCluster), + await getUngroupedResults(query, esClient), alertParams, alertInstanceFactory, updateAlertInstance @@ -133,7 +134,7 @@ async function executeRatioAlert( alertParams: RatioAlertParams, timestampField: string, indexPattern: string, - callCluster: LogThresholdAlertServices['callCluster'], + esClient: ElasticsearchClient, alertInstanceFactory: LogThresholdAlertServices['alertInstanceFactory'] ) { // Ratio alert params are separated out into two standard sets of alert params @@ -155,8 +156,8 @@ async function executeRatioAlert( } if (hasGroupBy(alertParams)) { - const numeratorGroupedResults = await getGroupedResults(numeratorQuery, callCluster); - const denominatorGroupedResults = await getGroupedResults(denominatorQuery, callCluster); + const numeratorGroupedResults = await getGroupedResults(numeratorQuery, esClient); + const denominatorGroupedResults = await getGroupedResults(denominatorQuery, esClient); processGroupByRatioResults( numeratorGroupedResults, denominatorGroupedResults, @@ -165,8 +166,8 @@ async function executeRatioAlert( updateAlertInstance ); } else { - const numeratorUngroupedResults = await getUngroupedResults(numeratorQuery, callCluster); - const denominatorUngroupedResults = await getUngroupedResults(denominatorQuery, callCluster); + const numeratorUngroupedResults = await getUngroupedResults(numeratorQuery, esClient); + const denominatorUngroupedResults = await getUngroupedResults(denominatorQuery, esClient); processUngroupedRatioResults( numeratorUngroupedResults, denominatorUngroupedResults, @@ -605,17 +606,11 @@ const getQueryMappingForComparator = (comparator: Comparator) => { return queryMappings[comparator]; }; -const getUngroupedResults = async ( - query: object, - callCluster: LogThresholdAlertServices['callCluster'] -) => { - return decodeOrThrow(UngroupedSearchQueryResponseRT)(await callCluster('search', query)); +const getUngroupedResults = async (query: object, esClient: ElasticsearchClient) => { + return decodeOrThrow(UngroupedSearchQueryResponseRT)((await esClient.search(query)).body); }; -const getGroupedResults = async ( - query: object, - callCluster: LogThresholdAlertServices['callCluster'] -) => { +const getGroupedResults = async (query: object, esClient: ElasticsearchClient) => { let compositeGroupBuckets: GroupedSearchQueryResponse['aggregations']['groups']['buckets'] = []; let lastAfterKey: GroupedSearchQueryResponse['aggregations']['groups']['after_key'] | undefined; @@ -623,7 +618,7 @@ const getGroupedResults = async ( const queryWithAfterKey: any = { ...query }; queryWithAfterKey.body.aggregations.groups.composite.after = lastAfterKey; const groupResponse: GroupedSearchQueryResponse = decodeOrThrow(GroupedSearchQueryResponseRT)( - await callCluster('search', queryWithAfterKey) + (await esClient.search(queryWithAfterKey)).body ); compositeGroupBuckets = [ ...compositeGroupBuckets, 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 3f6bb075c8f92..b7d3dbb1f7adb 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 @@ -6,6 +6,7 @@ */ import { mapValues, first, last, isNaN } from 'lodash'; +import { ElasticsearchClient } from 'kibana/server'; import { isTooManyBucketsPreviewException, TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, @@ -13,7 +14,6 @@ import { import { InfraSource } from '../../../../../common/http_api/source_api'; import { InfraDatabaseSearchResponse } from '../../../adapters/framework/adapter_types'; import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler'; -import { AlertServices } from '../../../../../../alerting/server'; import { getAllCompositeData } from '../../../../utils/get_all_composite_data'; import { DOCUMENT_COUNT_I18N } from '../../common/messages'; import { UNGROUPED_FACTORY_KEY } from '../../common/utils'; @@ -43,7 +43,7 @@ export interface EvaluatedAlertParams { } export const evaluateAlert = <Params extends EvaluatedAlertParams = EvaluatedAlertParams>( - callCluster: AlertServices['callCluster'], + esClient: ElasticsearchClient, params: Params, config: InfraSource['configuration'], timeframe?: { start: number; end: number } @@ -52,7 +52,7 @@ export const evaluateAlert = <Params extends EvaluatedAlertParams = EvaluatedAle return Promise.all( criteria.map(async (criterion) => { const currentValues = await getMetric( - callCluster, + esClient, criterion, config.metricAlias, config.fields.timestamp, @@ -91,7 +91,7 @@ export const evaluateAlert = <Params extends EvaluatedAlertParams = EvaluatedAle }; const getMetric: ( - callCluster: AlertServices['callCluster'], + esClient: ElasticsearchClient, params: MetricExpressionParams, index: string, timefield: string, @@ -99,7 +99,7 @@ const getMetric: ( filterQuery: string | undefined, timeframe?: { start: number; end: number } ) => Promise<Record<string, number[]>> = async function ( - callCluster, + esClient, params, index, timefield, @@ -127,7 +127,7 @@ const getMetric: ( (response) => response.aggregations?.groupings?.after_key ); const compositeBuckets = (await getAllCompositeData( - (body) => callCluster('search', { body, index }), + (body) => esClient.search({ body, index }), searchBody, bucketSelector, afterKeyHandler @@ -142,7 +142,7 @@ const getMetric: ( {} ); } - const result = await callCluster('search', { + const { body: result } = await esClient.search({ body: searchBody, index, }); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 8d052f725fe20..9086d6436c2a2 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -16,6 +16,8 @@ import { } from '../../../../../alerting/server/mocks'; import { InfraSources } from '../../sources'; import { MetricThresholdAlertExecutorOptions } from './register_metric_threshold_alert_type'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; interface AlertTestInstance { instance: AlertInstanceMock; @@ -439,26 +441,36 @@ const mockLibs: any = { const executor = createMetricThresholdExecutor(mockLibs); const services: AlertServicesMock = alertsMock.createAlertServices(); -services.callCluster.mockImplementation(async (_: string, { body, index }: any) => { - if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse; - const metric = body.query.bool.filter[1]?.exists.field; - if (body.aggs.groupings) { - if (body.aggs.groupings.composite.after) { - return mocks.compositeEndResponse; +services.scopedClusterClient.asCurrentUser.search.mockImplementation((params?: any): any => { + if (params.index === 'alternatebeat-*') return mocks.changedSourceIdResponse; + const metric = params?.body.query.bool.filter[1]?.exists.field; + if (params?.body.aggs.groupings) { + if (params?.body.aggs.groupings.composite.after) { + return elasticsearchClientMock.createSuccessTransportRequestPromise( + mocks.compositeEndResponse + ); } if (metric === 'test.metric.2') { - return mocks.alternateCompositeResponse; + return elasticsearchClientMock.createSuccessTransportRequestPromise( + mocks.alternateCompositeResponse + ); } - return mocks.basicCompositeResponse; + return elasticsearchClientMock.createSuccessTransportRequestPromise( + mocks.basicCompositeResponse + ); } if (metric === 'test.metric.2') { - return mocks.alternateMetricResponse; + return elasticsearchClientMock.createSuccessTransportRequestPromise( + mocks.alternateMetricResponse + ); } else if (metric === 'test.metric.3') { - return body.aggs.aggregatedIntervals.aggregations.aggregatedValue_max - ? mocks.emptyRateResponse - : mocks.emptyMetricResponse; + return elasticsearchClientMock.createSuccessTransportRequestPromise( + params?.body.aggs.aggregatedIntervals.aggregations.aggregatedValue_max + ? mocks.emptyRateResponse + : mocks.emptyMetricResponse + ); } - return mocks.basicMetricResponse; + return elasticsearchClientMock.createSuccessTransportRequestPromise(mocks.basicMetricResponse); }); services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => { if (sourceId === 'alternate') diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 934d6cc4293ad..190d8e028fe0d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -44,7 +44,7 @@ export const createMetricThresholdExecutor = ( ); const config = source.configuration; const alertResults = await evaluateAlert( - services.callCluster, + services.scopedClusterClient.asCurrentUser, params as EvaluatedAlertParams, config ); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts index 551116ddac091..49cb8d70f6020 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts @@ -9,6 +9,8 @@ import * as mocks from './test_mocks'; import { Comparator, Aggregators, MetricExpressionParams } from './types'; import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; import { previewMetricThresholdAlert } from './preview_metric_threshold_alert'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; describe('Previewing the metric threshold alert type', () => { describe('querying the entire infrastructure', () => { @@ -163,21 +165,32 @@ describe('Previewing the metric threshold alert type', () => { }); const services: AlertServicesMock = alertsMock.createAlertServices(); -services.callCluster.mockImplementation(async (_: string, { body, index }: any) => { - const metric = body.query.bool.filter[1]?.exists.field; - if (body.aggs.groupings) { - if (body.aggs.groupings.composite.after) { - return mocks.compositeEndResponse; + +services.scopedClusterClient.asCurrentUser.search.mockImplementation((params?: any): any => { + const metric = params?.body.query.bool.filter[1]?.exists.field; + if (params?.body.aggs.groupings) { + if (params?.body.aggs.groupings.composite.after) { + return elasticsearchClientMock.createSuccessTransportRequestPromise( + mocks.compositeEndResponse + ); } - return mocks.basicCompositePreviewResponse; + return elasticsearchClientMock.createSuccessTransportRequestPromise( + mocks.basicCompositePreviewResponse + ); } if (metric === 'test.metric.2') { - return mocks.alternateMetricPreviewResponse; + return elasticsearchClientMock.createSuccessTransportRequestPromise( + mocks.alternateMetricPreviewResponse + ); } if (metric === 'test.metric.3') { - return mocks.repeatingMetricPreviewResponse; + return elasticsearchClientMock.createSuccessTransportRequestPromise( + mocks.repeatingMetricPreviewResponse + ); } - return mocks.basicMetricPreviewResponse; + return elasticsearchClientMock.createSuccessTransportRequestPromise( + mocks.basicMetricPreviewResponse + ); }); const baseCriterion = { @@ -197,7 +210,7 @@ const config = { } as any; const baseParams = { - callCluster: services.callCluster, + esClient: services.scopedClusterClient.asCurrentUser, params: { criteria: [baseCriterion], groupBy: undefined, 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 fe2a88d89bf4a..064804b661b74 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 @@ -11,7 +11,7 @@ import { TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, isTooManyBucketsPreviewException, } from '../../../../common/alerting/metrics'; -import { ILegacyScopedClusterClient } from '../../../../../../../src/core/server'; +import { ElasticsearchClient } from '../../../../../../../src/core/server'; import { InfraSource } from '../../../../common/http_api/source_api'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { PreviewResult } from '../common/types'; @@ -21,7 +21,7 @@ import { evaluateAlert } from './lib/evaluate_alert'; const MAX_ITERATIONS = 50; interface PreviewMetricThresholdAlertParams { - callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; + esClient: ElasticsearchClient; params: { criteria: MetricExpressionParams[]; groupBy: string | undefined | string[]; @@ -43,7 +43,7 @@ export const previewMetricThresholdAlert: ( precalculatedNumberOfGroups?: number ) => Promise<PreviewResult[]> = async ( { - callCluster, + esClient, params, config, lookback, @@ -79,7 +79,7 @@ export const previewMetricThresholdAlert: ( // Get a date histogram using the bucket interval and the lookback interval try { - const alertResults = await evaluateAlert(callCluster, params, config, timeframe); + const alertResults = await evaluateAlert(esClient, params, config, timeframe); const groups = Object.keys(first(alertResults)!); // Now determine how to interpolate this histogram based on the alert interval @@ -174,7 +174,7 @@ export const previewMetricThresholdAlert: ( // If there's too much data on the first request, recursively slice the lookback interval // until all the data can be retrieved const basePreviewParams = { - callCluster, + esClient, params, config, lookback, @@ -187,7 +187,7 @@ export const previewMetricThresholdAlert: ( // If this is still the first iteration, try to get the number of groups in order to // calculate max buckets. If this fails, just estimate based on 1 group const currentAlertResults = !precalculatedNumberOfGroups - ? await evaluateAlert(callCluster, params, config) + ? await evaluateAlert(esClient, params, config) : []; const numberOfGroups = precalculatedNumberOfGroups ?? Math.max(Object.keys(first(currentAlertResults)!).length, 1); diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index d1807583acd39..6622df1a8333a 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -26,7 +26,6 @@ import { InfraBackendLibs } from '../../lib/infra_types'; import { assertHasInfraMlPlugins } from '../../utils/request_context'; export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) => { - const { callWithRequest } = framework; framework.registerRoute( { method: 'post', @@ -46,9 +45,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) alertNotifyWhen, } = request.body; - const callCluster = (endpoint: string, opts: Record<string, any>) => { - return callWithRequest(requestContext, endpoint, opts); - }; + const esClient = requestContext.core.elasticsearch.client.asCurrentUser; const source = await sources.getSourceConfiguration( requestContext.core.savedObjects.client, @@ -64,7 +61,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) filterQuery, } = request.body as MetricThresholdAlertPreviewRequestParams; const previewResult = await previewMetricThresholdAlert({ - callCluster, + esClient, params: { criteria, filterQuery, groupBy }, lookback, config: source.configuration, @@ -86,7 +83,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) filterQuery, } = request.body as InventoryAlertPreviewRequestParams; const previewResult = await previewInventoryMetricThresholdAlert({ - callCluster, + esClient, params: { criteria, filterQuery, nodeType }, lookback, source, diff --git a/x-pack/plugins/infra/server/types.ts b/x-pack/plugins/infra/server/types.ts index 5cae015861946..1c51a5549cb41 100644 --- a/x-pack/plugins/infra/server/types.ts +++ b/x-pack/plugins/infra/server/types.ts @@ -5,8 +5,7 @@ * 2.0. */ -import type { RequestHandlerContext } from 'src/core/server'; -import type { SearchRequestHandlerContext } from '../../../../src/plugins/data/server'; +import type { DataRequestHandlerContext } from '../../../../src/plugins/data/server'; import { MlPluginSetup } from '../../ml/server'; export type MlSystem = ReturnType<MlPluginSetup['mlSystemProvider']>; @@ -27,7 +26,6 @@ export type InfraRequestHandlerContext = InfraMlRequestHandlerContext & /** * @internal */ -export interface InfraPluginRequestHandlerContext extends RequestHandlerContext { +export interface InfraPluginRequestHandlerContext extends DataRequestHandlerContext { infra: InfraRequestHandlerContext; - search: SearchRequestHandlerContext; } diff --git a/x-pack/plugins/infra/server/utils/get_all_composite_data.ts b/x-pack/plugins/infra/server/utils/get_all_composite_data.ts index fbe8a36f5038c..df97c91aacd04 100644 --- a/x-pack/plugins/infra/server/utils/get_all_composite_data.ts +++ b/x-pack/plugins/infra/server/utils/get_all_composite_data.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; import { InfraDatabaseSearchResponse } from '../lib/adapters/framework'; export const getAllCompositeData = async < @@ -12,13 +13,15 @@ export const getAllCompositeData = async < Bucket = {}, Options extends object = {} >( - callCluster: (options: Options) => Promise<InfraDatabaseSearchResponse<{}, Aggregation>>, + esClientSearch: ( + options: Options + ) => Promise<ApiResponse<InfraDatabaseSearchResponse<{}, Aggregation>>>, options: Options, bucketSelector: (response: InfraDatabaseSearchResponse<{}, Aggregation>) => Bucket[], onAfterKey: (options: Options, response: InfraDatabaseSearchResponse<{}, Aggregation>) => Options, previousBuckets: Bucket[] = [] ): Promise<Bucket[]> => { - const response = await callCluster(options); + const { body: response } = await esClientSearch(options); // Nothing available, return the previous buckets. if (response.hits.total.value === 0) { @@ -40,7 +43,7 @@ export const getAllCompositeData = async < // There is possibly more data, concat previous and current buckets and call ourselves recursively. const newOptions = onAfterKey(options, response); return getAllCompositeData( - callCluster, + esClientSearch, newOptions, bucketSelector, onAfterKey, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx index ec143ac31438c..658ffe08607d8 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx @@ -32,9 +32,7 @@ export const OnFailureProcessorsTitle: FunctionComponent = () => { values={{ learnMoreLink: ( <EuiLink - href={ - services.documentation.getEsDocsBasePath() + '/handling-failure-in-pipelines.html' - } + href={services.documentation.getHandlingFailureUrl()} target="_blank" external > diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_empty_prompt.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_empty_prompt.tsx index 47b6374be4678..87684929e0c68 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_empty_prompt.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_empty_prompt.tsx @@ -39,11 +39,7 @@ export const ProcessorsEmptyPrompt: FunctionComponent<Props> = ({ onLoadJson }) defaultMessage="Use processors to transform data before indexing. {learnMoreLink}" values={{ learnMoreLink: ( - <EuiLink - href={services.documentation.getEsDocsBasePath() + '/ingest-processors.html'} - target="_blank" - external - > + <EuiLink href={services.documentation.getProcessorsUrl()} target="_blank" external> {i18n.translate( 'xpack.ingestPipelines.pipelineEditor.processorsDocumentationLink', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_header.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_header.tsx index 9e3e297cb967b..c1e412dd755fa 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_header.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_header.tsx @@ -59,11 +59,7 @@ export const ProcessorsHeader: FunctionComponent<Props> = ({ onLoadJson, hasProc defaultMessage="Use processors to transform data before indexing. {learnMoreLink}" values={{ learnMoreLink: ( - <EuiLink - href={services.documentation.getEsDocsBasePath() + '/ingest-processors.html'} - target="_blank" - external - > + <EuiLink href={services.documentation.getProcessorsUrl()} target="_blank" external> {i18n.translate( 'xpack.ingestPipelines.pipelineEditor.processorsDocumentationLink', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts b/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts index 5be400980f228..8aa165cc502a8 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts @@ -9,12 +9,20 @@ import { DocLinksStart } from 'src/core/public'; export class DocumentationService { private esDocBasePath: string = ''; + private ingestNodeUrl: string = ''; + private processorsUrl: string = ''; + private handlingFailureUrl: string = ''; + private putPipelineApiUrl: string = ''; public setup(docLinks: DocLinksStart): void { - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; + const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL, links } = docLinks; const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; this.esDocBasePath = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; + this.ingestNodeUrl = `${links.ingest.pipelines}`; + this.processorsUrl = `${links.ingest.processors}`; + this.handlingFailureUrl = `${links.ingest.pipelineFailure}`; + this.putPipelineApiUrl = `${links.apis.createPipeline}`; } public getEsDocsBasePath() { @@ -22,19 +30,19 @@ export class DocumentationService { } public getIngestNodeUrl() { - return `${this.esDocBasePath}/ingest.html`; + return this.ingestNodeUrl; } public getProcessorsUrl() { - return `${this.esDocBasePath}/ingest-processors.html`; + return this.processorsUrl; } public getHandlingFailureUrl() { - return `${this.esDocBasePath}/handling-failure-in-pipelines.html`; + return this.handlingFailureUrl; } public getPutPipelineApiUrl() { - return `${this.esDocBasePath}/put-pipeline-api.html`; + return this.putPipelineApiUrl; } } diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx index 672b29846d760..a750744811790 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -27,7 +27,7 @@ export function TableDimensionEditor( const currentAlignment = column?.alignment || (frame.activeData && - frame.activeData[state.layerId].columns.find( + frame.activeData[state.layerId]?.columns.find( (col) => col.id === accessor || getOriginalId(col.id) === accessor )?.meta.type === 'number' ? 'right' 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 5c27958aa1786..30740bbd6b217 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 @@ -119,6 +119,15 @@ describe('LayerPanel', () => { ); }); + it('should show to reset visualization for visualizations only allowing a single layer', () => { + const layerPanelAttributes = getDefaultProps(); + delete layerPanelAttributes.activeVisualization.removeLayer; + const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />); + expect(component.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain( + 'Reset visualization' + ); + }); + it('should call the clear callback', () => { const cb = jest.fn(); const component = mountWithIntl(<LayerPanel {...getDefaultProps()} onRemoveLayer={cb} />); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 14063aea02665..21115285b5ce0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -397,6 +397,7 @@ export function LayerPanel( onRemoveLayer={onRemoveLayer} layerIndex={layerIndex} isOnlyLayer={isOnlyLayer} + activeVisualization={activeVisualization} /> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx index d842d2af5c777..cca8cc88c6ab1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx @@ -8,33 +8,54 @@ import React from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { Visualization } from '../../../types'; export function RemoveLayerButton({ onRemoveLayer, layerIndex, isOnlyLayer, + activeVisualization, }: { onRemoveLayer: () => void; layerIndex: number; isOnlyLayer: boolean; + activeVisualization: Visualization; }) { + let ariaLabel; + let componentText; + + if (!activeVisualization.removeLayer) { + ariaLabel = i18n.translate('xpack.lens.resetVisualizationAriaLabel', { + defaultMessage: 'Reset visualization', + }); + componentText = i18n.translate('xpack.lens.resetVisualization', { + defaultMessage: 'Reset visualization', + }); + } else if (isOnlyLayer) { + ariaLabel = i18n.translate('xpack.lens.resetLayerAriaLabel', { + defaultMessage: 'Reset layer {index}', + values: { index: layerIndex + 1 }, + }); + componentText = i18n.translate('xpack.lens.resetLayer', { + defaultMessage: 'Reset layer', + }); + } else { + ariaLabel = i18n.translate('xpack.lens.deleteLayerAriaLabel', { + defaultMessage: `Delete layer {index}`, + values: { index: layerIndex + 1 }, + }); + componentText = i18n.translate('xpack.lens.deleteLayer', { + defaultMessage: `Delete layer`, + }); + } + return ( <EuiButtonEmpty size="xs" iconType="trash" color="danger" data-test-subj="lnsLayerRemove" - aria-label={ - isOnlyLayer - ? i18n.translate('xpack.lens.resetLayerAriaLabel', { - defaultMessage: 'Reset layer {index}', - values: { index: layerIndex + 1 }, - }) - : i18n.translate('xpack.lens.deleteLayerAriaLabel', { - defaultMessage: `Delete layer {index}`, - values: { index: layerIndex + 1 }, - }) - } + aria-label={ariaLabel} onClick={() => { // If we don't blur the remove / clear button, it remains focused // which is a strange UX in this case. e.target.blur doesn't work @@ -49,13 +70,7 @@ export function RemoveLayerButton({ onRemoveLayer(); }} > - {isOnlyLayer - ? i18n.translate('xpack.lens.resetLayer', { - defaultMessage: 'Reset layer', - }) - : i18n.translate('xpack.lens.deleteLayer', { - defaultMessage: `Delete layer`, - })} + {componentText} </EuiButtonEmpty> ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/error_helper.ts b/x-pack/plugins/lens/public/editor_frame_service/error_helper.ts index 54df403021422..4dad121555d46 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/error_helper.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/error_helper.ts @@ -26,6 +26,17 @@ const isRequestError = (e: Error | RequestError): e is RequestError => { return false; }; +interface ESError extends Error { + attributes?: { caused_by?: ElasticsearchErrorClause }; +} + +const isEsError = (e: Error | ESError): e is ESError => { + if ('attributes' in e) { + return e.attributes?.caused_by?.caused_by !== undefined; + } + return false; +}; + function getNestedErrorClause({ type, reason, @@ -37,9 +48,22 @@ function getNestedErrorClause({ return { type, reason }; } +function getErrorSource(e: Error | RequestError | ESError) { + if (isRequestError(e)) { + return e.body!.attributes!.error; + } + if (isEsError(e)) { + return e.attributes!.caused_by; + } +} + export function getOriginalRequestErrorMessage(error?: ExpressionRenderError | null) { - if (error && 'original' in error && error.original && isRequestError(error.original)) { - const rootError = getNestedErrorClause(error.original.body!.attributes!.error); + if (error && 'original' in error && error.original) { + const errorSource = getErrorSource(error.original); + if (errorSource == null) { + return; + } + const rootError = getNestedErrorClause(errorSource); if (rootError.reason && rootError.type) { return i18n.translate('xpack.lens.editorFrame.expressionFailureMessage', { defaultMessage: 'Request error: {type}, {reason}', diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index 7f256dc588c25..f769b20a6a454 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -23,6 +23,7 @@ export function createMockVisualization(): jest.Mocked<Visualization> { return { id: 'TEST_VIS', clearLayer: jest.fn((state, _layerId) => state), + removeLayer: jest.fn(), getLayerIds: jest.fn((_state) => ['layer1']), visualizationTypes: [ { diff --git a/x-pack/plugins/lists/.storybook/main.js b/x-pack/plugins/lists/.storybook/main.js new file mode 100644 index 0000000000000..86b48c32f103e --- /dev/null +++ b/x-pack/plugins/lists/.storybook/main.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/x-pack/plugins/lists/server/plugin.ts b/x-pack/plugins/lists/server/plugin.ts index b79d6a0b89a57..004677852d020 100644 --- a/x-pack/plugins/lists/server/plugin.ts +++ b/x-pack/plugins/lists/server/plugin.ts @@ -58,10 +58,10 @@ export class ListPlugin user, }); }, - getListClient: (callCluster, spaceId, user): ListClient => { + getListClient: (esClient, spaceId, user): ListClient => { return new ListClient({ - callCluster, config, + esClient, spaceId, user, }); @@ -86,9 +86,7 @@ export class ListPlugin core: { savedObjects: { client: savedObjectsClient }, elasticsearch: { - legacy: { - client: { callAsCurrentUser: callCluster }, - }, + client: { asCurrentUser: esClient }, }, }, } = context; @@ -105,8 +103,8 @@ export class ListPlugin }), getListClient: (): ListClient => new ListClient({ - callCluster, config, + esClient, spaceId, user, }), diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.mock.ts b/x-pack/plugins/lists/server/services/items/create_list_item.mock.ts index fba978d80d0bf..31befdc2122d3 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_item.mock.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_item.mock.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { CreateListItemOptions } from '../items'; import { DATE_NOW, @@ -19,9 +21,9 @@ import { } from '../../../common/constants.mock'; export const getCreateListItemOptionsMock = (): CreateListItemOptions => ({ - callCluster: getCallClusterMock(), dateNow: DATE_NOW, deserializer: undefined, + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, id: LIST_ITEM_ID, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, 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 cced16b88433e..a13163d8f774a 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 @@ -5,6 +5,9 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { getListItemResponseMock } from '../../../common/schemas/response/list_item_schema.mock'; import { getIndexESListItemMock } from '../../../common/schemas/elastic_query/index_es_list_item_schema.mock'; import { LIST_ITEM_ID, LIST_ITEM_INDEX } from '../../../common/constants.mock'; @@ -23,13 +26,17 @@ describe('crete_list_item', () => { test('it returns a list item as expected with the id changed out for the elastic id', async () => { const options = getCreateListItemOptionsMock(); - const listItem = await createListItem(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.index.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) + ); + const listItem = await createListItem({ ...options, esClient }); const expected = getListItemResponseMock(); expected.id = 'elastic-id-123'; expect(listItem).toEqual(expected); }); - test('It calls "callCluster" with body, index, and listIndex', async () => { + test('It calls "esClient" with body, index, and listIndex', async () => { const options = getCreateListItemOptionsMock(); await createListItem(options); const body = getIndexESListItemMock(); @@ -39,13 +46,17 @@ describe('crete_list_item', () => { index: LIST_ITEM_INDEX, refresh: 'wait_for', }; - expect(options.callCluster).toBeCalledWith('index', expected); + expect(options.esClient.index).toBeCalledWith(expected); }); test('It returns an auto-generated id if id is sent in undefined', async () => { const options = getCreateListItemOptionsMock(); options.id = undefined; - const list = await createListItem(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.index.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) + ); + const list = await createListItem({ ...options, esClient }); const expected = getListItemResponseMock(); expected.id = 'elastic-id-123'; expect(list).toEqual(expected); 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 bac2958857124..a5369bbfe7ca4 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 @@ -7,7 +7,7 @@ import uuid from 'uuid'; import { CreateDocumentResponse } from 'elasticsearch'; -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { DeserializerOrUndefined, @@ -28,7 +28,7 @@ export interface CreateListItemOptions { listId: string; type: Type; value: string; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; user: string; meta: MetaOrUndefined; @@ -43,7 +43,7 @@ export const createListItem = async ({ listId, type, value, - callCluster, + esClient, listItemIndex, user, meta, @@ -69,7 +69,7 @@ export const createListItem = async ({ ...baseBody, ...elasticQuery, }; - const response = await callCluster<CreateDocumentResponse>('index', { + const { body: response } = await esClient.index<CreateDocumentResponse>({ body, id, index: listItemIndex, diff --git a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.mock.ts b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.mock.ts index d6a752df38efc..d2ceb32b91951 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.mock.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.mock.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { CreateListItemsBulkOptions } from '../items'; import { DATE_NOW, @@ -20,9 +22,9 @@ import { } from '../../../common/constants.mock'; export const getCreateListItemBulkOptionsMock = (): CreateListItemsBulkOptions => ({ - callCluster: getCallClusterMock(), dateNow: DATE_NOW, deserializer: undefined, + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, meta: META, diff --git a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts index 38e22e9b19ef6..f9f9728798a0b 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts @@ -20,13 +20,13 @@ describe('crete_list_item_bulk', () => { jest.clearAllMocks(); }); - test('It calls "callCluster" with body, index, and the bulk items', async () => { + test('It calls "esClient" with body, index, and the bulk items', async () => { const options = getCreateListItemBulkOptionsMock(); await createListItemsBulk(options); const firstRecord = getIndexESListItemMock(); const secondRecord = getIndexESListItemMock(VALUE_2); [firstRecord.tie_breaker_id, secondRecord.tie_breaker_id] = TIE_BREAKERS; - expect(options.callCluster).toBeCalledWith('bulk', { + expect(options.esClient.bulk).toBeCalledWith({ body: [ { create: { _index: LIST_ITEM_INDEX } }, firstRecord, @@ -41,7 +41,7 @@ describe('crete_list_item_bulk', () => { test('It should not call the dataClient when the values are empty', async () => { const options = getCreateListItemBulkOptionsMock(); options.value = []; - expect(options.callCluster).not.toBeCalled(); + expect(options.esClient.bulk).not.toBeCalled(); }); test('It should skip over a value if it is not able to add that item because it is not parsable such as an ip_range with a serializer that only matches one ip', async () => { @@ -52,7 +52,7 @@ describe('crete_list_item_bulk', () => { value: ['127.0.0.1', '127.0.0.2'], }; await createListItemsBulk(options); - expect(options.callCluster).toBeCalledWith('bulk', { + expect(options.esClient.bulk).toBeCalledWith({ body: [ { create: { _index: LIST_ITEM_INDEX } }, { diff --git a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts index 0f8b5b7a08595..86d8d9a698b1f 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts @@ -6,7 +6,7 @@ */ import uuid from 'uuid'; -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { transformListItemToElasticQuery } from '../utils'; import { @@ -24,7 +24,7 @@ export interface CreateListItemsBulkOptions { listId: string; type: Type; value: string[]; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; user: string; meta: MetaOrUndefined; @@ -38,7 +38,7 @@ export const createListItemsBulk = async ({ deserializer, serializer, value, - callCluster, + esClient, listItemIndex, user, meta, @@ -82,7 +82,7 @@ export const createListItemsBulk = async ({ [] ); try { - await callCluster('bulk', { + await esClient.bulk({ body, index: listItemIndex, refresh: 'wait_for', diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item.mock.ts b/x-pack/plugins/lists/server/services/items/delete_list_item.mock.ts index 9755afcd5422f..89331d02dc3ff 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item.mock.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item.mock.ts @@ -5,12 +5,14 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { DeleteListItemOptions } from '../items'; import { LIST_ITEM_ID, LIST_ITEM_INDEX } from '../../../common/constants.mock'; export const getDeleteListItemOptionsMock = (): DeleteListItemOptions => ({ - callCluster: getCallClusterMock(), + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, id: LIST_ITEM_ID, listItemIndex: LIST_ITEM_INDEX, }); diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts b/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts index 364b575587d42..de5b6540eee40 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts @@ -50,6 +50,6 @@ describe('delete_list_item', () => { index: LIST_ITEM_INDEX, refresh: 'wait_for', }; - expect(options.callCluster).toBeCalledWith('delete', deleteQuery); + expect(options.esClient.delete).toBeCalledWith(deleteQuery); }); }); diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item.ts b/x-pack/plugins/lists/server/services/items/delete_list_item.ts index 8f1728c5b4a70..f2e9949c82c3e 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { Id, ListItemSchema } from '../../../common/schemas'; @@ -13,20 +13,20 @@ import { getListItem } from '.'; export interface DeleteListItemOptions { id: Id; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; } export const deleteListItem = async ({ id, - callCluster, + esClient, listItemIndex, }: DeleteListItemOptions): Promise<ListItemSchema | null> => { - const listItem = await getListItem({ callCluster, id, listItemIndex }); + const listItem = await getListItem({ esClient, id, listItemIndex }); if (listItem == null) { return null; } else { - await callCluster('delete', { + await esClient.delete({ id, index: listItemIndex, refresh: 'wait_for', diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.mock.ts b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.mock.ts index ef9cc0b46c0c2..54bfe3bae0811 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.mock.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.mock.ts @@ -5,12 +5,14 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { DeleteListItemByValueOptions } from '../items'; import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE } from '../../../common/constants.mock'; export const getDeleteListItemByValueOptionsMock = (): DeleteListItemByValueOptions => ({ - callCluster: getCallClusterMock(), + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, type: TYPE, diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts index c2c7fae942ac3..2755ff8e7aba6 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts @@ -61,8 +61,8 @@ describe('delete_list_item_by_value', () => { }, }, index: '.items', - refresh: 'wait_for', + refresh: false, }; - expect(options.callCluster).toBeCalledWith('deleteByQuery', deleteByQuery); + expect(options.esClient.deleteByQuery).toBeCalledWith(deleteByQuery); }); }); diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts index bf02d30b324b8..1c7ac3afb3ee3 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { ListItemArraySchema, Type } from '../../../common/schemas'; import { getQueryFilterFromTypeValue } from '../utils'; @@ -16,7 +16,7 @@ export interface DeleteListItemByValueOptions { listId: string; type: Type; value: string; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; } @@ -24,11 +24,11 @@ export const deleteListItemByValue = async ({ listId, value, type, - callCluster, + esClient, listItemIndex, }: DeleteListItemByValueOptions): Promise<ListItemArraySchema> => { const listItems = await getListItemByValues({ - callCluster, + esClient, listId, listItemIndex, type, @@ -40,7 +40,7 @@ export const deleteListItemByValue = async ({ type, value: values, }); - await callCluster('deleteByQuery', { + await esClient.deleteByQuery({ body: { query: { bool: { @@ -49,7 +49,7 @@ export const deleteListItemByValue = async ({ }, }, index: listItemIndex, - refresh: 'wait_for', + refresh: false, }); return listItems; }; diff --git a/x-pack/plugins/lists/server/services/items/find_list_item.mock.ts b/x-pack/plugins/lists/server/services/items/find_list_item.mock.ts index 81b9375bb7c4a..4bf62982b2a9f 100644 --- a/x-pack/plugins/lists/server/services/items/find_list_item.mock.ts +++ b/x-pack/plugins/lists/server/services/items/find_list_item.mock.ts @@ -6,11 +6,10 @@ */ import { Client } from 'elasticsearch'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; -import { getSearchListMock } from '../../../common/schemas/elastic_response/search_es_list_schema.mock'; import { getShardMock } from '../../../common/get_shard.mock'; -import { getSearchListItemMock } from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; -import { getCallClusterMockMultiTimes } from '../../../common/get_call_cluster.mock'; import { LIST_ID, LIST_INDEX, LIST_ITEM_INDEX } from '../../../common/constants.mock'; import { FindListItemOptions } from './find_list_item'; @@ -23,14 +22,9 @@ export const getFindCount = (): ReturnType<Client['count']> => { }; export const getFindListItemOptionsMock = (): FindListItemOptions => { - const callCluster = getCallClusterMockMultiTimes([ - getSearchListMock(), - getFindCount(), - getSearchListItemMock(), - ]); return { - callCluster, currentIndexPosition: 0, + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, filter: '', listId: LIST_ID, listIndex: LIST_INDEX, 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 4cd7e4aaef00a..29e6f2f845002 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 @@ -5,9 +5,12 @@ * 2.0. */ -import { getEmptySearchListMock } from '../../../common/schemas/elastic_response/search_es_list_schema.mock'; -import { getCallClusterMockMultiTimes } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + +import { getShardMock } from '../../../common/get_shard.mock'; import { getFoundListItemSchemaMock } from '../../../common/schemas/response/found_list_item_schema.mock'; +import { getEmptySearchListMock } from '../../../common/schemas/elastic_response/search_es_list_schema.mock'; import { getFindListItemOptionsMock } from './find_list_item.mock'; import { findListItem } from './find_list_item'; @@ -15,15 +18,53 @@ import { findListItem } from './find_list_item'; describe('find_list_item', () => { test('should find a simple single list item', async () => { const options = getFindListItemOptionsMock(); - const item = await findListItem(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.count.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ count: 1 }) + ); + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _scroll_id: '123', + _shards: getShardMock(), + hits: { + hits: [ + { + _id: 'some-list-item-id', + _source: { + _version: 'undefined', + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'some user', + date_range: '127.0.0.1', + deserializer: undefined, + list_id: 'some-list-id', + meta: {}, + serializer: undefined, + tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', + type: 'ip', + updated_at: '2020-04-20T15:25:31.830Z', + updated_by: 'some user', + }, + }, + ], + max_score: 0, + total: 1, + }, + timed_out: false, + took: 10, + }) + ); + const item = await findListItem({ ...options, esClient }); const expected = getFoundListItemSchemaMock(); expect(item).toEqual(expected); }); test('should return null if the list is null', async () => { const options = getFindListItemOptionsMock(); - options.callCluster = getCallClusterMockMultiTimes([getEmptySearchListMock()]); - const item = await findListItem(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(getEmptySearchListMock()) + ); + const item = await findListItem({ ...options, esClient }); expect(item).toEqual(null); }); }); 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 e0639bc51ce7b..727c55d53e459 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { @@ -37,13 +37,13 @@ export interface FindListItemOptions { page: Page; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listIndex: string; listItemIndex: string; } export const findListItem = async ({ - callCluster, + esClient, currentIndexPosition, filter, listId, @@ -55,7 +55,7 @@ export const findListItem = async ({ listItemIndex, sortOrder, }: FindListItemOptions): Promise<FoundListItemSchema | null> => { - const list = await getList({ callCluster, id: listId, listIndex }); + const list = await getList({ esClient, id: listId, listIndex }); if (list == null) { return null; } else { @@ -63,8 +63,8 @@ export const findListItem = async ({ const sortField = sortFieldWithPossibleValue === 'value' ? list.type : sortFieldWithPossibleValue; const scroll = await scrollToStartPage({ - callCluster, currentIndexPosition, + esClient, filter, hopSize: 100, index: listItemIndex, @@ -75,25 +75,25 @@ export const findListItem = async ({ sortOrder, }); - const { count } = await callCluster('count', { + const { body: respose } = await esClient.count<{ count: number }>({ body: { query, }, - ignoreUnavailable: true, + ignore_unavailable: true, index: listItemIndex, }); if (scroll.validSearchAfterFound) { - // Note: This typing of response = await callCluster<SearchResponse<SearchEsListSchema>> + // Note: This typing of response = await esClient<SearchResponse<SearchEsListSchema>> // 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 <T>. - const response = await callCluster<SearchResponse<SearchEsListItemSchema>>('search', { + const { body: response } = await esClient.search<SearchResponse<SearchEsListItemSchema>>({ body: { query, search_after: scroll.searchAfter, sort: getSortWithTieBreaker({ sortField, sortOrder }), }, - ignoreUnavailable: true, + ignore_unavailable: true, index: listItemIndex, seq_no_primary_term: true, size: perPage, @@ -107,7 +107,7 @@ export const findListItem = async ({ data: transformElasticToListItem({ response, type: list.type }), page, per_page: perPage, - total: count, + total: respose.count, }; } else { return { @@ -115,7 +115,7 @@ export const findListItem = async ({ data: [], page, per_page: perPage, - total: count, + total: respose.count, }; } } diff --git a/x-pack/plugins/lists/server/services/items/get_list_item.test.ts b/x-pack/plugins/lists/server/services/items/get_list_item.test.ts index 34425f10ec5ad..f92031cae02ca 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item.test.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item.test.ts @@ -5,9 +5,11 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { getSearchListItemMock } from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; import { getListItemResponseMock } from '../../../common/schemas/response/list_item_schema.mock'; -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { DATE_NOW, LIST_ID, @@ -30,8 +32,11 @@ describe('get_list_item', () => { test('it returns a list item as expected if the list item is found', async () => { const data = getSearchListItemMock(); - const callCluster = getCallClusterMock(data); - const list = await getListItem({ callCluster, id: LIST_ID, listItemIndex: LIST_INDEX }); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); + const list = await getListItem({ esClient, id: LIST_ID, listItemIndex: LIST_INDEX }); const expected = getListItemResponseMock(); expect(list).toEqual(expected); }); @@ -39,8 +44,11 @@ describe('get_list_item', () => { test('it returns null if the search is empty', async () => { const data = getSearchListItemMock(); data.hits.hits = []; - const callCluster = getCallClusterMock(data); - const list = await getListItem({ callCluster, id: LIST_ID, listItemIndex: LIST_INDEX }); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); + const list = await getListItem({ esClient, id: LIST_ID, listItemIndex: LIST_INDEX }); expect(list).toEqual(null); }); @@ -80,8 +88,11 @@ describe('get_list_item', () => { updated_at: DATE_NOW, updated_by: USER, }; - const callCluster = getCallClusterMock(data); - const list = await getListItem({ callCluster, id: LIST_ID, listItemIndex: LIST_INDEX }); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); + const list = await getListItem({ esClient, id: LIST_ID, listItemIndex: LIST_INDEX }); expect(list).toEqual(null); }); }); 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 2ccf27e0c00dc..eb05a899478a5 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { Id, ListItemSchema, SearchEsListItemSchema } from '../../../common/schemas'; @@ -14,19 +14,19 @@ import { findSourceType } from '../utils/find_source_type'; interface GetListItemOptions { id: Id; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; } export const getListItem = async ({ id, - callCluster, + esClient, listItemIndex, }: GetListItemOptions): Promise<ListItemSchema | null> => { - // Note: This typing of response = await callCluster<SearchResponse<SearchEsListSchema>> + // Note: This typing of response = await esClient<SearchResponse<SearchEsListSchema>> // 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 <T>. - const listItemES = await callCluster<SearchResponse<SearchEsListItemSchema>>('search', { + const { body: listItemES } = await esClient.search<SearchResponse<SearchEsListItemSchema>>({ body: { query: { term: { @@ -34,7 +34,7 @@ export const getListItem = async ({ }, }, }, - ignoreUnavailable: true, + ignore_unavailable: true, index: listItemIndex, seq_no_primary_term: true, }); diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_value.mock.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_value.mock.ts index 18e7f044bc4ca..3cd329fca3708 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item_by_value.mock.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_value.mock.ts @@ -5,12 +5,14 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { GetListItemByValueOptions } from '../items'; import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE } from '../../../common/constants.mock'; export const getListItemByValueOptionsMocks = (): GetListItemByValueOptions => ({ - callCluster: getCallClusterMock(), + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, type: TYPE, diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_value.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_value.ts index 8af7e3bdc4156..7d3fe81babe59 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item_by_value.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_value.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { ListItemArraySchema, Type } from '../../../common/schemas'; @@ -13,7 +13,7 @@ import { getListItemByValues } from '.'; export interface GetListItemByValueOptions { listId: string; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; type: Type; value: string; @@ -21,13 +21,13 @@ export interface GetListItemByValueOptions { export const getListItemByValue = async ({ listId, - callCluster, + esClient, listItemIndex, type, value, }: GetListItemByValueOptions): Promise<ListItemArraySchema> => getListItemByValues({ - callCluster, + esClient, listId, listItemIndex, type, diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.mock.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.mock.ts index 9496e175dd9a5..169934b2ee256 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.mock.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.mock.ts @@ -5,12 +5,14 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { GetListItemByValuesOptions } from '../items'; import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE, VALUE_2 } from '../../../common/constants.mock'; export const getListItemByValuesOptionsMocks = (): GetListItemByValuesOptions => ({ - callCluster: getCallClusterMock(), + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, type: TYPE, diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.test.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.test.ts index b5db19451063b..aa22049ce6fe4 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.test.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.test.ts @@ -5,8 +5,10 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { getSearchListItemMock } from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { DATE_NOW, LIST_ID, @@ -34,9 +36,12 @@ describe('get_list_item_by_values', () => { test('Returns a an empty array if the ES query is also empty', async () => { const data = getSearchListItemMock(); data.hits.hits = []; - const callCluster = getCallClusterMock(data); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); const listItem = await getListItemByValues({ - callCluster, + esClient, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, type: TYPE, @@ -48,9 +53,12 @@ describe('get_list_item_by_values', () => { test('Returns transformed list item if the data exists within ES', async () => { const data = getSearchListItemMock(); - const callCluster = getCallClusterMock(data); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); const listItem = await getListItemByValues({ - callCluster, + esClient, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, type: TYPE, diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts index 6b76f55f4ccc5..c00ee2b13426a 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts @@ -5,14 +5,18 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { ListItemArraySchema, SearchEsListItemSchema, Type } from '../../../common/schemas'; -import { getQueryFilterFromTypeValue, transformElasticToListItem } from '../utils'; +import { + TransformElasticToListItemOptions, + getQueryFilterFromTypeValue, + transformElasticToListItem, +} from '../utils'; export interface GetListItemByValuesOptions { listId: string; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; type: Type; value: string[]; @@ -20,12 +24,12 @@ export interface GetListItemByValuesOptions { export const getListItemByValues = async ({ listId, - callCluster, + esClient, listItemIndex, type, value, }: GetListItemByValuesOptions): Promise<ListItemArraySchema> => { - const response = await callCluster<SearchEsListItemSchema>('search', { + const { body: response } = await esClient.search<SearchEsListItemSchema>({ body: { query: { bool: { @@ -33,9 +37,12 @@ export const getListItemByValues = async ({ }, }, }, - ignoreUnavailable: true, + ignore_unavailable: true, index: listItemIndex, size: 10000, // TODO: This has a limit on the number which is 10,000 the default of Elastic but we might want to provide a way to increase that number }); - return transformElasticToListItem({ response, type }); + return transformElasticToListItem(({ + response, + type, + } as unknown) as TransformElasticToListItemOptions); }; diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts index 8b8a6a3041351..656b569502fbb 100644 --- a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts @@ -5,12 +5,14 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { SearchListItemByValuesOptions } from '../items'; import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE, VALUE_2 } from '../../../common/constants.mock'; export const searchListItemByValuesOptionsMocks = (): SearchListItemByValuesOptions => ({ - callCluster: getCallClusterMock(), + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, type: TYPE, diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts index d989dd6c92e3a..0d084c50b5745 100644 --- a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts @@ -5,9 +5,11 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { SearchListItemArraySchema } from '../../../common/schemas'; import { getSearchListItemMock } from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE, VALUE_2 } from '../../../common/constants.mock'; import { searchListItemByValues } from './search_list_item_by_values'; @@ -24,9 +26,12 @@ describe('search_list_item_by_values', () => { test('Returns a an empty array of items if the value is empty', async () => { const data = getSearchListItemMock(); data.hits.hits = []; - const callCluster = getCallClusterMock(data); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); const listItem = await searchListItemByValues({ - callCluster, + esClient, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, type: TYPE, @@ -39,9 +44,12 @@ describe('search_list_item_by_values', () => { test('Returns a an empty array of items if the ES query is also empty', async () => { const data = getSearchListItemMock(); data.hits.hits = []; - const callCluster = getCallClusterMock(data); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); const listItem = await searchListItemByValues({ - callCluster, + esClient, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, type: TYPE, @@ -57,9 +65,12 @@ describe('search_list_item_by_values', () => { test('Returns transformed list item if the data exists within ES', async () => { const data = getSearchListItemMock(); - const callCluster = getCallClusterMock(data); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); const listItem = await searchListItemByValues({ - callCluster, + esClient, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, type: TYPE, diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts index f15f57dfbbd07..4f8808d06e425 100644 --- a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts @@ -5,14 +5,18 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { SearchEsListItemSchema, SearchListItemArraySchema, Type } from '../../../common/schemas'; -import { getQueryFilterFromTypeValue, transformElasticNamedSearchToListItem } from '../utils'; +import { + TransformElasticMSearchToListItemOptions, + getQueryFilterFromTypeValue, + transformElasticNamedSearchToListItem, +} from '../utils'; export interface SearchListItemByValuesOptions { listId: string; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; type: Type; value: unknown[]; @@ -20,12 +24,12 @@ export interface SearchListItemByValuesOptions { export const searchListItemByValues = async ({ listId, - callCluster, + esClient, listItemIndex, type, value, }: SearchListItemByValuesOptions): Promise<SearchListItemArraySchema> => { - const response = await callCluster<SearchEsListItemSchema>('search', { + const { body: response } = await esClient.search<SearchEsListItemSchema>({ body: { query: { bool: { @@ -33,9 +37,13 @@ export const searchListItemByValues = async ({ }, }, }, - ignoreUnavailable: true, + ignore_unavailable: true, index: listItemIndex, size: 10000, // TODO: This has a limit on the number which is 10,000 the default of Elastic but we might want to provide a way to increase that number }); - return transformElasticNamedSearchToListItem({ response, type, value }); + return transformElasticNamedSearchToListItem(({ + response, + type, + value, + } as unknown) as TransformElasticMSearchToListItemOptions); }; diff --git a/x-pack/plugins/lists/server/services/items/update_list_item.mock.ts b/x-pack/plugins/lists/server/services/items/update_list_item.mock.ts index c69f087c96a72..705e207853543 100644 --- a/x-pack/plugins/lists/server/services/items/update_list_item.mock.ts +++ b/x-pack/plugins/lists/server/services/items/update_list_item.mock.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { UpdateListItemOptions } from '../items'; import { DATE_NOW, @@ -18,8 +20,8 @@ import { export const getUpdateListItemOptionsMock = (): UpdateListItemOptions => ({ _version: undefined, - callCluster: getCallClusterMock(), dateNow: DATE_NOW, + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, id: LIST_ITEM_ID, listItemIndex: LIST_ITEM_INDEX, meta: META, 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 ec44ae1a3b5fd..ae6b6ad3faecf 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 @@ -5,6 +5,9 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { ListItemSchema } from '../../../common/schemas'; import { getListItemResponseMock } from '../../../common/schemas/response/list_item_schema.mock'; @@ -29,7 +32,11 @@ describe('update_list_item', () => { const listItem = getListItemResponseMock(); ((getListItem as unknown) as jest.Mock).mockResolvedValueOnce(listItem); const options = getUpdateListItemOptionsMock(); - const updatedList = await updateListItem(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.update.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) + ); + const updatedList = await updateListItem({ ...options, esClient }); const expected: ListItemSchema = { ...getListItemResponseMock(), id: 'elastic-id-123' }; expect(updatedList).toEqual(expected); }); 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 7da17ba3c3eb6..645508691acc8 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 @@ -6,7 +6,7 @@ */ import { CreateDocumentResponse } from 'elasticsearch'; -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { Id, @@ -25,7 +25,7 @@ export interface UpdateListItemOptions { _version: _VersionOrUndefined; id: Id; value: string | null | undefined; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; user: string; meta: MetaOrUndefined; @@ -36,14 +36,14 @@ export const updateListItem = async ({ _version, id, value, - callCluster, + esClient, listItemIndex, user, meta, dateNow, }: UpdateListItemOptions): Promise<ListItemSchema | null> => { const updatedAt = dateNow ?? new Date().toISOString(); - const listItem = await getListItem({ callCluster, id, listItemIndex }); + const listItem = await getListItem({ esClient, id, listItemIndex }); if (listItem == null) { return null; } else { @@ -62,7 +62,7 @@ export const updateListItem = async ({ ...elasticQuery, }; - const response = await callCluster<CreateDocumentResponse>('update', { + const { body: response } = await esClient.update<CreateDocumentResponse>({ ...decodeVersion(_version), body: { doc, diff --git a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.mock.ts b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.mock.ts index c59f95e152ba8..949b7a5c1a691 100644 --- a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.mock.ts +++ b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.mock.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { ImportListItemsToStreamOptions, WriteBufferToItemsOptions } from '../items'; import { LIST_ID, @@ -21,9 +23,9 @@ import { getConfigMockDecoded } from '../../config.mock'; import { TestReadable } from './test_readable.mock'; export const getImportListItemsToStreamOptionsMock = (): ImportListItemsToStreamOptions => ({ - callCluster: getCallClusterMock(), config: getConfigMockDecoded(), deserializer: undefined, + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, @@ -37,8 +39,8 @@ export const getImportListItemsToStreamOptionsMock = (): ImportListItemsToStream export const getWriteBufferToItemsOptionsMock = (): WriteBufferToItemsOptions => ({ buffer: [], - callCluster: getCallClusterMock(), deserializer: undefined, + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, meta: META, diff --git a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts index 1dd9aa6d97368..8450890cfa355 100644 --- a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts +++ b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts @@ -7,7 +7,7 @@ import { Readable } from 'stream'; -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { createListIfItDoesNotExist } from '../lists/create_list_if_it_does_not_exist'; import { @@ -31,7 +31,7 @@ export interface ImportListItemsToStreamOptions { deserializer: DeserializerOrUndefined; serializer: SerializerOrUndefined; stream: Readable; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; type: Type; user: string; @@ -45,7 +45,7 @@ export const importListItemsToStream = ({ serializer, listId, stream, - callCluster, + esClient, listItemIndex, listIndex, type, @@ -62,9 +62,9 @@ export const importListItemsToStream = ({ fileName = fileNameEmitted; if (listId == null) { list = await createListIfItDoesNotExist({ - callCluster, description: `File uploaded from file system of ${fileNameEmitted}`, deserializer, + esClient, id: fileNameEmitted, immutable: false, listIndex, @@ -83,8 +83,8 @@ export const importListItemsToStream = ({ if (listId != null) { await writeBufferToItems({ buffer: lines, - callCluster, deserializer, + esClient, listId, listItemIndex, meta, @@ -95,8 +95,8 @@ export const importListItemsToStream = ({ } else if (fileName != null) { await writeBufferToItems({ buffer: lines, - callCluster, deserializer, + esClient, listId: fileName, listItemIndex, meta, @@ -117,7 +117,7 @@ export interface WriteBufferToItemsOptions { listId: string; deserializer: DeserializerOrUndefined; serializer: SerializerOrUndefined; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; buffer: string[]; type: Type; @@ -131,7 +131,7 @@ export interface LinesResult { export const writeBufferToItems = async ({ listId, - callCluster, + esClient, deserializer, serializer, listItemIndex, @@ -141,8 +141,8 @@ export const writeBufferToItems = async ({ meta, }: WriteBufferToItemsOptions): Promise<LinesResult> => { await createListItemsBulk({ - callCluster, deserializer, + esClient, listId, listItemIndex, meta, 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 2f161369c84fb..b096adb2d1a13 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 @@ -5,8 +5,10 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { getSearchListItemMock } from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { LIST_ID, LIST_ITEM_INDEX } from '../../../common/constants.mock'; import { @@ -38,8 +40,11 @@ describe('write_list_items_to_stream', () => { const options = getExportListItemsToStreamOptionsMock(); const firstResponse = getSearchListItemMock(); firstResponse.hits.hits = []; - options.callCluster = getCallClusterMock(firstResponse); - exportListItemsToStream(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(firstResponse) + ); + exportListItemsToStream({ ...options, esClient }); let chunks: string[] = []; options.stream.on('data', (chunk: Buffer) => { @@ -54,7 +59,12 @@ describe('write_list_items_to_stream', () => { test('It exports single list item to the stream', (done) => { const options = getExportListItemsToStreamOptionsMock(); - exportListItemsToStream(options); + const response = getSearchListItemMock(); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); + exportListItemsToStream({ ...options, esClient }); let chunks: string[] = []; options.stream.on('data', (chunk: Buffer) => { @@ -72,8 +82,11 @@ describe('write_list_items_to_stream', () => { const firstResponse = getSearchListItemMock(); const secondResponse = getSearchListItemMock(); firstResponse.hits.hits = [...firstResponse.hits.hits, ...secondResponse.hits.hits]; - options.callCluster = getCallClusterMock(firstResponse); - exportListItemsToStream(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(firstResponse) + ); + exportListItemsToStream({ ...options, esClient }); let chunks: string[] = []; options.stream.on('data', (chunk: Buffer) => { @@ -95,12 +108,14 @@ describe('write_list_items_to_stream', () => { const secondResponse = getSearchListItemMock(); secondResponse.hits.hits[0]._source.ip = '255.255.255.255'; - options.callCluster = jest - .fn() - .mockResolvedValueOnce(firstResponse) - .mockResolvedValueOnce(secondResponse); - - exportListItemsToStream(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(firstResponse) + ); + esClient.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(secondResponse) + ); + exportListItemsToStream({ ...options, esClient }); let chunks: string[] = []; options.stream.on('data', (chunk: Buffer) => { @@ -117,7 +132,12 @@ describe('write_list_items_to_stream', () => { describe('writeNextResponse', () => { test('It returns an empty searchAfter response when there is no sort defined', async () => { const options = getWriteNextResponseOptions(); - const searchAfter = await writeNextResponse(options); + const listItem = getSearchListItemMock(); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(listItem) + ); + const searchAfter = await writeNextResponse({ ...options, esClient }); expect(searchAfter).toEqual(undefined); }); @@ -125,8 +145,11 @@ describe('write_list_items_to_stream', () => { const listItem = getSearchListItemMock(); listItem.hits.hits[0].sort = ['sort-value-1']; const options = getWriteNextResponseOptions(); - options.callCluster = getCallClusterMock(listItem); - const searchAfter = await writeNextResponse(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(listItem) + ); + const searchAfter = await writeNextResponse({ ...options, esClient }); expect(searchAfter).toEqual(['sort-value-1']); }); @@ -134,8 +157,11 @@ describe('write_list_items_to_stream', () => { const listItem = getSearchListItemMock(); listItem.hits.hits = []; const options = getWriteNextResponseOptions(); - options.callCluster = getCallClusterMock(listItem); - const searchAfter = await writeNextResponse(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(listItem) + ); + const searchAfter = await writeNextResponse({ ...options, esClient }); expect(searchAfter).toEqual(undefined); }); }); @@ -183,11 +209,11 @@ describe('write_list_items_to_stream', () => { search_after: ['string 1', 'string 2'], sort: [{ tie_breaker_id: 'asc' }], }, - ignoreUnavailable: true, + ignore_unavailable: true, index: LIST_ITEM_INDEX, size: 100, }; - expect(options.callCluster).toBeCalledWith('search', expected); + expect(options.esClient.search).toBeCalledWith(expected); }); test('It returns a simple response with expected values and size changed', async () => { @@ -201,11 +227,11 @@ describe('write_list_items_to_stream', () => { search_after: ['string 1', 'string 2'], sort: [{ tie_breaker_id: 'asc' }], }, - ignoreUnavailable: true, + ignore_unavailable: true, index: LIST_ITEM_INDEX, size: 33, }; - expect(options.callCluster).toBeCalledWith('search', expected); + expect(options.esClient.search).toBeCalledWith(expected); }); }); 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 58b09d9e466d3..9bdcb58835ab0 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 @@ -8,7 +8,7 @@ import { PassThrough } from 'stream'; import { SearchResponse } from 'elasticsearch'; -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { SearchEsListItemSchema } from '../../../common/schemas'; import { ErrorWithStatusCode } from '../../error_with_status_code'; @@ -22,7 +22,7 @@ export const SIZE = 100; export interface ExportListItemsToStreamOptions { listId: string; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; stream: PassThrough; stringToAppend: string | null | undefined; @@ -30,7 +30,7 @@ export interface ExportListItemsToStreamOptions { export const exportListItemsToStream = ({ listId, - callCluster, + esClient, stream, listItemIndex, stringToAppend, @@ -39,7 +39,7 @@ export const exportListItemsToStream = ({ // and prevent the async await from bubbling up to the caller setTimeout(async () => { let searchAfter = await writeNextResponse({ - callCluster, + esClient, listId, listItemIndex, searchAfter: undefined, @@ -48,7 +48,7 @@ export const exportListItemsToStream = ({ }); while (searchAfter != null) { searchAfter = await writeNextResponse({ - callCluster, + esClient, listId, listItemIndex, searchAfter, @@ -62,7 +62,7 @@ export const exportListItemsToStream = ({ export interface WriteNextResponseOptions { listId: string; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listItemIndex: string; stream: PassThrough; searchAfter: string[] | undefined; @@ -71,14 +71,14 @@ export interface WriteNextResponseOptions { export const writeNextResponse = async ({ listId, - callCluster, + esClient, stream, listItemIndex, searchAfter, stringToAppend, }: WriteNextResponseOptions): Promise<string[] | undefined> => { const response = await getResponse({ - callCluster, + esClient, listId, listItemIndex, searchAfter, @@ -102,7 +102,7 @@ export const getSearchAfterFromResponse = <T>({ : undefined; export interface GetResponseOptions { - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listId: string; searchAfter: undefined | string[]; listItemIndex: string; @@ -110,26 +110,28 @@ export interface GetResponseOptions { } export const getResponse = async ({ - callCluster, + esClient, searchAfter, listId, listItemIndex, size = SIZE, }: GetResponseOptions): Promise<SearchResponse<SearchEsListItemSchema>> => { - return callCluster<SearchEsListItemSchema>('search', { - body: { - query: { - term: { - list_id: listId, + return (( + await esClient.search<SearchEsListItemSchema>({ + body: { + query: { + term: { + list_id: listId, + }, }, + search_after: searchAfter, + sort: [{ tie_breaker_id: 'asc' }], }, - search_after: searchAfter, - sort: [{ tie_breaker_id: 'asc' }], - }, - ignoreUnavailable: true, - index: listItemIndex, - size, - }); + ignore_unavailable: true, + index: listItemIndex, + size, + }) + ).body as unknown) as SearchResponse<SearchEsListItemSchema>; }; export interface WriteResponseHitsToStreamOptions { diff --git a/x-pack/plugins/lists/server/services/items/write_list_items_to_streams.mock.ts b/x-pack/plugins/lists/server/services/items/write_list_items_to_streams.mock.ts index 676613a205042..3de8fdb0c9df6 100644 --- a/x-pack/plugins/lists/server/services/items/write_list_items_to_streams.mock.ts +++ b/x-pack/plugins/lists/server/services/items/write_list_items_to_streams.mock.ts @@ -7,8 +7,10 @@ import { Stream } from 'stream'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { getSearchListItemMock } from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { ExportListItemsToStreamOptions, GetResponseOptions, @@ -18,7 +20,7 @@ import { import { LIST_ID, LIST_ITEM_INDEX } from '../../../common/constants.mock'; export const getExportListItemsToStreamOptionsMock = (): ExportListItemsToStreamOptions => ({ - callCluster: getCallClusterMock(getSearchListItemMock()), + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, stream: new Stream.PassThrough(), @@ -26,7 +28,7 @@ export const getExportListItemsToStreamOptionsMock = (): ExportListItemsToStream }); export const getWriteNextResponseOptions = (): WriteNextResponseOptions => ({ - callCluster: getCallClusterMock(getSearchListItemMock()), + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, searchAfter: [], @@ -35,7 +37,7 @@ export const getWriteNextResponseOptions = (): WriteNextResponseOptions => ({ }); export const getResponseOptionsMock = (): GetResponseOptions => ({ - callCluster: getCallClusterMock(), + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, listId: LIST_ID, listItemIndex: LIST_ITEM_INDEX, searchAfter: [], diff --git a/x-pack/plugins/lists/server/services/lists/create_list.mock.ts b/x-pack/plugins/lists/server/services/lists/create_list.mock.ts index c3ddb3bfc56ae..5e9c8e38c3f5e 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.mock.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { CreateListOptions } from '../lists'; import { DATE_NOW, @@ -22,10 +24,10 @@ import { } from '../../../common/constants.mock'; export const getCreateListOptionsMock = (): CreateListOptions => ({ - callCluster: getCallClusterMock(), dateNow: DATE_NOW, description: DESCRIPTION, deserializer: undefined, + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, id: LIST_ID, immutable: IMMUTABLE, listIndex: LIST_INDEX, 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 dbbb7d6e6c5f1..6fc556955fae3 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 @@ -5,6 +5,9 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { ListSchema } from '../../../common/schemas'; import { getListResponseMock } from '../../../common/schemas/response/list_schema.mock'; import { getIndexESListMock } from '../../../common/schemas/elastic_query/index_es_list_schema.mock'; @@ -24,7 +27,11 @@ describe('crete_list', () => { test('it returns a list as expected with the id changed out for the elastic id', async () => { const options = getCreateListOptionsMock(); - const list = await createList(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.index.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) + ); + const list = await createList({ ...options, esClient }); const expected: ListSchema = { ...getListResponseMock(), id: 'elastic-id-123' }; expect(list).toEqual(expected); }); @@ -35,7 +42,11 @@ describe('crete_list', () => { deserializer: '{{value}}', serializer: '(?<value>)', }; - const list = await createList(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.index.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) + ); + const list = await createList({ ...options, esClient }); const expected: ListSchema = { ...getListResponseMock(), deserializer: '{{value}}', @@ -45,7 +56,7 @@ describe('crete_list', () => { expect(list).toEqual(expected); }); - test('It calls "callCluster" with body, index, and listIndex', async () => { + test('It calls "esClient" with body, index, and listIndex', async () => { const options = getCreateListOptionsMock(); await createList(options); const body = getIndexESListMock(); @@ -55,13 +66,17 @@ describe('crete_list', () => { index: LIST_INDEX, refresh: 'wait_for', }; - expect(options.callCluster).toBeCalledWith('index', expected); + expect(options.esClient.index).toBeCalledWith(expected); }); test('It returns an auto-generated id if id is sent in undefined', async () => { const options = getCreateListOptionsMock(); options.id = undefined; - const list = await createList(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.index.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) + ); + const list = await createList({ ...options, esClient }); const expected: ListSchema = { ...getListResponseMock(), id: 'elastic-id-123' }; expect(list).toEqual(expected); }); 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 999b29bcb08fd..2671a23266ec9 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.ts @@ -7,7 +7,7 @@ import uuid from 'uuid'; import { CreateDocumentResponse } from 'elasticsearch'; -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { encodeHitVersion } from '../utils/encode_hit_version'; import { @@ -31,7 +31,7 @@ export interface CreateListOptions { type: Type; name: Name; description: Description; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listIndex: string; user: string; meta: MetaOrUndefined; @@ -48,7 +48,7 @@ export const createList = async ({ name, type, description, - callCluster, + esClient, listIndex, user, meta, @@ -73,7 +73,7 @@ export const createList = async ({ updated_by: user, version, }; - const response = await callCluster<CreateDocumentResponse>('index', { + const { body: response } = await esClient.index<CreateDocumentResponse>({ body, id, index: listIndex, diff --git a/x-pack/plugins/lists/server/services/lists/create_list_if_it_does_not_exist.ts b/x-pack/plugins/lists/server/services/lists/create_list_if_it_does_not_exist.ts index 0f1fd196d3dc2..5325d951626c7 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list_if_it_does_not_exist.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list_if_it_does_not_exist.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { Description, @@ -31,7 +31,7 @@ export interface CreateListIfItDoesNotExistOptions { serializer: SerializerOrUndefined; description: Description; immutable: Immutable; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listIndex: string; user: string; meta: MetaOrUndefined; @@ -46,7 +46,7 @@ export const createListIfItDoesNotExist = async ({ type, description, deserializer, - callCluster, + esClient, listIndex, user, meta, @@ -56,13 +56,13 @@ export const createListIfItDoesNotExist = async ({ version, immutable, }: CreateListIfItDoesNotExistOptions): Promise<ListSchema> => { - const list = await getList({ callCluster, id, listIndex }); + const list = await getList({ esClient, id, listIndex }); if (list == null) { return createList({ - callCluster, dateNow, description, deserializer, + esClient, id, immutable, listIndex, diff --git a/x-pack/plugins/lists/server/services/lists/delete_list.mock.ts b/x-pack/plugins/lists/server/services/lists/delete_list.mock.ts index f231213753762..569083aad40db 100644 --- a/x-pack/plugins/lists/server/services/lists/delete_list.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/delete_list.mock.ts @@ -5,12 +5,14 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { DeleteListOptions } from '../lists'; import { LIST_ID, LIST_INDEX, LIST_ITEM_INDEX } from '../../../common/constants.mock'; export const getDeleteListOptionsMock = (): DeleteListOptions => ({ - callCluster: getCallClusterMock(), + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, id: LIST_ID, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, diff --git a/x-pack/plugins/lists/server/services/lists/delete_list.test.ts b/x-pack/plugins/lists/server/services/lists/delete_list.test.ts index 8742123238717..9ceecbc299bab 100644 --- a/x-pack/plugins/lists/server/services/lists/delete_list.test.ts +++ b/x-pack/plugins/lists/server/services/lists/delete_list.test.ts @@ -48,9 +48,9 @@ describe('delete_list', () => { const deleteByQuery = { body: { query: { term: { list_id: LIST_ID } } }, index: LIST_ITEM_INDEX, - refresh: 'wait_for', + refresh: false, }; - expect(options.callCluster).toBeCalledWith('deleteByQuery', deleteByQuery); + expect(options.esClient.deleteByQuery).toBeCalledWith(deleteByQuery); }); test('Delete calls "delete" second if a list is returned from getList', async () => { @@ -61,15 +61,15 @@ describe('delete_list', () => { const deleteQuery = { id: LIST_ID, index: LIST_INDEX, - refresh: 'wait_for', + refresh: false, }; - expect(options.callCluster).toHaveBeenNthCalledWith(2, 'delete', deleteQuery); + expect(options.esClient.delete).toHaveBeenNthCalledWith(1, deleteQuery); }); test('Delete does not call data client if the list returns null', async () => { ((getList as unknown) as jest.Mock).mockResolvedValueOnce(null); const options = getDeleteListOptionsMock(); await deleteList(options); - expect(options.callCluster).not.toHaveBeenCalled(); + expect(options.esClient.delete).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/lists/server/services/lists/delete_list.ts b/x-pack/plugins/lists/server/services/lists/delete_list.ts index cac0189d789b6..4fe200bff436f 100644 --- a/x-pack/plugins/lists/server/services/lists/delete_list.ts +++ b/x-pack/plugins/lists/server/services/lists/delete_list.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { Id, ListSchema } from '../../../common/schemas'; @@ -13,22 +13,22 @@ import { getList } from './get_list'; export interface DeleteListOptions { id: Id; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listIndex: string; listItemIndex: string; } export const deleteList = async ({ id, - callCluster, + esClient, listIndex, listItemIndex, }: DeleteListOptions): Promise<ListSchema | null> => { - const list = await getList({ callCluster, id, listIndex }); + const list = await getList({ esClient, id, listIndex }); if (list == null) { return null; } else { - await callCluster('deleteByQuery', { + await esClient.deleteByQuery({ body: { query: { term: { @@ -37,13 +37,13 @@ export const deleteList = async ({ }, }, index: listItemIndex, - refresh: 'wait_for', + refresh: false, }); - await callCluster('delete', { + await esClient.delete({ id, index: listIndex, - refresh: 'wait_for', + refresh: false, }); return list; } 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 c6b995c5102c8..c5a398b0a1ad0 100644 --- a/x-pack/plugins/lists/server/services/lists/find_list.ts +++ b/x-pack/plugins/lists/server/services/lists/find_list.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { @@ -34,12 +34,12 @@ interface FindListOptions { page: Page; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listIndex: string; } export const findList = async ({ - callCluster, + esClient, currentIndexPosition, filter, page, @@ -52,8 +52,8 @@ export const findList = async ({ const query = getQueryFilter({ filter }); const scroll = await scrollToStartPage({ - callCluster, currentIndexPosition, + esClient, filter, hopSize: 100, index: listIndex, @@ -64,25 +64,25 @@ export const findList = async ({ sortOrder, }); - const { count } = await callCluster('count', { + const { body: totalCount } = await esClient.count({ body: { query, }, - ignoreUnavailable: true, + ignore_unavailable: true, index: listIndex, }); if (scroll.validSearchAfterFound) { - // Note: This typing of response = await callCluster<SearchResponse<SearchEsListSchema>> + // Note: This typing of response = await esClient<SearchResponse<SearchEsListSchema>> // 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 <T>. - const response = await callCluster<SearchResponse<SearchEsListSchema>>('search', { + const { body: response } = await esClient.search<SearchResponse<SearchEsListSchema>>({ body: { query, search_after: scroll.searchAfter, sort: getSortWithTieBreaker({ sortField, sortOrder }), }, - ignoreUnavailable: true, + ignore_unavailable: true, index: listIndex, seq_no_primary_term: true, size: perPage, @@ -96,7 +96,7 @@ export const findList = async ({ data: transformElasticToList({ response }), page, per_page: perPage, - total: count, + total: totalCount.count, }; } else { return { @@ -104,7 +104,7 @@ export const findList = async ({ data: [], page, per_page: perPage, - total: count, + total: totalCount.count, }; } }; diff --git a/x-pack/plugins/lists/server/services/lists/get_list.test.ts b/x-pack/plugins/lists/server/services/lists/get_list.test.ts index 9d1b8d8d02fe8..930a52266ba41 100644 --- a/x-pack/plugins/lists/server/services/lists/get_list.test.ts +++ b/x-pack/plugins/lists/server/services/lists/get_list.test.ts @@ -5,9 +5,11 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { getSearchListMock } from '../../../common/schemas/elastic_response/search_es_list_schema.mock'; import { getListResponseMock } from '../../../common/schemas/response/list_schema.mock'; -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { LIST_ID, LIST_INDEX } from '../../../common/constants.mock'; import { getList } from './get_list'; @@ -23,8 +25,11 @@ describe('get_list', () => { test('it returns a list as expected if the list is found', async () => { const data = getSearchListMock(); - const callCluster = getCallClusterMock(data); - const list = await getList({ callCluster, id: LIST_ID, listIndex: LIST_INDEX }); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); + const list = await getList({ esClient, id: LIST_ID, listIndex: LIST_INDEX }); const expected = getListResponseMock(); expect(list).toEqual(expected); }); @@ -32,8 +37,11 @@ describe('get_list', () => { test('it returns null if the search is empty', async () => { const data = getSearchListMock(); data.hits.hits = []; - const callCluster = getCallClusterMock(data); - const list = await getList({ callCluster, id: LIST_ID, listIndex: LIST_INDEX }); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(data) + ); + const list = await getList({ esClient, id: LIST_ID, listIndex: LIST_INDEX }); expect(list).toEqual(null); }); }); 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 a4c45ef6ab0d4..50e6d08dd80ff 100644 --- a/x-pack/plugins/lists/server/services/lists/get_list.ts +++ b/x-pack/plugins/lists/server/services/lists/get_list.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { Id, ListSchema, SearchEsListSchema } from '../../../common/schemas'; @@ -13,19 +13,19 @@ import { transformElasticToList } from '../utils/transform_elastic_to_list'; interface GetListOptions { id: Id; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listIndex: string; } export const getList = async ({ id, - callCluster, + esClient, listIndex, }: GetListOptions): Promise<ListSchema | null> => { - // Note: This typing of response = await callCluster<SearchResponse<SearchEsListSchema>> + // Note: This typing of response = await esClient<SearchResponse<SearchEsListSchema>> // 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 <T>. - const response = await callCluster<SearchResponse<SearchEsListSchema>>('search', { + const { body: response } = await esClient.search<SearchResponse<SearchEsListSchema>>({ body: { query: { term: { @@ -33,7 +33,7 @@ export const getList = async ({ }, }, }, - ignoreUnavailable: true, + ignore_unavailable: true, index: listIndex, seq_no_primary_term: true, }); diff --git a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts index c49f73cfb0009..08c14534ac345 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts @@ -5,11 +5,13 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { getFoundListItemSchemaMock } from '../../../common/schemas/response/found_list_item_schema.mock'; import { getFoundListSchemaMock } from '../../../common/schemas/response/found_list_schema.mock'; import { getListItemResponseMock } from '../../../common/schemas/response/list_item_schema.mock'; import { getListResponseMock } from '../../../common/schemas/response/list_schema.mock'; -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { IMPORT_BUFFER_SIZE, IMPORT_TIMEOUT, @@ -63,7 +65,6 @@ export class ListClientMock extends ListClient { export const getListClientMock = (): ListClient => { const mock = new ListClientMock({ - callCluster: getCallClusterMock(), config: { enabled: true, importBufferSize: IMPORT_BUFFER_SIZE, @@ -72,6 +73,7 @@ export const getListClientMock = (): ListClient => { listItemIndex: LIST_ITEM_INDEX, maxImportPayloadBytes: MAX_IMPORT_PAYLOAD_BYTES, }, + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, spaceId: 'default', user: 'elastic', }); diff --git a/x-pack/plugins/lists/server/services/lists/list_client.ts b/x-pack/plugins/lists/server/services/lists/list_client.ts index 1d10dc8eff926..0b9bfbed28d83 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { FoundListItemSchema, @@ -80,13 +80,13 @@ export class ListClient { private readonly spaceId: string; private readonly user: string; private readonly config: ConfigType; - private readonly callCluster: LegacyAPICaller; + private readonly esClient: ElasticsearchClient; - constructor({ spaceId, user, config, callCluster }: ConstructorOptions) { + constructor({ spaceId, user, config, esClient }: ConstructorOptions) { this.spaceId = spaceId; this.user = user; this.config = config; - this.callCluster = callCluster; + this.esClient = esClient; } public getListIndex = (): string => { @@ -106,9 +106,9 @@ export class ListClient { }; public getList = async ({ id }: GetListOptions): Promise<ListSchema | null> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); - return getList({ callCluster, id, listIndex }); + return getList({ esClient, id, listIndex }); }; public createList = async ({ @@ -122,12 +122,12 @@ export class ListClient { meta, version, }: CreateListOptions): Promise<ListSchema> => { - const { callCluster, user } = this; + const { esClient, user } = this; const listIndex = this.getListIndex(); return createList({ - callCluster, description, deserializer, + esClient, id, immutable, listIndex, @@ -151,12 +151,12 @@ export class ListClient { meta, version, }: CreateListIfItDoesNotExistOptions): Promise<ListSchema> => { - const { callCluster, user } = this; + const { esClient, user } = this; const listIndex = this.getListIndex(); return createListIfItDoesNotExist({ - callCluster, description, deserializer, + esClient, id, immutable, listIndex, @@ -170,51 +170,51 @@ export class ListClient { }; public getListIndexExists = async (): Promise<boolean> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); - return getIndexExists(callCluster, listIndex); + return getIndexExists(esClient, listIndex); }; public getListItemIndexExists = async (): Promise<boolean> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); - return getIndexExists(callCluster, listItemIndex); + return getIndexExists(esClient, listItemIndex); }; public createListBootStrapIndex = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); - return createBootstrapIndex(callCluster, listIndex); + return createBootstrapIndex(esClient, listIndex); }; public createListItemBootStrapIndex = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); - return createBootstrapIndex(callCluster, listItemIndex); + return createBootstrapIndex(esClient, listItemIndex); }; public getListPolicyExists = async (): Promise<boolean> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); - return getPolicyExists(callCluster, listIndex); + return getPolicyExists(esClient, listIndex); }; public getListItemPolicyExists = async (): Promise<boolean> => { - const { callCluster } = this; + const { esClient } = this; const listsItemIndex = this.getListItemIndex(); - return getPolicyExists(callCluster, listsItemIndex); + return getPolicyExists(esClient, listsItemIndex); }; public getListTemplateExists = async (): Promise<boolean> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); - return getTemplateExists(callCluster, listIndex); + return getTemplateExists(esClient, listIndex); }; public getListItemTemplateExists = async (): Promise<boolean> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); - return getTemplateExists(callCluster, listItemIndex); + return getTemplateExists(esClient, listItemIndex); }; public getListTemplate = (): Record<string, unknown> => { @@ -228,71 +228,71 @@ export class ListClient { }; public setListTemplate = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const template = this.getListTemplate(); const listIndex = this.getListIndex(); - return setTemplate(callCluster, listIndex, template); + return setTemplate(esClient, listIndex, template); }; public setListItemTemplate = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const template = this.getListItemTemplate(); const listItemIndex = this.getListItemIndex(); - return setTemplate(callCluster, listItemIndex, template); + return setTemplate(esClient, listItemIndex, template); }; public setListPolicy = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); - return setPolicy(callCluster, listIndex, listPolicy); + return setPolicy(esClient, listIndex, listPolicy); }; public setListItemPolicy = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); - return setPolicy(callCluster, listItemIndex, listsItemsPolicy); + return setPolicy(esClient, listItemIndex, listsItemsPolicy); }; public deleteListIndex = async (): Promise<boolean> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); - return deleteAllIndex(callCluster, `${listIndex}-*`); + return deleteAllIndex(esClient, `${listIndex}-*`); }; public deleteListItemIndex = async (): Promise<boolean> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); - return deleteAllIndex(callCluster, `${listItemIndex}-*`); + return deleteAllIndex(esClient, `${listItemIndex}-*`); }; public deleteListPolicy = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); - return deletePolicy(callCluster, listIndex); + return deletePolicy(esClient, listIndex); }; public deleteListItemPolicy = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); - return deletePolicy(callCluster, listItemIndex); + return deletePolicy(esClient, listItemIndex); }; public deleteListTemplate = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); - return deleteTemplate(callCluster, listIndex); + return deleteTemplate(esClient, listIndex); }; public deleteListItemTemplate = async (): Promise<unknown> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); - return deleteTemplate(callCluster, listItemIndex); + return deleteTemplate(esClient, listItemIndex); }; public deleteListItem = async ({ id }: DeleteListItemOptions): Promise<ListItemSchema | null> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); - return deleteListItem({ callCluster, id, listItemIndex }); + return deleteListItem({ esClient, id, listItemIndex }); }; public deleteListItemByValue = async ({ @@ -300,10 +300,10 @@ export class ListClient { value, type, }: DeleteListItemByValueOptions): Promise<ListItemArraySchema> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); return deleteListItemByValue({ - callCluster, + esClient, listId, listItemIndex, type, @@ -312,11 +312,11 @@ export class ListClient { }; public deleteList = async ({ id }: DeleteListOptions): Promise<ListSchema | null> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); const listItemIndex = this.getListItemIndex(); return deleteList({ - callCluster, + esClient, id, listIndex, listItemIndex, @@ -328,10 +328,10 @@ export class ListClient { listId, stream, }: ExportListItemsToStreamOptions): void => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); exportListItemsToStream({ - callCluster, + esClient, listId, listItemIndex, stream, @@ -348,13 +348,13 @@ export class ListClient { meta, version, }: ImportListItemsToStreamOptions): Promise<ListSchema | null> => { - const { callCluster, user, config } = this; + const { esClient, user, config } = this; const listItemIndex = this.getListItemIndex(); const listIndex = this.getListIndex(); return importListItemsToStream({ - callCluster, config, deserializer, + esClient, listId, listIndex, listItemIndex, @@ -372,10 +372,10 @@ export class ListClient { value, type, }: GetListItemByValueOptions): Promise<ListItemArraySchema> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); return getListItemByValue({ - callCluster, + esClient, listId, listItemIndex, type, @@ -392,11 +392,11 @@ export class ListClient { type, meta, }: CreateListItemOptions): Promise<ListItemSchema | null> => { - const { callCluster, user } = this; + const { esClient, user } = this; const listItemIndex = this.getListItemIndex(); return createListItem({ - callCluster, deserializer, + esClient, id, listId, listItemIndex, @@ -414,11 +414,11 @@ export class ListClient { value, meta, }: UpdateListItemOptions): Promise<ListItemSchema | null> => { - const { callCluster, user } = this; + const { esClient, user } = this; const listItemIndex = this.getListItemIndex(); return updateListItem({ _version, - callCluster, + esClient, id, listItemIndex, meta, @@ -435,12 +435,12 @@ export class ListClient { meta, version, }: UpdateListOptions): Promise<ListSchema | null> => { - const { callCluster, user } = this; + const { esClient, user } = this; const listIndex = this.getListIndex(); return updateList({ _version, - callCluster, description, + esClient, id, listIndex, meta, @@ -451,10 +451,10 @@ export class ListClient { }; public getListItem = async ({ id }: GetListItemOptions): Promise<ListItemSchema | null> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); return getListItem({ - callCluster, + esClient, id, listItemIndex, }); @@ -465,10 +465,10 @@ export class ListClient { listId, value, }: GetListItemsByValueOptions): Promise<ListItemArraySchema> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); return getListItemByValues({ - callCluster, + esClient, listId, listItemIndex, type, @@ -481,10 +481,10 @@ export class ListClient { listId, value, }: SearchListItemByValuesOptions): Promise<SearchListItemArraySchema> => { - const { callCluster } = this; + const { esClient } = this; const listItemIndex = this.getListItemIndex(); return searchListItemByValues({ - callCluster, + esClient, listId, listItemIndex, type, @@ -501,11 +501,11 @@ export class ListClient { sortOrder, searchAfter, }: FindListOptions): Promise<FoundListSchema> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); return findList({ - callCluster, currentIndexPosition, + esClient, filter, listIndex, page, @@ -526,12 +526,12 @@ export class ListClient { sortOrder, searchAfter, }: FindListItemOptions): Promise<FoundListItemSchema | null> => { - const { callCluster } = this; + const { esClient } = this; const listIndex = this.getListIndex(); const listItemIndex = this.getListItemIndex(); return findListItem({ - callCluster, currentIndexPosition, + esClient, filter, listId, listIndex, diff --git a/x-pack/plugins/lists/server/services/lists/list_client_types.ts b/x-pack/plugins/lists/server/services/lists/list_client_types.ts index 54fd4f83e2d83..1efcd2af5420e 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client_types.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client_types.ts @@ -7,7 +7,7 @@ import { PassThrough, Readable } from 'stream'; -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { Description, @@ -35,7 +35,7 @@ import { import { ConfigType } from '../../config'; export interface ConstructorOptions { - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; config: ConfigType; spaceId: string; user: string; diff --git a/x-pack/plugins/lists/server/services/lists/update_list.mock.ts b/x-pack/plugins/lists/server/services/lists/update_list.mock.ts index 313ab5bb45e2f..5648a8df7dde8 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.mock.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { UpdateListOptions } from '../lists'; import { DATE_NOW, @@ -20,9 +22,9 @@ import { export const getUpdateListOptionsMock = (): UpdateListOptions => ({ _version: undefined, - callCluster: getCallClusterMock(), dateNow: DATE_NOW, description: DESCRIPTION, + esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser, id: LIST_ID, listIndex: LIST_INDEX, meta: META, 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 ff9a6f598db23..e2d3b09fe518a 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 @@ -5,6 +5,9 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + import { ListSchema } from '../../../common/schemas'; import { getListResponseMock } from '../../../common/schemas/response/list_schema.mock'; @@ -29,7 +32,11 @@ describe('update_list', () => { const list = getListResponseMock(); ((getList as unknown) as jest.Mock).mockResolvedValueOnce(list); const options = getUpdateListOptionsMock(); - const updatedList = await updateList(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.update.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) + ); + const updatedList = await updateList({ ...options, esClient }); const expected: ListSchema = { ...getListResponseMock(), id: 'elastic-id-123' }; expect(updatedList).toEqual(expected); }); @@ -42,7 +49,11 @@ describe('update_list', () => { }; ((getList as unknown) as jest.Mock).mockResolvedValueOnce(list); const options = getUpdateListOptionsMock(); - const updatedList = await updateList(options); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.update.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) + ); + const updatedList = await updateList({ ...options, esClient }); const expected: ListSchema = { ...getListResponseMock(), deserializer: '{{value}}', 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 05939d86189c5..aa4eb9a8d834f 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.ts @@ -6,7 +6,7 @@ */ import { CreateDocumentResponse } from 'elasticsearch'; -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { decodeVersion } from '../utils/decode_version'; import { encodeHitVersion } from '../utils/encode_hit_version'; @@ -26,7 +26,7 @@ import { getList } from '.'; export interface UpdateListOptions { _version: _VersionOrUndefined; id: Id; - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; listIndex: string; user: string; name: NameOrUndefined; @@ -41,7 +41,7 @@ export const updateList = async ({ id, name, description, - callCluster, + esClient, listIndex, user, meta, @@ -49,7 +49,7 @@ export const updateList = async ({ version, }: UpdateListOptions): Promise<ListSchema | null> => { const updatedAt = dateNow ?? new Date().toISOString(); - const list = await getList({ callCluster, id, listIndex }); + const list = await getList({ esClient, id, listIndex }); if (list == null) { return null; } else { @@ -61,7 +61,7 @@ export const updateList = async ({ updated_at: updatedAt, updated_by: user, }; - const response = await callCluster<CreateDocumentResponse>('update', { + const { body: response } = await esClient.update<CreateDocumentResponse>({ ...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 ef9b2b4d93e5f..34359a7a9c697 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 @@ -5,7 +5,8 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; +import { SearchResponse } from 'elasticsearch'; import { Filter, SortFieldOrUndefined, SortOrderOrUndefined } from '../../../common/schemas'; import { Scroll } from '../lists/types'; @@ -16,7 +17,7 @@ import { getSourceWithTieBreaker } from './get_source_with_tie_breaker'; import { TieBreaker, getSearchAfterWithTieBreaker } from './get_search_after_with_tie_breaker'; interface GetSearchAfterOptions { - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; filter: Filter; hops: number; hopSize: number; @@ -27,7 +28,7 @@ interface GetSearchAfterOptions { } export const getSearchAfterScroll = async <T>({ - callCluster, + esClient, filter, hopSize, hops, @@ -39,14 +40,14 @@ export const getSearchAfterScroll = async <T>({ const query = getQueryFilter({ filter }); let newSearchAfter = searchAfter; for (let i = 0; i < hops; ++i) { - const response = await callCluster<TieBreaker<T>>('search', { + const { body: response } = await esClient.search<SearchResponse<TieBreaker<T>>>({ body: { _source: getSourceWithTieBreaker({ sortField }), query, search_after: newSearchAfter, sort: getSortWithTieBreaker({ sortField, sortOrder }), }, - ignoreUnavailable: true, + ignore_unavailable: true, index, size: hopSize, }); diff --git a/x-pack/plugins/lists/server/services/utils/scroll_to_start_page.ts b/x-pack/plugins/lists/server/services/utils/scroll_to_start_page.ts index 502c754615416..2b65c0df54a83 100644 --- a/x-pack/plugins/lists/server/services/utils/scroll_to_start_page.ts +++ b/x-pack/plugins/lists/server/services/utils/scroll_to_start_page.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { Filter, SortFieldOrUndefined, SortOrderOrUndefined } from '../../../common/schemas'; import { Scroll } from '../lists/types'; @@ -14,7 +14,7 @@ import { calculateScrollMath } from './calculate_scroll_math'; import { getSearchAfterScroll } from './get_search_after_scroll'; interface ScrollToStartPageOptions { - callCluster: LegacyAPICaller; + esClient: ElasticsearchClient; filter: Filter; sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; @@ -27,7 +27,7 @@ interface ScrollToStartPageOptions { } export const scrollToStartPage = async ({ - callCluster, + esClient, filter, hopSize, currentIndexPosition, @@ -58,7 +58,7 @@ export const scrollToStartPage = async ({ }; } else if (hops > 0) { const scroll = await getSearchAfterScroll({ - callCluster, + esClient, filter, hopSize, hops, @@ -69,7 +69,7 @@ export const scrollToStartPage = async ({ }); if (scroll.validSearchAfterFound && leftOverAfterHops > 0) { return getSearchAfterScroll({ - callCluster, + esClient, filter, hopSize: leftOverAfterHops, hops: 1, @@ -83,7 +83,7 @@ export const scrollToStartPage = async ({ } } else { return getSearchAfterScroll({ - callCluster, + esClient, filter, hopSize: leftOverAfterHops, hops: 1, diff --git a/x-pack/plugins/lists/server/types.ts b/x-pack/plugins/lists/server/types.ts index c41bfcc0014c8..50d8d4d652a82 100644 --- a/x-pack/plugins/lists/server/types.ts +++ b/x-pack/plugins/lists/server/types.ts @@ -6,9 +6,9 @@ */ import { + ElasticsearchClient, IContextProvider, IRouter, - LegacyAPICaller, RequestHandlerContext, SavedObjectsClientContract, } from 'kibana/server'; @@ -27,7 +27,7 @@ export interface PluginsStart { } export type GetListClientType = ( - dataClient: LegacyAPICaller, + esClient: ElasticsearchClient, spaceId: string, user: string ) => ListClient; diff --git a/x-pack/plugins/ml/common/constants/messages.test.mock.ts b/x-pack/plugins/ml/common/constants/messages.test.mock.ts new file mode 100644 index 0000000000000..6e539617604c1 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/messages.test.mock.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. + */ + +/* To keep tests in sync, these mocks should be used in API intregation tests + * as expected values to check against, and in the client side jest tests to be + * the values used as function arguments for `parseMessages()` to retrieve the + * messages populated with translations and documentation links. + */ + +export const basicValidJobMessages = [ + { + id: 'job_id_valid', + }, + { + id: 'detectors_function_not_empty', + }, + { + id: 'success_bucket_span', + bucketSpan: '15m', + }, + { + id: 'success_time_range', + }, + { + id: 'success_mml', + }, +]; + +export const basicInvalidJobMessages = [ + { + id: 'job_id_invalid', + }, + { + id: 'detectors_function_not_empty', + }, + { + id: 'bucket_span_valid', + bucketSpan: '15m', + }, + { + id: 'skipped_extended_tests', + }, +]; + +export const nonBasicIssuesMessages = [ + { + id: 'job_id_valid', + }, + { + id: 'detectors_function_not_empty', + }, + { + id: 'cardinality_model_plot_high', + }, + { + id: 'cardinality_partition_field', + fieldName: 'order_id', + }, + { + id: 'bucket_span_high', + }, + { + bucketSpanCompareFactor: 25, + id: 'time_range_short', + minTimeSpanReadable: '2 hours', + }, + { + id: 'success_influencers', + }, + { + id: 'half_estimated_mml_greater_than_mml', + mml: '1MB', + }, + { + id: 'missing_summary_count_field_name', + }, +]; diff --git a/x-pack/plugins/ml/common/constants/messages.test.ts b/x-pack/plugins/ml/common/constants/messages.test.ts new file mode 100644 index 0000000000000..1141eea2c176d --- /dev/null +++ b/x-pack/plugins/ml/common/constants/messages.test.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { docLinksServiceMock } from 'src/core/public/mocks'; + +import { parseMessages } from './messages'; + +import { + basicValidJobMessages, + basicInvalidJobMessages, + nonBasicIssuesMessages, +} from './messages.test.mock'; + +describe('Constants: Messages parseMessages()', () => { + const docLinksService = docLinksServiceMock.createStartContract(); + + it('should parse valid job configuration messages', () => { + expect(parseMessages(basicValidJobMessages, docLinksService)).toStrictEqual([ + { + heading: 'Job ID format is valid', + id: 'job_id_valid', + status: 'success', + text: + 'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, starts and ends with an alphanumeric character, and is no more than 64 characters long.', + url: + 'https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/ml-put-job.html#ml-put-job-path-parms', + }, + { + heading: 'Detector functions', + id: 'detectors_function_not_empty', + status: 'success', + text: 'Presence of detector functions validated in all detectors.', + url: + 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#detectors', + }, + { + bucketSpan: '15m', + heading: 'Bucket span', + id: 'success_bucket_span', + status: 'success', + text: 'Format of "15m" is valid and passed validation checks.', + url: + 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#bucket-span', + }, + { + heading: 'Time range', + id: 'success_time_range', + status: 'success', + text: 'Valid and long enough to model patterns in the data.', + }, + { + heading: 'Model memory limit', + id: 'success_mml', + status: 'success', + text: 'Valid and within the estimated model memory limit.', + url: + 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#model-memory-limits', + }, + ]); + }); + + it('should parse basic invalid job configuration messages', () => { + expect(parseMessages(basicInvalidJobMessages, docLinksService)).toStrictEqual([ + { + id: 'job_id_invalid', + status: 'error', + text: + 'Job ID is invalid. It can contain lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores and must start and end with an alphanumeric character.', + url: + 'https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/ml-put-job.html#ml-put-job-path-parms', + }, + { + heading: 'Detector functions', + id: 'detectors_function_not_empty', + status: 'success', + text: 'Presence of detector functions validated in all detectors.', + url: + 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#detectors', + }, + { + bucketSpan: '15m', + heading: 'Bucket span', + id: 'bucket_span_valid', + status: 'success', + text: 'Format of "15m" is valid.', + url: + 'https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/ml-put-job.html#put-analysisconfig', + }, + { + id: 'skipped_extended_tests', + status: 'warning', + text: + 'Skipped additional checks because the basic requirements of the job configuration were not met.', + }, + ]); + }); + + it('should parse non-basic issues messages', () => { + expect(parseMessages(nonBasicIssuesMessages, docLinksService)).toStrictEqual([ + { + heading: 'Job ID format is valid', + id: 'job_id_valid', + status: 'success', + text: + 'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, starts and ends with an alphanumeric character, and is no more than 64 characters long.', + url: + 'https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/ml-put-job.html#ml-put-job-path-parms', + }, + { + heading: 'Detector functions', + id: 'detectors_function_not_empty', + status: 'success', + text: 'Presence of detector functions validated in all detectors.', + url: + 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#detectors', + }, + { + id: 'cardinality_model_plot_high', + status: 'warning', + text: + 'The estimated cardinality of undefined of fields relevant to creating model plots might result in resource intensive jobs.', + }, + { + fieldName: 'order_id', + id: 'cardinality_partition_field', + status: 'warning', + text: + 'Cardinality of partition_field "order_id" is above 1000 and might result in high memory usage.', + url: + 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#cardinality', + }, + { + heading: 'Bucket span', + id: 'bucket_span_high', + status: 'info', + text: + 'Bucket span is 1 day or more. Be aware that days are considered as UTC days, not local days.', + url: + 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#bucket-span', + }, + { + bucketSpanCompareFactor: 25, + heading: 'Time range', + id: 'time_range_short', + minTimeSpanReadable: '2 hours', + status: 'warning', + text: + 'The selected or available time range might be too short. The recommended minimum time range should be at least 2 hours and 25 times the bucket span.', + }, + { + id: 'success_influencers', + status: 'success', + text: 'Influencer configuration passed the validation checks.', + url: + 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-influencers.html', + }, + { + id: 'half_estimated_mml_greater_than_mml', + mml: '1MB', + status: 'warning', + text: + 'The specified model memory limit is less than half of the estimated model memory limit and will likely hit the hard limit.', + url: + 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#model-memory-limits', + }, + { + id: 'missing_summary_count_field_name', + status: 'error', + text: + 'A job configured with a datafeed with aggregations must set summary_count_field_name; use doc_count or suitable alternative.', + }, + ]); + }); +}); diff --git a/x-pack/plugins/ml/common/constants/messages.ts b/x-pack/plugins/ml/common/constants/messages.ts index 551bb364ea357..0327e8746c7d8 100644 --- a/x-pack/plugins/ml/common/constants/messages.ts +++ b/x-pack/plugins/ml/common/constants/messages.ts @@ -7,8 +7,13 @@ import { once } from 'lodash'; import { i18n } from '@kbn/i18n'; + +import type { DocLinksStart } from 'kibana/public'; + import { JOB_ID_MAX_LENGTH, VALIDATION_STATUS } from './validation'; +import { renderTemplate } from '../util/string_utils'; + export type MessageId = keyof ReturnType<typeof getMessages>; export interface JobValidationMessageDef { @@ -40,9 +45,9 @@ export type JobValidationMessage = { [key: string]: any; }; -export const getMessages = once(() => { - const createJobsDocsUrl = `https://www.elastic.co/guide/en/machine-learning/{{version}}/create-jobs.html`; - +// This is still consumed by a legacy class based React component. +// Once we migrate that component to use hooks, we may replace `once()` with `useMemo()`. +export const getMessages = once((docLinks?: DocLinksStart) => { return { categorizer_detector_missing_per_partition_field: { status: VALIDATION_STATUS.ERROR, @@ -53,8 +58,7 @@ export const getMessages = once(() => { 'Partition field must be set for detectors that reference "mlcategory" when per-partition categorization is enabled.', } ), - url: - 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-categories.html', + url: docLinks?.links.ml.anomalyDetectionConfiguringCategories, }, categorizer_varying_per_partition_fields: { status: VALIDATION_STATUS.ERROR, @@ -69,8 +73,7 @@ export const getMessages = once(() => { }, } ), - url: - 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-categories.html', + url: docLinks?.links.ml.anomalyDetectionConfiguringCategories, }, field_not_aggregatable: { status: VALIDATION_STATUS.ERROR, @@ -80,16 +83,14 @@ export const getMessages = once(() => { fieldName: '"{{fieldName}}"', }, }), - url: - 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-aggregation.html', + url: docLinks?.links.ml.aggregrations, }, fields_not_aggregatable: { status: VALIDATION_STATUS.ERROR, text: i18n.translate('xpack.ml.models.jobValidation.messages.fieldsNotAggregatableMessage', { defaultMessage: 'One of the detector fields is not an aggregatable field.', }), - url: - 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-aggregation.html', + url: docLinks?.links.ml.aggregrations, }, cardinality_no_results: { status: VALIDATION_STATUS.WARNING, @@ -120,8 +121,7 @@ export const getMessages = once(() => { }, } ), - url: - 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-aggregation.html', + url: docLinks?.links.ml.aggregrations, }, cardinality_by_field: { status: VALIDATION_STATUS.WARNING, @@ -132,7 +132,7 @@ export const getMessages = once(() => { fieldName: 'by_field "{{fieldName}}"', }, }), - url: `${createJobsDocsUrl}#cardinality`, + url: docLinks?.links.ml.anomalyDetectionCardinality, }, cardinality_over_field_low: { status: VALIDATION_STATUS.WARNING, @@ -146,7 +146,7 @@ export const getMessages = once(() => { }, } ), - url: `${createJobsDocsUrl}#cardinality`, + url: docLinks?.links.ml.anomalyDetectionCardinality, }, cardinality_over_field_high: { status: VALIDATION_STATUS.WARNING, @@ -160,7 +160,7 @@ export const getMessages = once(() => { }, } ), - url: `${createJobsDocsUrl}#cardinality`, + url: docLinks?.links.ml.anomalyDetectionCardinality, }, cardinality_partition_field: { status: VALIDATION_STATUS.WARNING, @@ -174,7 +174,7 @@ export const getMessages = once(() => { }, } ), - url: `${createJobsDocsUrl}#cardinality`, + url: docLinks?.links.ml.anomalyDetectionCardinality, }, cardinality_model_plot_high: { status: VALIDATION_STATUS.WARNING, @@ -198,8 +198,7 @@ export const getMessages = once(() => { defaultMessage: 'Categorization filters checks passed.', } ), - url: - 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-categories.html', + url: docLinks?.links.ml.anomalyDetectionConfiguringCategories, }, categorization_filters_invalid: { status: VALIDATION_STATUS.ERROR, @@ -214,16 +213,14 @@ export const getMessages = once(() => { }, } ), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig', + url: docLinks?.links.ml.anomalyDetectionJobResourceAnalysisConfig, }, bucket_span_empty: { status: VALIDATION_STATUS.ERROR, text: i18n.translate('xpack.ml.models.jobValidation.messages.bucketSpanEmptyMessage', { defaultMessage: 'The bucket span field must be specified.', }), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig', + url: docLinks?.links.ml.anomalyDetectionJobResourceAnalysisConfig, }, bucket_span_estimation_mismatch: { status: VALIDATION_STATUS.INFO, @@ -244,7 +241,7 @@ export const getMessages = once(() => { }, } ), - url: `${createJobsDocsUrl}#bucket-span`, + url: docLinks?.links.ml.anomalyDetectionBucketSpan, }, bucket_span_high: { status: VALIDATION_STATUS.INFO, @@ -255,7 +252,7 @@ export const getMessages = once(() => { defaultMessage: 'Bucket span is 1 day or more. Be aware that days are considered as UTC days, not local days.', }), - url: `${createJobsDocsUrl}#bucket-span`, + url: docLinks?.links.ml.anomalyDetectionBucketSpan, }, bucket_span_valid: { status: VALIDATION_STATUS.SUCCESS, @@ -268,8 +265,7 @@ export const getMessages = once(() => { bucketSpan: '"{{bucketSpan}}"', }, }), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig', + url: docLinks?.links.ml.anomalyDetectionJobResourceAnalysisConfig, }, bucket_span_invalid: { status: VALIDATION_STATUS.ERROR, @@ -280,8 +276,7 @@ export const getMessages = once(() => { defaultMessage: 'The specified bucket span is not a valid time interval format e.g. 10m, 1h. It also needs to be higher than zero.', }), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig', + url: docLinks?.links.ml.anomalyDetectionJobResourceAnalysisConfig, }, detectors_duplicates: { status: VALIDATION_STATUS.ERROR, @@ -298,21 +293,21 @@ export const getMessages = once(() => { partitionFieldNameParam: `'partition_field_name'`, }, }), - url: `${createJobsDocsUrl}#detectors`, + url: docLinks?.links.ml.anomalyDetectionDetectors, }, detectors_empty: { status: VALIDATION_STATUS.ERROR, text: i18n.translate('xpack.ml.models.jobValidation.messages.detectorsEmptyMessage', { defaultMessage: 'No detectors were found. At least one detector must be specified.', }), - url: `${createJobsDocsUrl}#detectors`, + url: docLinks?.links.ml.anomalyDetectionDetectors, }, detectors_function_empty: { status: VALIDATION_STATUS.ERROR, text: i18n.translate('xpack.ml.models.jobValidation.messages.detectorsFunctionEmptyMessage', { defaultMessage: 'One of the detector functions is empty.', }), - url: `${createJobsDocsUrl}#detectors`, + url: docLinks?.links.ml.anomalyDetectionDetectors, }, detectors_function_not_empty: { status: VALIDATION_STATUS.SUCCESS, @@ -328,7 +323,7 @@ export const getMessages = once(() => { defaultMessage: 'Presence of detector functions validated in all detectors.', } ), - url: `${createJobsDocsUrl}#detectors`, + url: docLinks?.links.ml.anomalyDetectionDetectors, }, index_fields_invalid: { status: VALIDATION_STATUS.ERROR, @@ -349,7 +344,7 @@ export const getMessages = once(() => { 'The job configuration includes more than 3 influencers. ' + 'Consider using fewer influencers or creating multiple jobs.', }), - url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html', + url: docLinks?.links.ml.anomalyDetectionInfluencers, }, influencer_low: { status: VALIDATION_STATUS.WARNING, @@ -357,7 +352,7 @@ export const getMessages = once(() => { defaultMessage: 'No influencers have been configured. Picking an influencer is strongly recommended.', }), - url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html', + url: docLinks?.links.ml.anomalyDetectionInfluencers, }, influencer_low_suggestion: { status: VALIDATION_STATUS.WARNING, @@ -369,7 +364,7 @@ export const getMessages = once(() => { values: { influencerSuggestion: '{{influencerSuggestion}}' }, } ), - url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html', + url: docLinks?.links.ml.anomalyDetectionInfluencers, }, influencer_low_suggestions: { status: VALIDATION_STATUS.WARNING, @@ -381,15 +376,14 @@ export const getMessages = once(() => { values: { influencerSuggestion: '{{influencerSuggestion}}' }, } ), - url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html', + url: docLinks?.links.ml.anomalyDetectionInfluencers, }, job_id_empty: { status: VALIDATION_STATUS.ERROR, text: i18n.translate('xpack.ml.models.jobValidation.messages.jobIdEmptyMessage', { defaultMessage: 'Job ID field must not be empty.', }), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource', + url: docLinks?.links.ml.anomalyDetectionJobResource, }, job_id_invalid: { status: VALIDATION_STATUS.ERROR, @@ -398,8 +392,7 @@ export const getMessages = once(() => { 'Job ID is invalid. It can contain lowercase alphanumeric (a-z and 0-9) characters, ' + 'hyphens or underscores and must start and end with an alphanumeric character.', }), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource', + url: docLinks?.links.ml.anomalyDetectionJobResource, }, job_id_invalid_max_length: { status: VALIDATION_STATUS.ERROR, @@ -413,8 +406,7 @@ export const getMessages = once(() => { }, } ), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource', + url: docLinks?.links.ml.anomalyDetectionJobResource, }, job_id_valid: { status: VALIDATION_STATUS.SUCCESS, @@ -430,8 +422,7 @@ export const getMessages = once(() => { maxLength: JOB_ID_MAX_LENGTH, }, }), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource', + url: docLinks?.links.ml.anomalyDetectionJobResource, }, job_group_id_invalid: { status: VALIDATION_STATUS.ERROR, @@ -440,8 +431,7 @@ export const getMessages = once(() => { 'One of the job group names is invalid. They can contain lowercase ' + 'alphanumeric (a-z and 0-9) characters, hyphens or underscores and must start and end with an alphanumeric character.', }), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource', + url: docLinks?.links.ml.anomalyDetectionJobResource, }, job_group_id_invalid_max_length: { status: VALIDATION_STATUS.ERROR, @@ -455,8 +445,7 @@ export const getMessages = once(() => { }, } ), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource', + url: docLinks?.links.ml.anomalyDetectionJobResource, }, job_group_id_valid: { status: VALIDATION_STATUS.SUCCESS, @@ -472,8 +461,7 @@ export const getMessages = once(() => { maxLength: JOB_ID_MAX_LENGTH, }, }), - url: - 'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource', + url: docLinks?.links.ml.anomalyDetectionJobResource, }, missing_summary_count_field_name: { status: VALIDATION_STATUS.ERROR, @@ -500,7 +488,7 @@ export const getMessages = once(() => { text: i18n.translate('xpack.ml.models.jobValidation.messages.successCardinalityMessage', { defaultMessage: 'Cardinality of detector fields is within recommended bounds.', }), - url: `${createJobsDocsUrl}#cardinality`, + url: docLinks?.links.ml.anomalyDetectionCardinality, }, success_bucket_span: { status: VALIDATION_STATUS.SUCCESS, @@ -511,14 +499,14 @@ export const getMessages = once(() => { defaultMessage: 'Format of {bucketSpan} is valid and passed validation checks.', values: { bucketSpan: '"{{bucketSpan}}"' }, }), - url: `${createJobsDocsUrl}#bucket-span`, + url: docLinks?.links.ml.anomalyDetectionBucketSpan, }, success_influencers: { status: VALIDATION_STATUS.SUCCESS, text: i18n.translate('xpack.ml.models.jobValidation.messages.successInfluencersMessage', { defaultMessage: 'Influencer configuration passed the validation checks.', }), - url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html', + url: docLinks?.links.ml.anomalyDetectionInfluencers, }, estimated_mml_greater_than_max_mml: { status: VALIDATION_STATUS.WARNING, @@ -556,7 +544,7 @@ export const getMessages = once(() => { '1MB and should be specified in bytes e.g. 10MB.', values: { mml: '{{mml}}' }, }), - url: `${createJobsDocsUrl}#model-memory-limits`, + url: docLinks?.links.ml.anomalyDetectionModelMemoryLimits, }, half_estimated_mml_greater_than_mml: { status: VALIDATION_STATUS.WARNING, @@ -568,7 +556,7 @@ export const getMessages = once(() => { 'memory limit and will likely hit the hard limit.', } ), - url: `${createJobsDocsUrl}#model-memory-limits`, + url: docLinks?.links.ml.anomalyDetectionModelMemoryLimits, }, estimated_mml_greater_than_mml: { status: VALIDATION_STATUS.INFO, @@ -579,7 +567,7 @@ export const getMessages = once(() => { 'The estimated model memory limit is greater than the model memory limit you have configured.', } ), - url: `${createJobsDocsUrl}#model-memory-limits`, + url: docLinks?.links.ml.anomalyDetectionModelMemoryLimits, }, success_mml: { status: VALIDATION_STATUS.SUCCESS, @@ -589,7 +577,7 @@ export const getMessages = once(() => { text: i18n.translate('xpack.ml.models.jobValidation.messages.successMmlMessage', { defaultMessage: 'Valid and within the estimated model memory limit.', }), - url: `${createJobsDocsUrl}#model-memory-limits`, + url: docLinks?.links.ml.anomalyDetectionModelMemoryLimits, }, success_time_range: { status: VALIDATION_STATUS.SUCCESS, @@ -640,3 +628,36 @@ export const getMessages = once(() => { }, }; }); + +export const parseMessages = ( + validationMessages: JobValidationMessage[], + docLinks: DocLinksStart +) => { + const messages = getMessages(docLinks); + + return validationMessages.map((message) => { + const messageId = message.id as MessageId; + const messageDef = messages[messageId] as JobValidationMessageDef; + if (typeof messageDef !== 'undefined') { + // render the message template with the provided metadata + if (typeof messageDef.heading !== 'undefined') { + message.heading = renderTemplate(messageDef.heading, message); + } + message.text = renderTemplate(messageDef.text, message); + // check if the error message provides a link with further information + // if so, add it to the message to be returned with it + if (typeof messageDef.url !== 'undefined') { + message.url = messageDef.url; + } + + message.status = messageDef.status; + } else { + message.text = i18n.translate('xpack.ml.models.jobValidation.unknownMessageIdErrorMessage', { + defaultMessage: '{messageId} (unknown message id)', + values: { messageId }, + }); + } + + return message; + }); +}; 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 06938485649fb..ed9c9e7589749 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 @@ -45,7 +45,7 @@ export type Aggregation = Record< } >; -interface IndicesOptions { +export interface IndicesOptions { expand_wildcards?: 'all' | 'open' | 'closed' | 'hidden' | 'none'; ignore_unavailable?: boolean; allow_no_indices?: boolean; diff --git a/x-pack/plugins/ml/common/types/job_service.ts b/x-pack/plugins/ml/common/types/job_service.ts index aae0b9c9b209f..5209743f87b3c 100644 --- a/x-pack/plugins/ml/common/types/job_service.ts +++ b/x-pack/plugins/ml/common/types/job_service.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { Job, JobStats } from './anomaly_detection_jobs'; +import { Job, JobStats, IndicesOptions } from './anomaly_detection_jobs'; +import { RuntimeMappings } from './fields'; +import { ES_AGGREGATION } from '../constants/aggregation_types'; export interface MlJobsResponse { jobs: Job[]; @@ -23,3 +25,18 @@ export interface JobsExistResponse { isGroup: boolean; }; } + +export interface BucketSpanEstimatorData { + aggTypes: Array<ES_AGGREGATION | null>; + duration: { + start: number; + end: number; + }; + fields: Array<string | null>; + index: string; + query: any; + splitField: string | undefined; + timeField: string | undefined; + runtimeMappings: RuntimeMappings | undefined; + indicesOptions: IndicesOptions | undefined; +} 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 020f0d015eafe..a3753c8f000ae 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 @@ -29,7 +29,8 @@ export function chartLoaderProvider(mlResultsService: MlResultsService) { job.data_description.time_field, job.data_counts.earliest_record_timestamp, job.data_counts.latest_record_timestamp, - intervalMs + intervalMs, + job.datafeed_config.indices_options ); if (resp.error !== undefined) { throw resp.error; diff --git a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js index c05e0fa21b304..14e242ee69211 100644 --- a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js +++ b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js @@ -28,6 +28,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { getDocLinks } from '../../util/dependency_cache'; +import { parseMessages } from '../../../../common/constants/messages'; import { VALIDATION_STATUS } from '../../../../common/constants/validation'; import { Callout, statusToEuiIconType } from '../callout'; import { getMostSevereMessageStatus } from '../../../../common/util/validation_utils'; @@ -132,7 +133,8 @@ export class ValidateJobUI extends Component { this.props.ml .validateJob({ duration, fields, job }) - .then((messages) => { + .then((validationMessages) => { + const messages = parseMessages(validationMessages, getDocLinks()); shouldShowLoadingIndicator = false; const messagesContainError = messages.some((m) => m.status === VALIDATION_STATUS.ERROR); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 3def5ca4193d1..36c66a76c68f6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -558,8 +558,9 @@ export function reducer(state: State, action: Action): State { case ACTION.SWITCH_TO_FORM: const { jobConfig: config } = state; const { jobId } = state.form; + // Persist form state when switching back from advanced editor // @ts-ignore - const formState = getFormStateFromJobConfig(config, false); + const formState = { ...state.form, ...getFormStateFromJobConfig(config, false) }; if (typeof jobId === 'string' && jobId.trim() !== '') { formState.jobId = jobId; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts index a36e52f4e863b..ddd2aa3619472 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts @@ -8,6 +8,7 @@ import memoizeOne from 'memoize-one'; import { isEqual } from 'lodash'; import { IndexPatternTitle } from '../../../../../../common/types/kibana'; +import { IndicesOptions } from '../../../../../../common/types/anomaly_detection_jobs'; import { Field, SplitField, @@ -56,7 +57,8 @@ export class ChartLoader { splitField: SplitField, splitFieldValue: SplitFieldValue, intervalMs: number, - runtimeMappings: RuntimeMappings | null + runtimeMappings: RuntimeMappings | null, + indicesOptions?: IndicesOptions ): Promise<LineChartData> { if (this._timeFieldName !== '') { if (aggFieldPairsCanBeCharted(aggFieldPairs) === false) { @@ -77,7 +79,8 @@ export class ChartLoader { aggFieldPairNames, splitFieldName, splitFieldValue, - runtimeMappings ?? undefined + runtimeMappings ?? undefined, + indicesOptions ); return resp.results; @@ -91,7 +94,8 @@ export class ChartLoader { aggFieldPairs: AggFieldPair[], splitField: SplitField, intervalMs: number, - runtimeMappings: RuntimeMappings | null + runtimeMappings: RuntimeMappings | null, + indicesOptions?: IndicesOptions ): Promise<LineChartData> { if (this._timeFieldName !== '') { if (aggFieldPairsCanBeCharted(aggFieldPairs) === false) { @@ -111,7 +115,8 @@ export class ChartLoader { this._query, aggFieldPairNames, splitFieldName, - runtimeMappings ?? undefined + runtimeMappings ?? undefined, + indicesOptions ); return resp.results; @@ -122,7 +127,8 @@ export class ChartLoader { async loadEventRateChart( start: number, end: number, - intervalMs: number + intervalMs: number, + indicesOptions?: IndicesOptions ): Promise<LineChartPoint[]> { if (this._timeFieldName !== '') { const resp = await getEventRateData( @@ -131,7 +137,8 @@ export class ChartLoader { this._timeFieldName, start, end, - intervalMs * 3 + intervalMs * 3, + indicesOptions ); if (resp.error !== undefined) { throw resp.error; @@ -147,14 +154,16 @@ export class ChartLoader { async loadFieldExampleValues( field: Field, - runtimeMappings: RuntimeMappings | null + runtimeMappings: RuntimeMappings | null, + indicesOptions?: IndicesOptions ): Promise<string[]> { const { results } = await getCategoryFields( this._indexPatternTitle, field.name, 10, this._query, - runtimeMappings ?? undefined + runtimeMappings ?? undefined, + indicesOptions ); return results; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts index 54917c4884f22..499e1639f1fde 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts @@ -9,6 +9,7 @@ import { get } from 'lodash'; import { ml } from '../../../../services/ml_api_service'; import { RuntimeMappings } from '../../../../../../common/types/fields'; +import { IndicesOptions } from '../../../../../../common/types/anomaly_detection_jobs'; interface CategoryResults { success: boolean; @@ -20,7 +21,8 @@ export function getCategoryFields( fieldName: string, size: number, query: any, - runtimeMappings?: RuntimeMappings + runtimeMappings?: RuntimeMappings, + indicesOptions?: IndicesOptions ): Promise<CategoryResults> { return new Promise((resolve, reject) => { ml.esSearch({ @@ -38,6 +40,7 @@ export function getCategoryFields( }, ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, + ...(indicesOptions ?? {}), }) .then((resp: any) => { const catFields = get(resp, ['aggregations', 'catFields', 'buckets'], []); 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 d03d67e058bfa..da5cfc53b7950 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 @@ -177,16 +177,13 @@ export class AdvancedJobCreator extends JobCreator { // load the start and end times for the selected index // and apply them to the job creator public async autoSetTimeRange() { - try { - const { start, end } = await ml.getTimeFieldRange({ - index: this._indexPatternTitle, - timeFieldName: this.timeFieldName, - query: this.query, - }); - this.setTimeRange(start.epoch, end.epoch); - } catch (error) { - throw Error(error); - } + const { start, end } = await ml.getTimeFieldRange({ + index: this._indexPatternTitle, + timeFieldName: this.timeFieldName, + query: this.query, + indicesOptions: this.datafeedConfig.indices_options, + }); + this.setTimeRange(start.epoch, end.epoch); } public cloneFromExistingJob(job: Job, datafeed: Datafeed) { 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 06d489ee5a437..641eda3dbf3e8 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 @@ -51,7 +51,8 @@ export class CategorizationExamplesLoader { this._jobCreator.start, this._jobCreator.end, analyzer, - this._jobCreator.runtimeMappings ?? undefined + this._jobCreator.runtimeMappings ?? undefined, + 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 c4365bd656f9e..a01581f7526c5 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 @@ -256,7 +256,8 @@ export class ResultsLoader { if (this._jobCreator.splitField !== null) { const fieldValues = await this._chartLoader.loadFieldExampleValues( this._jobCreator.splitField, - this._jobCreator.runtimeMappings + this._jobCreator.runtimeMappings, + this._jobCreator.datafeedConfig.indices_options ); if (fieldValues.length > 0) { this._detectorSplitFieldFilters = { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/json_editor_flyout/json_editor_flyout.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/json_editor_flyout/json_editor_flyout.tsx index 39b03ac546081..b2e4a447e4c31 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/json_editor_flyout/json_editor_flyout.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/json_editor_flyout/json_editor_flyout.tsx @@ -25,7 +25,9 @@ import { CombinedJob, Datafeed } from '../../../../../../../../common/types/anom import { ML_EDITOR_MODE, MLJobEditor } from '../../../../../jobs_list/components/ml_job_editor'; import { isValidJson } from '../../../../../../../../common/util/validation_utils'; import { JobCreatorContext } from '../../job_creator_context'; +import { isAdvancedJobCreator } from '../../../../common/job_creator'; import { DatafeedPreview } from '../datafeed_preview_flyout'; +import { useToastNotificationService } from '../../../../../../services/toast_notification_service'; export enum EDITOR_MODE { HIDDEN, @@ -40,6 +42,7 @@ interface Props { } export const JsonEditorFlyout: FC<Props> = ({ isDisabled, jobEditorMode, datafeedEditorMode }) => { const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const { displayErrorToast } = useToastNotificationService(); const [showJsonFlyout, setShowJsonFlyout] = useState(false); const [showChangedIndicesWarning, setShowChangedIndicesWarning] = useState(false); @@ -120,10 +123,23 @@ export const JsonEditorFlyout: FC<Props> = ({ isDisabled, jobEditorMode, datafee setSaveable(valid); } - function onSave() { + async function onSave() { const jobConfig = JSON.parse(jobConfigString); const datafeedConfig = JSON.parse(collapseLiteralStrings(datafeedConfigString)); jobCreator.cloneFromExistingJob(jobConfig, datafeedConfig); + if (isAdvancedJobCreator(jobCreator)) { + try { + await jobCreator.autoSetTimeRange(); + } catch (error) { + const title = i18n.translate( + 'xpack.ml.newJob.wizard.jsonFlyout.autoSetJobCreatorTimeRange.error', + { + defaultMessage: `Error retrieving beginning and end times of index`, + } + ); + displayErrorToast(error, title); + } + } jobCreatorUpdate(); setShowJsonFlyout(false); } 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 f0932b09af46b..85083146c1378 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 @@ -9,12 +9,13 @@ import { useContext, useState } from 'react'; import { JobCreatorContext } from '../../../job_creator_context'; import { EVENT_RATE_FIELD_ID } from '../../../../../../../../../common/types/fields'; +import { BucketSpanEstimatorData } from '../../../../../../../../../common/types/job_service'; import { isMultiMetricJobCreator, isPopulationJobCreator, isAdvancedJobCreator, } from '../../../../../common/job_creator'; -import { ml, BucketSpanEstimatorData } from '../../../../../../../services/ml_api_service'; +import { ml } from '../../../../../../../services/ml_api_service'; import { useMlContext } from '../../../../../../../contexts/ml'; import { getToastNotificationService } from '../../../../../../../services/toast_notification_service'; @@ -41,6 +42,7 @@ export function useEstimateBucketSpan() { splitField: undefined, timeField: mlContext.currentIndexPattern.timeFieldName, runtimeMappings: jobCreator.runtimeMappings ?? undefined, + indicesOptions: jobCreator.datafeedConfig.indices_options, }; if ( 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 f027372d01204..da9f306cf30e6 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 @@ -54,7 +54,8 @@ export const CategorizationDetectorsSummary: FC = () => { const resp = await chartLoader.loadEventRateChart( jobCreator.start, jobCreator.end, - chartInterval.getInterval().asMilliseconds() + chartInterval.getInterval().asMilliseconds(), + jobCreator.datafeedConfig.indices_options ); setEventRateChartData(resp); } catch (error) { 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 5bf4beacc1593..46eb4b88d0518 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 @@ -111,7 +111,11 @@ export const MultiMetricDetectors: FC<Props> = ({ setIsValid }) => { useEffect(() => { if (splitField !== null) { chartLoader - .loadFieldExampleValues(splitField, jobCreator.runtimeMappings) + .loadFieldExampleValues( + splitField, + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options + ) .then(setFieldValues) .catch((error) => { getToastNotificationService().displayErrorToast(error); @@ -140,7 +144,8 @@ export const MultiMetricDetectors: FC<Props> = ({ setIsValid }) => { jobCreator.splitField, fieldValues.length > 0 ? fieldValues[0] : null, cs.intervalMs, - jobCreator.runtimeMappings + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options ); setLineChartsData(resp); } catch (error) { 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 11f2f60e17d3d..a4c344d16482b 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 @@ -43,7 +43,8 @@ export const MultiMetricDetectorsSummary: FC = () => { try { const tempFieldValues = await chartLoader.loadFieldExampleValues( jobCreator.splitField, - jobCreator.runtimeMappings + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options ); setFieldValues(tempFieldValues); } catch (error) { @@ -76,7 +77,8 @@ export const MultiMetricDetectorsSummary: FC = () => { jobCreator.splitField, fieldValues.length > 0 ? fieldValues[0] : null, cs.intervalMs, - jobCreator.runtimeMappings + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options ); setLineChartsData(resp); } catch (error) { 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 aba2acfa41a85..a7eaaff611183 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 @@ -160,7 +160,8 @@ export const PopulationDetectors: FC<Props> = ({ setIsValid }) => { aggFieldPairList, jobCreator.splitField, cs.intervalMs, - jobCreator.runtimeMappings + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options ); setLineChartsData(resp); @@ -180,7 +181,11 @@ export const PopulationDetectors: FC<Props> = ({ setIsValid }) => { (async (index: number, field: Field) => { return { index, - fields: await chartLoader.loadFieldExampleValues(field, jobCreator.runtimeMappings), + fields: await chartLoader.loadFieldExampleValues( + field, + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options + ), }; })(i, af.by.field) ); 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 c615010891101..55a9d37d1115c 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 @@ -78,7 +78,8 @@ export const PopulationDetectorsSummary: FC = () => { aggFieldPairList, jobCreator.splitField, cs.intervalMs, - jobCreator.runtimeMappings + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options ); setLineChartsData(resp); @@ -98,7 +99,11 @@ export const PopulationDetectorsSummary: FC = () => { (async (index: number, field: Field) => { return { index, - fields: await chartLoader.loadFieldExampleValues(field, jobCreator.runtimeMappings), + fields: await chartLoader.loadFieldExampleValues( + field, + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options + ), }; })(i, af.by.field) ); 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 f4a907dcc6a49..0e09a81908e83 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 @@ -93,7 +93,8 @@ export const SingleMetricDetectors: FC<Props> = ({ setIsValid }) => { null, null, cs.intervalMs, - jobCreator.runtimeMappings + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options ); if (resp[DTR_IDX] !== undefined) { setLineChartData(resp); 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 4d8fc5ef76084..ced94b2095f72 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 @@ -59,7 +59,8 @@ export const SingleMetricDetectorsSummary: FC = () => { null, null, cs.intervalMs, - jobCreator.runtimeMappings + jobCreator.runtimeMappings, + jobCreator.datafeedConfig.indices_options ); if (resp[DTR_IDX] !== undefined) { setLineChartData(resp); 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 bed2d36524e36..d2cf6b7a00471 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 @@ -47,7 +47,8 @@ export const TimeRangeStep: FC<StepProps> = ({ setCurrentStep, isCurrentStep }) const resp = await chartLoader.loadEventRateChart( jobCreator.start, jobCreator.end, - chartInterval.getInterval().asMilliseconds() + chartInterval.getInterval().asMilliseconds(), + jobCreator.datafeedConfig.indices_options ); setEventRateChartData(resp); } catch (error) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx index c8dbb90804444..3a01ce8c70fc8 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx @@ -20,7 +20,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { Wizard } from './wizard'; import { WIZARD_STEPS } from '../components/step_types'; import { getJobCreatorTitle } from '../../common/job_creator/util/general'; -import { useMlKibana } from '../../../../contexts/kibana'; import { jobCreatorFactory, isAdvancedJobCreator, @@ -41,6 +40,7 @@ import { ExistingJobsAndGroups, mlJobService } from '../../../../services/job_se import { newJobCapsService } from '../../../../services/new_job_capabilities_service'; import { EVENT_RATE_FIELD_ID } from '../../../../../../common/types/fields'; import { getNewJobDefaults } from '../../../../services/ml_server_info'; +import { useToastNotificationService } from '../../../../services/toast_notification_service'; const PAGE_WIDTH = 1200; // document.querySelector('.single-metric-job-container').width(); const BAR_TARGET = PAGE_WIDTH > 2000 ? 1000 : PAGE_WIDTH / 2; @@ -52,15 +52,13 @@ export interface PageProps { } export const Page: FC<PageProps> = ({ existingJobsAndGroups, jobType }) => { - const { - services: { notifications }, - } = useMlKibana(); const mlContext = useMlContext(); const jobCreator = jobCreatorFactory(jobType)( mlContext.currentIndexPattern, mlContext.currentSavedSearch, mlContext.combinedQuery ); + const { displayErrorToast } = useToastNotificationService(); const { from, to } = getTimeFilterRange(); jobCreator.setTimeRange(from, to); @@ -154,17 +152,12 @@ export const Page: FC<PageProps> = ({ existingJobsAndGroups, jobType }) => { if (autoSetTimeRange && isAdvancedJobCreator(jobCreator)) { // for advanced jobs, load the full time range start and end times // so they can be used for job validation and bucket span estimation - try { - jobCreator.autoSetTimeRange(); - } catch (error) { - const { toasts } = notifications; - toasts.addDanger({ - title: i18n.translate('xpack.ml.newJob.wizard.autoSetJobCreatorTimeRange.error', { - defaultMessage: `Error retrieving beginning and end times of index`, - }), - text: error, + jobCreator.autoSetTimeRange().catch((error) => { + const title = i18n.translate('xpack.ml.newJob.wizard.autoSetJobCreatorTimeRange.error', { + defaultMessage: `Error retrieving beginning and end times of index`, }); - } + displayErrorToast(error, title); + }); } function initCategorizationSettings() { diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index 33ccf81798353..2fa60b8db83a7 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -13,7 +13,6 @@ import { ml } from './ml_api_service'; import { getToastNotificationService } from '../services/toast_notification_service'; import { isWebUrl } from '../util/url_utils'; -import { ML_DATA_PREVIEW_COUNT } from '../../../common/util/job_utils'; import { TIME_FORMAT } from '../../../common/constants/time_format'; import { parseInterval } from '../../../common/util/parse_interval'; import { validateTimeRange } from '../../../common/util/date_utils'; @@ -348,163 +347,9 @@ class JobService { return job; } - searchPreview(job) { - return new Promise((resolve, reject) => { - if (job.datafeed_config) { - // if query is set, add it to the search, otherwise use match_all - let query = { match_all: {} }; - if (job.datafeed_config.query) { - query = job.datafeed_config.query; - } - - // Get bucket span - // Get first doc time for datafeed - // Create a new query - must user query and must range query. - // Time range 'to' first doc time plus < 10 buckets - - // Do a preliminary search to get the date of the earliest doc matching the - // query in the datafeed. This will be used to apply a time range criteria - // on the datafeed search preview. - // This time filter is required for datafeed searches using aggregations to ensure - // the search does not create too many buckets (default 10000 max_bucket limit), - // but apply it to searches without aggregations too for consistency. - ml.getTimeFieldRange({ - index: job.datafeed_config.indices, - timeFieldName: job.data_description.time_field, - query, - }) - .then((timeRange) => { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - const earliestMs = timeRange.start.epoch; - const latestMs = +timeRange.start.epoch + 10 * bucketSpan.asMilliseconds(); - - const body = { - query: { - bool: { - must: [ - { - range: { - [job.data_description.time_field]: { - gte: earliestMs, - lt: latestMs, - format: 'epoch_millis', - }, - }, - }, - query, - ], - }, - }, - }; - - // if aggs or aggregations is set, add it to the search - const aggregations = job.datafeed_config.aggs || job.datafeed_config.aggregations; - if (aggregations && Object.keys(aggregations).length) { - body.size = 0; - body.aggregations = aggregations; - - // add script_fields if present - const scriptFields = job.datafeed_config.script_fields; - if (scriptFields && Object.keys(scriptFields).length) { - body.script_fields = scriptFields; - } - - // add runtime_mappings if present - const runtimeMappings = job.datafeed_config.runtime_mappings; - if (runtimeMappings && Object.keys(runtimeMappings).length) { - body.runtime_mappings = runtimeMappings; - } - } else { - // if aggregations is not set and retrieveWholeSource is not set, add all of the fields from the job - body.size = ML_DATA_PREVIEW_COUNT; - - // add script_fields if present - const scriptFields = job.datafeed_config.script_fields; - if (scriptFields && Object.keys(scriptFields).length) { - body.script_fields = scriptFields; - } - - // add runtime_mappings if present - const runtimeMappings = job.datafeed_config.runtime_mappings; - if (runtimeMappings && Object.keys(runtimeMappings).length) { - body.runtime_mappings = runtimeMappings; - } - - const fields = {}; - - // get fields from detectors - if (job.analysis_config.detectors) { - each(job.analysis_config.detectors, (dtr) => { - if (dtr.by_field_name) { - fields[dtr.by_field_name] = {}; - } - if (dtr.field_name) { - fields[dtr.field_name] = {}; - } - if (dtr.over_field_name) { - fields[dtr.over_field_name] = {}; - } - if (dtr.partition_field_name) { - fields[dtr.partition_field_name] = {}; - } - }); - } - - // get fields from influencers - if (job.analysis_config.influencers) { - each(job.analysis_config.influencers, (inf) => { - fields[inf] = {}; - }); - } - - // get fields from categorizationFieldName - if (job.analysis_config.categorization_field_name) { - fields[job.analysis_config.categorization_field_name] = {}; - } - - // get fields from summary_count_field_name - if (job.analysis_config.summary_count_field_name) { - fields[job.analysis_config.summary_count_field_name] = {}; - } - - // get fields from time_field - if (job.data_description.time_field) { - fields[job.data_description.time_field] = {}; - } - - // add runtime fields - if (runtimeMappings) { - Object.keys(runtimeMappings).forEach((fieldName) => { - fields[fieldName] = {}; - }); - } - - const fieldsList = Object.keys(fields); - if (fieldsList.length) { - body.fields = fieldsList; - body._source = false; - } - } - - const data = { - index: job.datafeed_config.indices, - body, - ...(job.datafeed_config.indices_options || {}), - }; - - ml.esSearch(data) - .then((resp) => { - resolve(resp); - }) - .catch((resp) => { - reject(resp); - }); - }) - .catch((resp) => { - reject(resp); - }); - } - }); + searchPreview(combinedJob) { + const { datafeed_config: datafeed, ...job } = combinedJob; + return ml.jobs.datafeedPreview(job, datafeed); } openJob(jobId) { 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 8d0ecddaa97b8..e6d0d93cade1f 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 @@ -24,7 +24,7 @@ import { import { MlCapabilitiesResponse } from '../../../../common/types/capabilities'; import { Calendar, CalendarId, UpdateCalendar } from '../../../../common/types/calendars'; -import { RuntimeMappings } from '../../../../common/types/fields'; +import { BucketSpanEstimatorData } from '../../../../common/types/job_service'; import { Job, JobStats, @@ -33,8 +33,8 @@ import { Detector, AnalysisConfig, ModelSnapshot, + IndicesOptions, } from '../../../../common/types/anomaly_detection_jobs'; -import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { FieldHistogramRequestConfig, FieldRequestConfig, @@ -53,20 +53,6 @@ export interface MlInfoResponse { cloudId?: string; } -export interface BucketSpanEstimatorData { - aggTypes: Array<ES_AGGREGATION | null>; - duration: { - start: number; - end: number; - }; - fields: Array<string | null>; - index: string; - query: any; - splitField: string | undefined; - timeField: string | undefined; - runtimeMappings: RuntimeMappings | undefined; -} - export interface BucketSpanEstimatorResponse { name: string; ms: number; @@ -704,12 +690,14 @@ export function mlApiServicesProvider(httpService: HttpService) { index, timeFieldName, query, + indicesOptions, }: { index: string; timeFieldName?: string; query: any; + indicesOptions?: IndicesOptions; }) { - const body = JSON.stringify({ index, timeFieldName, query }); + const body = JSON.stringify({ index, timeFieldName, query, indicesOptions }); return httpService.http<GetTimeFieldRangeResponse>({ path: `${basePath()}/fields_service/time_field_range`, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index df72bd25c6bcd..811e9cab365d7 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -15,6 +15,7 @@ import type { CombinedJobWithStats, Job, Datafeed, + IndicesOptions, } from '../../../../common/types/anomaly_detection_jobs'; import type { JobMessage } from '../../../../common/types/audit_message'; import type { AggFieldNamePair, RuntimeMappings } from '../../../../common/types/fields'; @@ -189,7 +190,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, splitFieldValue: string | null, - runtimeMappings?: RuntimeMappings + runtimeMappings?: RuntimeMappings, + indicesOptions?: IndicesOptions ) { const body = JSON.stringify({ indexPatternTitle, @@ -202,6 +204,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ splitFieldName, splitFieldValue, runtimeMappings, + indicesOptions, }); return httpService.http<any>({ path: `${ML_BASE_PATH}/jobs/new_job_line_chart`, @@ -219,7 +222,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ query: any, aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string, - runtimeMappings?: RuntimeMappings + runtimeMappings?: RuntimeMappings, + indicesOptions?: IndicesOptions ) { const body = JSON.stringify({ indexPatternTitle, @@ -231,6 +235,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ aggFieldNamePairs, splitFieldName, runtimeMappings, + indicesOptions, }); return httpService.http<any>({ path: `${ML_BASE_PATH}/jobs/new_job_population_chart`, @@ -268,7 +273,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ start: number, end: number, analyzer: CategorizationAnalyzer, - runtimeMappings?: RuntimeMappings + runtimeMappings?: RuntimeMappings, + indicesOptions?: IndicesOptions ) { const body = JSON.stringify({ indexPatternTitle, @@ -280,6 +286,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ end, analyzer, runtimeMappings, + indicesOptions, }); return httpService.http<{ examples: CategoryFieldExample[]; @@ -322,4 +329,16 @@ export const jobsApiProvider = (httpService: HttpService) => ({ body, }); }, + + datafeedPreview(job: Job, datafeed: Datafeed) { + const body = JSON.stringify({ job, datafeed }); + return httpService.http<{ + total: number; + categories: Array<{ count?: number; category: Category }>; + }>({ + path: `${ML_BASE_PATH}/jobs/datafeed_preview`, + method: 'POST', + body, + }); + }, }); 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 90ab302458354..f9a2c1389c828 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { IndicesOptions } from '../../../../common/types/anomaly_detection_jobs'; import { MlApiServices } from '../ml_api_service'; export function resultsServiceProvider( @@ -58,7 +59,8 @@ export function resultsServiceProvider( timeFieldName: string, earliestMs: number, latestMs: number, - intervalMs: number + intervalMs: number, + indicesOptions?: IndicesOptions ): Promise<any>; getEventDistributionData( index: string, 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 3390e62030dd6..502692da39c96 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 @@ -1052,7 +1052,15 @@ export function resultsServiceProvider(mlApiServices) { // Extra query object can be supplied, or pass null if no additional query. // Returned response contains a results property, which is an object // of document counts against time (epoch millis). - getEventRateData(index, query, timeFieldName, earliestMs, latestMs, intervalMs) { + getEventRateData( + index, + query, + timeFieldName, + earliestMs, + latestMs, + intervalMs, + indicesOptions + ) { return new Promise((resolve, reject) => { const obj = { success: true, results: {} }; @@ -1102,6 +1110,7 @@ export function resultsServiceProvider(mlApiServices) { }, }, }, + ...(indicesOptions ?? {}), }) .then((resp) => { const dataByTimeBucket = get(resp, ['aggregations', 'eventRate', 'buckets'], []); diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts index 40a6bd1decd97..75983975f7acd 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts @@ -8,20 +8,8 @@ import { IScopedClusterClient } from 'kibana/server'; import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; import { RuntimeMappings } from '../../../common/types/fields'; - -export interface BucketSpanEstimatorData { - aggTypes: Array<ES_AGGREGATION | null>; - duration: { - start: number; - end: number; - }; - fields: Array<string | null>; - index: string; - query: any; - splitField: string | undefined; - timeField: string | undefined; - runtimeMappings: RuntimeMappings | undefined; -} +import { IndicesOptions } from '../../../common/types/anomaly_detection_jobs'; +import { BucketSpanEstimatorData } from '../../../common/types/job_service'; export function estimateBucketSpanFactory({ asCurrentUser, diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js index 79f48645d52f2..29961918e7aba 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js @@ -20,7 +20,17 @@ export function estimateBucketSpanFactory(client) { class BucketSpanEstimator { constructor( - { index, timeField, aggTypes, fields, duration, query, splitField, runtimeMappings }, + { + index, + timeField, + aggTypes, + fields, + duration, + query, + splitField, + runtimeMappings, + indicesOptions, + }, splitFieldValues, maxBuckets ) { @@ -72,7 +82,8 @@ export function estimateBucketSpanFactory(client) { this.index, this.timeField, this.duration, - this.query + this.query, + indicesOptions ); if (this.aggTypes.length === this.fields.length) { @@ -89,7 +100,8 @@ export function estimateBucketSpanFactory(client) { this.duration, this.query, this.thresholds, - this.runtimeMappings + this.runtimeMappings, + indicesOptions ), result: null, }); @@ -112,7 +124,8 @@ export function estimateBucketSpanFactory(client) { this.duration, queryCopy, this.thresholds, - this.runtimeMappings + this.runtimeMappings, + indicesOptions ), result: null, }); @@ -246,7 +259,7 @@ export function estimateBucketSpanFactory(client) { } } - const getFieldCardinality = function (index, field, runtimeMappings) { + const getFieldCardinality = function (index, field, runtimeMappings, indicesOptions) { return new Promise((resolve, reject) => { asCurrentUser .search({ @@ -262,6 +275,7 @@ export function estimateBucketSpanFactory(client) { }, ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, + ...(indicesOptions ?? {}), }) .then(({ body }) => { const value = get(body, ['aggregations', 'field_count', 'value'], 0); @@ -273,13 +287,13 @@ export function estimateBucketSpanFactory(client) { }); }; - const getRandomFieldValues = function (index, field, query, runtimeMappings) { + const getRandomFieldValues = function (index, field, query, runtimeMappings, indicesOptions) { let fieldValues = []; return new Promise((resolve, reject) => { const NUM_PARTITIONS = 10; // use a partitioned search to load 10 random fields // load ten fields, to test that there are at least 10. - getFieldCardinality(index, field) + getFieldCardinality(index, field, runtimeMappings, indicesOptions) .then((value) => { const numPartitions = Math.floor(value / NUM_PARTITIONS) || 1; asCurrentUser @@ -301,6 +315,7 @@ export function estimateBucketSpanFactory(client) { }, ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, + ...(indicesOptions ?? {}), }) .then(({ body }) => { // eslint-disable-next-line camelcase @@ -390,7 +405,8 @@ export function estimateBucketSpanFactory(client) { formConfig.index, formConfig.splitField, formConfig.query, - formConfig.runtimeMappings + formConfig.runtimeMappings, + formConfig.indicesOptions ) .then((splitFieldValues) => { runEstimator(splitFieldValues); diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts index aa576d1f69915..de5514ed1e18f 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts @@ -8,8 +8,9 @@ import { IScopedClusterClient } from 'kibana/server'; import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; +import { BucketSpanEstimatorData } from '../../../common/types/job_service'; -import { estimateBucketSpanFactory, BucketSpanEstimatorData } from './bucket_span_estimator'; +import { estimateBucketSpanFactory } from './bucket_span_estimator'; const callAs = { search: () => Promise.resolve({ body: {} }), @@ -36,6 +37,7 @@ const formConfig: BucketSpanEstimatorData = { splitField: undefined, timeField: undefined, runtimeMappings: undefined, + indicesOptions: undefined, }; describe('ML - BucketSpanEstimator', () => { 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 59da334a18393..8a40787f44490 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) { + constructor(index, timeField, duration, query, indicesOptions) { this.index = index; this.timeField = timeField; this.duration = duration; this.query = query; + this.indicesOptions = indicesOptions; this.isPolled = false; this.minimumBucketSpan = 0; @@ -73,6 +74,7 @@ export function polledDataCheckerFactory({ asCurrentUser }) { index: this.index, size: 0, body: searchBody, + ...(this.indicesOptions ?? {}), }); return body; } diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js index 25c87c5c2acbf..f9f01070f2f82 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js @@ -18,7 +18,17 @@ export function singleSeriesCheckerFactory({ asCurrentUser }) { const REF_DATA_INTERVAL = { name: '1h', ms: 3600000 }; class SingleSeriesChecker { - constructor(index, timeField, aggType, field, duration, query, thresholds, runtimeMappings) { + constructor( + index, + timeField, + aggType, + field, + duration, + query, + thresholds, + runtimeMappings, + indicesOptions + ) { this.index = index; this.timeField = timeField; this.aggType = aggType; @@ -32,6 +42,7 @@ export function singleSeriesCheckerFactory({ asCurrentUser }) { created: false, }; this.runtimeMappings = runtimeMappings; + this.indicesOptions = indicesOptions; this.interval = null; } @@ -193,6 +204,7 @@ export function singleSeriesCheckerFactory({ asCurrentUser }) { index: this.index, size: 0, body: searchBody, + ...(this.indicesOptions ?? {}), }); return body; } 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 56eddf9df2e04..1270cc6f08e23 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 @@ -13,7 +13,7 @@ import { initCardinalityFieldsCache } from './fields_aggs_cache'; import { AggCardinality } from '../../../common/types/fields'; import { isValidAggregationField } from '../../../common/util/validation_utils'; import { getDatafeedAggregations } from '../../../common/util/datafeed_utils'; -import { Datafeed } from '../../../common/types/anomaly_detection_jobs'; +import { Datafeed, IndicesOptions } from '../../../common/types/anomaly_detection_jobs'; /** * Service for carrying out queries to obtain data @@ -183,6 +183,7 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { } = await asCurrentUser.search({ index, body, + ...(datafeedConfig?.indices_options ?? {}), }); if (!aggregations) { @@ -210,7 +211,8 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { async function getTimeFieldRange( index: string[] | string, timeFieldName: string, - query: any + query: any, + indicesOptions?: IndicesOptions ): Promise<{ success: boolean; start: { epoch: number; string: string }; @@ -238,6 +240,7 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { }, }, }, + ...(indicesOptions ?? {}), }); if (aggregations && aggregations.earliest && aggregations.latest) { @@ -394,6 +397,7 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { } = await asCurrentUser.search({ index, body, + ...(datafeedConfig?.indices_options ?? {}), }); if (!aggregations) { 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 88c4659198727..cb651a0a410af 100644 --- a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts +++ b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts @@ -6,10 +6,15 @@ */ import { i18n } from '@kbn/i18n'; +import { IScopedClusterClient } from 'kibana/server'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; import { fillResultsWithTimeouts, isRequestTimeout } from './error_utils'; -import { Datafeed, DatafeedStats } from '../../../common/types/anomaly_detection_jobs'; +import { Datafeed, DatafeedStats, Job } from '../../../common/types/anomaly_detection_jobs'; +import { ML_DATA_PREVIEW_COUNT } from '../../../common/util/job_utils'; +import { fieldsServiceProvider } from '../fields_service'; import type { MlClient } from '../../lib/ml_client'; +import { parseInterval } from '../../../common/util/parse_interval'; +import { isPopulatedObject } from '../../../common/util/object_utils'; export interface MlDatafeedsResponse { datafeeds: Datafeed[]; @@ -27,7 +32,7 @@ interface Results { }; } -export function datafeedsProvider(mlClient: MlClient) { +export function datafeedsProvider(client: IScopedClusterClient, mlClient: MlClient) { async function forceStartDatafeeds(datafeedIds: string[], start?: number, end?: number) { const jobIds = await getJobIdsByDatafeedId(); const doStartsCalled = datafeedIds.reduce((acc, cur) => { @@ -204,6 +209,153 @@ export function datafeedsProvider(mlClient: MlClient) { } } + async function datafeedPreview(job: Job, datafeed: Datafeed) { + let query: any = { match_all: {} }; + if (datafeed.query) { + query = datafeed.query; + } + const { getTimeFieldRange } = fieldsServiceProvider(client); + const { start } = await getTimeFieldRange( + datafeed.indices, + job.data_description.time_field, + query, + datafeed.indices_options + ); + + // Get bucket span + // Get first doc time for datafeed + // Create a new query - must user query and must range query. + // Time range 'to' first doc time plus < 10 buckets + + // Do a preliminary search to get the date of the earliest doc matching the + // query in the datafeed. This will be used to apply a time range criteria + // on the datafeed search preview. + // This time filter is required for datafeed searches using aggregations to ensure + // the search does not create too many buckets (default 10000 max_bucket limit), + // but apply it to searches without aggregations too for consistency. + const bucketSpan = parseInterval(job.analysis_config.bucket_span); + if (bucketSpan === null) { + return; + } + const earliestMs = start.epoch; + const latestMs = +start.epoch + 10 * bucketSpan.asMilliseconds(); + + const body: any = { + query: { + bool: { + must: [ + { + range: { + [job.data_description.time_field]: { + gte: earliestMs, + lt: latestMs, + format: 'epoch_millis', + }, + }, + }, + query, + ], + }, + }, + }; + + // if aggs or aggregations is set, add it to the search + const aggregations = datafeed.aggs ?? datafeed.aggregations; + if (isPopulatedObject(aggregations)) { + body.size = 0; + body.aggregations = aggregations; + + // add script_fields if present + const scriptFields = datafeed.script_fields; + if (isPopulatedObject(scriptFields)) { + body.script_fields = scriptFields; + } + + // add runtime_mappings if present + const runtimeMappings = datafeed.runtime_mappings; + if (isPopulatedObject(runtimeMappings)) { + body.runtime_mappings = runtimeMappings; + } + } else { + // if aggregations is not set and retrieveWholeSource is not set, add all of the fields from the job + body.size = ML_DATA_PREVIEW_COUNT; + + // add script_fields if present + const scriptFields = datafeed.script_fields; + if (isPopulatedObject(scriptFields)) { + body.script_fields = scriptFields; + } + + // add runtime_mappings if present + const runtimeMappings = datafeed.runtime_mappings; + if (isPopulatedObject(runtimeMappings)) { + body.runtime_mappings = runtimeMappings; + } + + const fields = new Set<string>(); + + // get fields from detectors + if (job.analysis_config.detectors) { + job.analysis_config.detectors.forEach((dtr) => { + if (dtr.by_field_name) { + fields.add(dtr.by_field_name); + } + if (dtr.field_name) { + fields.add(dtr.field_name); + } + if (dtr.over_field_name) { + fields.add(dtr.over_field_name); + } + if (dtr.partition_field_name) { + fields.add(dtr.partition_field_name); + } + }); + } + + // get fields from influencers + if (job.analysis_config.influencers) { + job.analysis_config.influencers.forEach((inf) => { + fields.add(inf); + }); + } + + // get fields from categorizationFieldName + if (job.analysis_config.categorization_field_name) { + fields.add(job.analysis_config.categorization_field_name); + } + + // get fields from summary_count_field_name + if (job.analysis_config.summary_count_field_name) { + fields.add(job.analysis_config.summary_count_field_name); + } + + // get fields from time_field + if (job.data_description.time_field) { + fields.add(job.data_description.time_field); + } + + // add runtime fields + if (runtimeMappings) { + Object.keys(runtimeMappings).forEach((fieldName) => { + fields.add(fieldName); + }); + } + + const fieldsList = [...fields]; + if (fieldsList.length) { + body.fields = fieldsList; + body._source = false; + } + } + const data = { + index: datafeed.indices, + body, + ...(datafeed.indices_options ?? {}), + }; + + return (await client.asCurrentUser.search(data)).body; + } + return { forceStartDatafeeds, stopDatafeeds, @@ -211,5 +363,6 @@ export function datafeedsProvider(mlClient: MlClient) { getDatafeedIdsByJobId, getJobIdsByDatafeedId, getDatafeedByJobId, + datafeedPreview, }; } diff --git a/x-pack/plugins/ml/server/models/job_service/index.ts b/x-pack/plugins/ml/server/models/job_service/index.ts index e88ff8cb751aa..d36ec822c1314 100644 --- a/x-pack/plugins/ml/server/models/job_service/index.ts +++ b/x-pack/plugins/ml/server/models/job_service/index.ts @@ -16,12 +16,12 @@ import type { MlClient } from '../../lib/ml_client'; export function jobServiceProvider(client: IScopedClusterClient, mlClient: MlClient) { return { - ...datafeedsProvider(mlClient), + ...datafeedsProvider(client, mlClient), ...jobsProvider(client, mlClient), ...groupsProvider(mlClient), ...newJobCapsProvider(client), ...newJobChartsProvider(client), ...topCategoriesProvider(mlClient), - ...modelSnapshotProvider(mlClient), + ...modelSnapshotProvider(client, mlClient), }; } 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 dc2c04540ef21..ac3e00a918da8 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -52,6 +52,7 @@ export function jobsProvider(client: IScopedClusterClient, mlClient: MlClient) { const { asInternalUser } = client; const { forceDeleteDatafeed, getDatafeedIdsByJobId, getDatafeedByJobId } = datafeedsProvider( + client, mlClient ); const { getAuditMessagesSummary } = jobAuditMessagesProvider(client, mlClient); 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 9f4607414c10a..f1f5d98b96a53 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 @@ -7,6 +7,7 @@ import Boom from '@hapi/boom'; import { i18n } from '@kbn/i18n'; +import { IScopedClusterClient } from 'kibana/server'; import { ModelSnapshot } from '../../../common/types/anomaly_detection_jobs'; import { datafeedsProvider } from './datafeeds'; import { FormCalendar, CalendarManager } from '../calendar'; @@ -20,8 +21,8 @@ export interface RevertModelSnapshotResponse { model: ModelSnapshot; } -export function modelSnapshotProvider(mlClient: MlClient) { - const { forceStartDatafeeds, getDatafeedIdsByJobId } = datafeedsProvider(mlClient); +export function modelSnapshotProvider(client: IScopedClusterClient, mlClient: MlClient) { + const { forceStartDatafeeds, getDatafeedIdsByJobId } = datafeedsProvider(client, mlClient); async function revertModelSnapshot( jobId: string, 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 63df425791e85..37fa675362773 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 @@ -15,6 +15,7 @@ import { CategoryFieldExample, } from '../../../../../common/types/categories'; import { RuntimeMappings } from '../../../../../common/types/fields'; +import { IndicesOptions } from '../../../../../common/types/anomaly_detection_jobs'; import { ValidationResults } from './validation_results'; const CHUNK_SIZE = 100; @@ -34,7 +35,8 @@ export function categorizationExamplesProvider({ start: number, end: number, analyzer: CategorizationAnalyzer, - runtimeMappings: RuntimeMappings | undefined + runtimeMappings: RuntimeMappings | undefined, + indicesOptions: IndicesOptions | undefined ): Promise<{ examples: CategoryFieldExample[]; error?: any }> { if (timeField !== undefined) { const range = { @@ -69,6 +71,7 @@ export function categorizationExamplesProvider({ sort: ['_doc'], ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, + ...(indicesOptions ?? {}), }); // hit.fields can be undefined if value is originally null @@ -169,7 +172,8 @@ export function categorizationExamplesProvider({ start: number, end: number, analyzer: CategorizationAnalyzer, - runtimeMappings: RuntimeMappings | undefined + runtimeMappings: RuntimeMappings | undefined, + indicesOptions: IndicesOptions | undefined ) { const resp = await categorizationExamples( indexPatternTitle, @@ -180,7 +184,8 @@ export function categorizationExamplesProvider({ start, end, analyzer, - runtimeMappings + runtimeMappings, + indicesOptions ); const { examples } = resp; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts index c83485211b455..e6a2432e28dc1 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts @@ -12,6 +12,7 @@ import { EVENT_RATE_FIELD_ID, RuntimeMappings, } from '../../../../common/types/fields'; +import { IndicesOptions } from '../../../../common/types/anomaly_detection_jobs'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; type DtrIndex = number; @@ -39,7 +40,8 @@ export function newJobLineChartProvider({ asCurrentUser }: IScopedClusterClient) aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, splitFieldValue: string | null, - runtimeMappings: RuntimeMappings | undefined + runtimeMappings: RuntimeMappings | undefined, + indicesOptions: IndicesOptions | undefined ) { const json: object = getSearchJsonFromConfig( indexPatternTitle, @@ -51,7 +53,8 @@ export function newJobLineChartProvider({ asCurrentUser }: IScopedClusterClient) aggFieldNamePairs, splitFieldName, splitFieldValue, - runtimeMappings + runtimeMappings, + indicesOptions ); const { body } = await asCurrentUser.search(json); @@ -110,7 +113,8 @@ function getSearchJsonFromConfig( aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, splitFieldValue: string | null, - runtimeMappings: RuntimeMappings | undefined + runtimeMappings: RuntimeMappings | undefined, + indicesOptions: IndicesOptions | undefined ): object { const json = { index: indexPatternTitle, @@ -134,6 +138,7 @@ function getSearchJsonFromConfig( }, ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, + ...(indicesOptions ?? {}), }; if (query.bool === undefined) { diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts index 10f6d94e764ac..2385ffef67191 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts @@ -12,6 +12,7 @@ import { EVENT_RATE_FIELD_ID, RuntimeMappings, } from '../../../../common/types/fields'; +import { IndicesOptions } from '../../../../common/types/anomaly_detection_jobs'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; const OVER_FIELD_EXAMPLES_COUNT = 40; @@ -44,7 +45,8 @@ export function newJobPopulationChartProvider({ asCurrentUser }: IScopedClusterC query: object, aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, - runtimeMappings: RuntimeMappings | undefined + runtimeMappings: RuntimeMappings | undefined, + indicesOptions: IndicesOptions | undefined ) { const json: object = getPopulationSearchJsonFromConfig( indexPatternTitle, @@ -55,7 +57,8 @@ export function newJobPopulationChartProvider({ asCurrentUser }: IScopedClusterC query, aggFieldNamePairs, splitFieldName, - runtimeMappings + runtimeMappings, + indicesOptions ); const { body } = await asCurrentUser.search(json); @@ -138,7 +141,8 @@ function getPopulationSearchJsonFromConfig( query: any, aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, - runtimeMappings: RuntimeMappings | undefined + runtimeMappings: RuntimeMappings | undefined, + indicesOptions: IndicesOptions | undefined ): object { const json = { index: indexPatternTitle, @@ -162,6 +166,7 @@ function getPopulationSearchJsonFromConfig( }, ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, + ...(indicesOptions ?? {}), }; if (query.bool === undefined) { diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts index c3c3d52465d40..949159b67d33a 100644 --- a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts @@ -8,7 +8,6 @@ import { IScopedClusterClient } from 'kibana/server'; import { validateJob, ValidateJobPayload } from './job_validation'; -import { JobValidationMessage } from '../../../common/constants/messages'; import { HITS_TOTAL_RELATION } from '../../../common/types/es_client'; import type { MlClient } from '../../lib/ml_client'; @@ -277,7 +276,6 @@ describe('ML - validateJob', () => { }); }); - // Failing https://github.com/elastic/kibana/issues/65865 it('basic validation passes, extended checks return some messages', () => { const payload = getBasicPayload(); return validateJob(mlClusterClient, mlClient, payload).then((messages) => { @@ -291,7 +289,6 @@ describe('ML - validateJob', () => { }); }); - // Failing https://github.com/elastic/kibana/issues/65866 it('categorization job using mlcategory passes aggregatable field check', () => { const payload: any = { job: { @@ -358,7 +355,6 @@ describe('ML - validateJob', () => { }); }); - // Failing https://github.com/elastic/kibana/issues/65867 it('script field not reported as non aggregatable', () => { const payload: any = { job: { @@ -401,27 +397,4 @@ describe('ML - validateJob', () => { ]); }); }); - - // the following two tests validate the correct template rendering of - // urls in messages with {{version}} in them to be replaced with the - // specified version. (defaulting to 'current') - const docsTestPayload = getBasicPayload() as any; - docsTestPayload.job.analysis_config.detectors = [{ function: 'count', by_field_name: 'airline' }]; - it('creates a docs url pointing to the current docs version', () => { - return validateJob(mlClusterClient, mlClient, docsTestPayload).then((messages) => { - const message = messages[ - messages.findIndex((m) => m.id === 'field_not_aggregatable') - ] as JobValidationMessage; - expect(message.url!.search('/current/')).not.toBe(-1); - }); - }); - - it('creates a docs url pointing to the master docs version', () => { - return validateJob(mlClusterClient, mlClient, docsTestPayload, 'master').then((messages) => { - const message = messages[ - messages.findIndex((m) => m.id === 'field_not_aggregatable') - ] as JobValidationMessage; - expect(message.url!.search('/master/')).not.toBe(-1); - }); - }); }); 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 5f2fe180577c9..31d98753f0bd1 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 @@ -5,17 +5,11 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; import Boom from '@hapi/boom'; import { IScopedClusterClient } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { fieldsServiceProvider } from '../fields_service'; -import { renderTemplate } from '../../../common/util/string_utils'; -import { - getMessages, - MessageId, - JobValidationMessageDef, -} from '../../../common/constants/messages'; +import { getMessages, MessageId, JobValidationMessage } from '../../../common/constants/messages'; import { VALIDATION_STATUS } from '../../../common/constants/validation'; import { basicJobValidation, uniqWithIsEqual } from '../../../common/util/job_utils'; @@ -40,7 +34,6 @@ export async function validateJob( client: IScopedClusterClient, mlClient: MlClient, payload: ValidateJobPayload, - kbnVersion = 'current', isSecurityDisabled?: boolean ) { const messages = getMessages(); @@ -55,7 +48,7 @@ export async function validateJob( // if so, run the extended tests and merge the messages. // otherwise just return the basic test messages. const basicValidation = basicJobValidation(job, fields, {}, true); - let validationMessages; + let validationMessages: JobValidationMessage[]; if (basicValidation.valid === true) { // remove basic success messages from tests @@ -113,36 +106,7 @@ export async function validateJob( validationMessages.push({ id: 'skipped_extended_tests' }); } - return uniqWithIsEqual(validationMessages).map((message) => { - const messageId = message.id as MessageId; - const messageDef = messages[messageId] as JobValidationMessageDef; - if (typeof messageDef !== 'undefined') { - // render the message template with the provided metadata - if (typeof messageDef.heading !== 'undefined') { - message.heading = renderTemplate(messageDef.heading, message); - } - message.text = renderTemplate(messageDef.text, message); - // check if the error message provides a link with further information - // if so, add it to the message to be returned with it - if (typeof messageDef.url !== 'undefined') { - // the link is also treated as a template so we're able to dynamically link to - // documentation links matching the running version of Kibana. - message.url = renderTemplate(messageDef.url, { version: kbnVersion! }); - } - - message.status = messageDef.status; - } else { - message.text = i18n.translate( - 'xpack.ml.models.jobValidation.unknownMessageIdErrorMessage', - { - defaultMessage: '{messageId} (unknown message id)', - values: { messageId }, - } - ); - } - - return message; - }); + return uniqWithIsEqual(validationMessages); } catch (error) { throw Boom.badRequest(error); } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 3aee2ec89a6e1..c4ee1fd76530e 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -67,7 +67,6 @@ export type MlPluginStart = void; export class MlServerPlugin implements Plugin<MlPluginSetup, MlPluginStart, PluginsSetup, PluginsStart> { private log: Logger; - private version: string; private mlLicense: MlLicense; private capabilities: CapabilitiesStart | null = null; private clusterClient: IClusterClient | null = null; @@ -79,7 +78,6 @@ export class MlServerPlugin constructor(ctx: PluginInitializerContext) { this.log = ctx.logger.get(); - this.version = ctx.env.packageInfo.branch; this.mlLicense = new MlLicense(); this.isMlReady = new Promise((resolve) => (this.setMlReady = resolve)); } @@ -182,7 +180,7 @@ export class MlServerPlugin jobServiceRoutes(routeInit); notificationRoutes(routeInit); resultsServiceRoutes(routeInit); - jobValidationRoutes(routeInit, this.version); + jobValidationRoutes(routeInit); savedObjectsRoutes(routeInit, { getSpaces, resolveMlCapabilities, diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 33ae5b4f96829..1a10046380658 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -84,6 +84,7 @@ "GetLookBackProgress", "ValidateCategoryExamples", "TopCategories", + "DatafeedPreview", "UpdateGroups", "DeletingJobTasks", "DeleteJobs", diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.test.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.test.ts index b7a706a9bbd0c..6c15e6c707a5a 100644 --- a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.test.ts +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.test.ts @@ -133,7 +133,7 @@ describe('schema_extractor', () => { { name: 'expand_wildcards', documentation: '', - type: 'string[]', + type: '"all" | "open" | "closed" | "hidden" | "none"[]', }, { name: 'ignore_unavailable', diff --git a/x-pack/plugins/ml/server/routes/fields_service.ts b/x-pack/plugins/ml/server/routes/fields_service.ts index 6e68a80354491..c087b86172fa9 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 } = payload; - return fs.getTimeFieldRange(index, timeFieldName, query); + const { index, timeFieldName, query, indicesOptions } = payload; + return fs.getTimeFieldRange(index, timeFieldName, query, indicesOptions); } /** diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index 1e028dfb20b4d..b3aa9f956895a 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -20,12 +20,14 @@ import { updateGroupsSchema, revertModelSnapshotSchema, jobsExistSchema, + datafeedPreviewSchema, } from './schemas/job_service_schema'; import { jobIdSchema } from './schemas/anomaly_detectors_schema'; import { jobServiceProvider } from '../models/job_service'; import { categorizationExamplesProvider } from '../models/job_service/new_job'; +import { getAuthorizationHeader } from '../lib/request_authorization'; /** * Routes for job service @@ -535,6 +537,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { splitFieldName, splitFieldValue, runtimeMappings, + indicesOptions, } = request.body; const { newJobLineChart } = jobServiceProvider(client, mlClient); @@ -548,7 +551,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { aggFieldNamePairs, splitFieldName, splitFieldValue, - runtimeMappings + runtimeMappings, + indicesOptions ); return response.ok({ @@ -591,6 +595,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { aggFieldNamePairs, splitFieldName, runtimeMappings, + indicesOptions, } = request.body; const { newJobPopulationChart } = jobServiceProvider(client, mlClient); @@ -603,7 +608,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { query, aggFieldNamePairs, splitFieldName, - runtimeMappings + runtimeMappings, + indicesOptions ); return response.ok({ @@ -710,6 +716,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { end, analyzer, runtimeMappings, + indicesOptions, } = request.body; const resp = await validateCategoryExamples( @@ -721,7 +728,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { start, end, analyzer, - runtimeMappings + runtimeMappings, + indicesOptions ); return response.ok({ @@ -767,6 +775,52 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { }) ); + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/datafeed_preview Get datafeed preview + * @apiName DatafeedPreview + * @apiDescription Returns a preview of the datafeed search + * + * @apiSchema (body) datafeedPreviewSchema + */ + router.post( + { + path: '/api/ml/jobs/datafeed_preview', + validate: { + body: datafeedPreviewSchema, + }, + options: { + tags: ['access:ml:canGetJobs'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { + try { + const { datafeedId, job, datafeed } = request.body; + + if (datafeedId !== undefined) { + const { body } = await mlClient.previewDatafeed( + { + datafeed_id: datafeedId, + }, + getAuthorizationHeader(request) + ); + return response.ok({ + body, + }); + } + + const { datafeedPreview } = jobServiceProvider(client, mlClient); + const body = await datafeedPreview(job, datafeed); + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup JobService * diff --git a/x-pack/plugins/ml/server/routes/job_validation.ts b/x-pack/plugins/ml/server/routes/job_validation.ts index 6840f827831e8..ae5c66f35215b 100644 --- a/x-pack/plugins/ml/server/routes/job_validation.ts +++ b/x-pack/plugins/ml/server/routes/job_validation.ts @@ -27,10 +27,7 @@ type CalculateModelMemoryLimitPayload = TypeOf<typeof modelMemoryLimitSchema>; /** * Routes for job validation */ -export function jobValidationRoutes( - { router, mlLicense, routeGuard }: RouteInitialization, - version: string -) { +export function jobValidationRoutes({ router, mlLicense, routeGuard }: RouteInitialization) { function calculateModelMemoryLimit( client: IScopedClusterClient, mlClient: MlClient, @@ -191,12 +188,10 @@ export function jobValidationRoutes( }, routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { try { - // version corresponds to the version used in documentation links. const resp = await validateJob( client, mlClient, request.body, - version, mlLicense.isSecurityEnabled() === false ); diff --git a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts index d8d0cd659c2e6..e860be59e4eaf 100644 --- a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts @@ -13,6 +13,23 @@ export const startDatafeedSchema = schema.object({ timeout: schema.maybe(schema.any()), }); +export const indicesOptionsSchema = schema.object({ + expand_wildcards: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.literal('all'), + schema.literal('open'), + schema.literal('closed'), + schema.literal('hidden'), + schema.literal('none'), + ]) + ) + ), + ignore_unavailable: schema.maybe(schema.boolean()), + allow_no_indices: schema.maybe(schema.boolean()), + ignore_throttled: schema.maybe(schema.boolean()), +}); + export const datafeedConfigSchema = schema.object({ datafeed_id: schema.maybe(schema.string()), feed_id: schema.maybe(schema.string()), @@ -35,14 +52,7 @@ export const datafeedConfigSchema = schema.object({ runtime_mappings: schema.maybe(schema.any()), scroll_size: schema.maybe(schema.number()), delayed_data_check_config: schema.maybe(schema.any()), - indices_options: schema.maybe( - schema.object({ - expand_wildcards: schema.maybe(schema.arrayOf(schema.string())), - ignore_unavailable: schema.maybe(schema.boolean()), - allow_no_indices: schema.maybe(schema.boolean()), - ignore_throttled: schema.maybe(schema.boolean()), - }) - ), + indices_options: indicesOptionsSchema, }); export const datafeedIdSchema = schema.object({ datafeedId: schema.string() }); 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 462fca17bda85..db827b26fe73a 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 @@ -6,6 +6,7 @@ */ import { schema } from '@kbn/config-schema'; +import { indicesOptionsSchema } from './datafeeds_schema'; export const getCardinalityOfFieldsSchema = schema.object({ /** Index or indexes for which to return the time range. */ @@ -29,4 +30,6 @@ export const getTimeFieldRangeSchema = schema.object({ timeFieldName: schema.maybe(schema.string()), /** Query to match documents in the index(es). */ query: schema.maybe(schema.any()), + /** Additional search options. */ + indicesOptions: indicesOptionsSchema, }); diff --git a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts index 65955fbc47a37..8e160094c68eb 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts @@ -6,6 +6,8 @@ */ import { schema } from '@kbn/config-schema'; +import { anomalyDetectionJobSchema } from './anomaly_detectors_schema'; +import { datafeedConfigSchema, indicesOptionsSchema } from './datafeeds_schema'; export const categorizationFieldExamplesSchema = { indexPatternTitle: schema.string(), @@ -17,6 +19,7 @@ export const categorizationFieldExamplesSchema = { end: schema.number(), analyzer: schema.any(), runtimeMappings: schema.maybe(schema.any()), + indicesOptions: indicesOptionsSchema, }; export const chartSchema = { @@ -30,6 +33,7 @@ export const chartSchema = { splitFieldName: schema.maybe(schema.nullable(schema.string())), splitFieldValue: schema.maybe(schema.nullable(schema.string())), runtimeMappings: schema.maybe(schema.any()), + indicesOptions: indicesOptionsSchema, }; export const datafeedIdsSchema = schema.object({ @@ -92,6 +96,16 @@ export const revertModelSnapshotSchema = schema.object({ ), }); +export const datafeedPreviewSchema = schema.oneOf([ + schema.object({ + job: schema.maybe(schema.object(anomalyDetectionJobSchema)), + datafeed: schema.maybe(datafeedConfigSchema), + }), + schema.object({ + datafeedId: schema.maybe(schema.string()), + }), +]); + export const jobsExistSchema = schema.object({ jobIds: schema.arrayOf(schema.string()), allSpaces: schema.maybe(schema.boolean()), diff --git a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts index 8c054d54e0589..ad2bafdfb5dd1 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts @@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema'; import { analysisConfigSchema, anomalyDetectionJobSchema } from './anomaly_detectors_schema'; -import { datafeedConfigSchema } from './datafeeds_schema'; +import { datafeedConfigSchema, indicesOptionsSchema } from './datafeeds_schema'; export const estimateBucketSpanSchema = schema.object({ aggTypes: schema.arrayOf(schema.nullable(schema.string())), @@ -19,6 +19,7 @@ export const estimateBucketSpanSchema = schema.object({ splitField: schema.maybe(schema.string()), timeField: schema.maybe(schema.string()), runtimeMappings: schema.maybe(schema.any()), + indicesOptions: indicesOptionsSchema, }); export const modelMemoryLimitSchema = schema.object({ diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.ts index 9a2efade7b44f..fbe487f240699 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Logger, LegacyCallAPIOptions } from 'kibana/server'; +import { Logger, ElasticsearchClient } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { AlertType, @@ -32,7 +32,6 @@ import { fetchClusters } from '../lib/alerts/fetch_clusters'; import { getCcsIndexPattern } from '../lib/alerts/get_ccs_index_pattern'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; import { AlertSeverity } from '../../common/enums'; -import { mbSafeQuery } from '../lib/mb_safe_query'; import { appendMetricbeatIndex } from '../lib/alerts/append_mb_index'; import { parseDuration } from '../../../alerting/common/parse_duration'; import { Globals } from '../static_globals'; @@ -56,12 +55,6 @@ interface AlertOptions { accessorKey?: string; } -type CallCluster = ( - endpoint: string, - clientParams?: Record<string, unknown> | undefined, - options?: LegacyCallAPIOptions | undefined -) => Promise<any>; - const defaultAlertOptions = (): AlertOptions => { return { id: '', @@ -233,29 +226,15 @@ export class BaseAlert { `Executing alert with params: ${JSON.stringify(params)} and state: ${JSON.stringify(state)}` ); - const useCallCluster = - Globals.app.monitoringCluster?.callAsInternalUser || services.callCluster; - const callCluster = async ( - endpoint: string, - clientParams?: Record<string, unknown>, - options?: LegacyCallAPIOptions - ) => { - return await mbSafeQuery(async () => useCallCluster(endpoint, clientParams, options)); - }; - const availableCcs = Globals.app.config.ui.ccs.enabled - ? await fetchAvailableCcs(callCluster) - : []; - const clusters = await this.fetchClusters( - callCluster, - params as CommonAlertParams, - availableCcs - ); - const data = await this.fetchData(params, callCluster, clusters, availableCcs); + const esClient = services.scopedClusterClient.asCurrentUser; + const availableCcs = Globals.app.config.ui.ccs.enabled ? await fetchAvailableCcs(esClient) : []; + const clusters = await this.fetchClusters(esClient, params as CommonAlertParams, availableCcs); + const data = await this.fetchData(params, esClient, clusters, availableCcs); return await this.processData(data, clusters, services, state); } protected async fetchClusters( - callCluster: CallCluster, + esClient: ElasticsearchClient, params: CommonAlertParams, ccs?: string[] ) { @@ -264,7 +243,7 @@ export class BaseAlert { esIndexPattern = getCcsIndexPattern(esIndexPattern, ccs); } if (!params.limit) { - return await fetchClusters(callCluster, esIndexPattern); + return await fetchClusters(esClient, esIndexPattern); } const limit = parseDuration(params.limit); const rangeFilter = this.alertOptions.fetchClustersRange @@ -275,12 +254,12 @@ export class BaseAlert { }, } : undefined; - return await fetchClusters(callCluster, esIndexPattern, rangeFilter); + return await fetchClusters(esClient, esIndexPattern, rangeFilter); } protected async fetchData( params: CommonAlertParams | unknown, - callCluster: CallCluster, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<Array<AlertData & unknown>> { diff --git a/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts b/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts index b089b466564e4..6401c5213ee7d 100644 --- a/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -70,7 +71,7 @@ export class CCRReadExceptionsAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -83,7 +84,7 @@ export class CCRReadExceptionsAlert extends BaseAlert { const endMs = +new Date(); const startMs = endMs - duration; const stats = await fetchCCRReadExceptions( - callCluster, + esClient, esIndexPattern, startMs, endMs, diff --git a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts index 1490a6ce58e04..1c39d6d6b9629 100644 --- a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.test.ts @@ -10,6 +10,7 @@ import { ALERT_CLUSTER_HEALTH } from '../../common/constants'; import { AlertClusterHealthType, AlertSeverity } from '../../common/enums'; import { fetchClusterHealth } from '../lib/alerts/fetch_cluster_health'; import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; const RealDate = Date; @@ -80,7 +81,7 @@ describe('ClusterHealthAlert', () => { const getState = jest.fn(); const executorOptions = { services: { - callCluster: jest.fn(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { replaceState, diff --git a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.ts b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.ts index 15b3a7b486fa2..c5983ae9897fe 100644 --- a/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/cluster_health_alert.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -64,7 +65,7 @@ export class ClusterHealthAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -72,7 +73,7 @@ export class ClusterHealthAlert extends BaseAlert { if (availableCcs) { esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs); } - const healths = await fetchClusterHealth(callCluster, clusters, esIndexPattern); + const healths = await fetchClusterHealth(esClient, clusters, esIndexPattern); return healths.map((clusterHealth) => { const shouldFire = clusterHealth.health !== AlertClusterHealthType.Green; const severity = diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts index 03342099773ca..be10ba15d2674 100644 --- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts @@ -9,6 +9,7 @@ import { CpuUsageAlert } from './cpu_usage_alert'; import { ALERT_CPU_USAGE } from '../../common/constants'; import { fetchCpuUsageNodeStats } from '../lib/alerts/fetch_cpu_usage_node_stats'; import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; const RealDate = Date; @@ -83,7 +84,7 @@ describe('CpuUsageAlert', () => { const getState = jest.fn(); const executorOptions = { services: { - callCluster: jest.fn(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { replaceState, diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts index e95c4402c0f90..438d350d366f8 100644 --- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import numeral from '@elastic/numeral'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -68,7 +69,7 @@ export class CpuUsageAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -80,7 +81,7 @@ export class CpuUsageAlert extends BaseAlert { const endMs = +new Date(); const startMs = endMs - duration; const stats = await fetchCpuUsageNodeStats( - callCluster, + esClient, clusters, esIndexPattern, startMs, diff --git a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts index cdc60faedf0d2..4c40a170e40b4 100644 --- a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts @@ -9,6 +9,7 @@ import { DiskUsageAlert } from './disk_usage_alert'; import { ALERT_DISK_USAGE } from '../../common/constants'; import { fetchDiskUsageNodeStats } from '../lib/alerts/fetch_disk_usage_node_stats'; import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; type IDiskUsageAlertMock = DiskUsageAlert & { defaultParams: { @@ -95,7 +96,7 @@ describe('DiskUsageAlert', () => { const getState = jest.fn(); const executorOptions = { services: { - callCluster: jest.fn(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { replaceState, diff --git a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts index 3503195e51b82..8eb36f322168c 100644 --- a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import numeral from '@elastic/numeral'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -67,7 +68,7 @@ export class DiskUsageAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -77,7 +78,7 @@ export class DiskUsageAlert extends BaseAlert { } const { duration, threshold } = params; const stats = await fetchDiskUsageNodeStats( - callCluster, + esClient, clusters, esIndexPattern, duration as string, diff --git a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts index a231cec762191..2bd67298e7b5a 100644 --- a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.test.ts @@ -9,6 +9,7 @@ import { ElasticsearchVersionMismatchAlert } from './elasticsearch_version_misma import { ALERT_ELASTICSEARCH_VERSION_MISMATCH } from '../../common/constants'; import { fetchElasticsearchVersions } from '../lib/alerts/fetch_elasticsearch_versions'; import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; const RealDate = Date; @@ -84,7 +85,7 @@ describe('ElasticsearchVersionMismatchAlert', () => { const getState = jest.fn(); const executorOptions = { services: { - callCluster: jest.fn(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { replaceState, diff --git a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.ts b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.ts index 735e1c43f569a..d51eb99e3a47d 100644 --- a/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/elasticsearch_version_mismatch_alert.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -53,7 +54,7 @@ export class ElasticsearchVersionMismatchAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -62,7 +63,7 @@ export class ElasticsearchVersionMismatchAlert extends BaseAlert { esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs); } const elasticsearchVersions = await fetchElasticsearchVersions( - callCluster, + esClient, clusters, esIndexPattern, Globals.app.config.ui.max_bucket_size diff --git a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts index 6252fc59ba246..02a8f59aecfbd 100644 --- a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.test.ts @@ -9,6 +9,7 @@ import { KibanaVersionMismatchAlert } from './kibana_version_mismatch_alert'; import { ALERT_KIBANA_VERSION_MISMATCH } from '../../common/constants'; import { fetchKibanaVersions } from '../lib/alerts/fetch_kibana_versions'; import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; const RealDate = Date; @@ -87,7 +88,7 @@ describe('KibanaVersionMismatchAlert', () => { const getState = jest.fn(); const executorOptions = { services: { - callCluster: jest.fn(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { replaceState, diff --git a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.ts b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.ts index 04ee29f5e47fc..3d6417e8fd64c 100644 --- a/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/kibana_version_mismatch_alert.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -66,7 +67,7 @@ export class KibanaVersionMismatchAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -75,7 +76,7 @@ export class KibanaVersionMismatchAlert extends BaseAlert { kibanaIndexPattern = getCcsIndexPattern(kibanaIndexPattern, availableCcs); } const kibanaVersions = await fetchKibanaVersions( - callCluster, + esClient, clusters, kibanaIndexPattern, Globals.app.config.ui.max_bucket_size diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts index b0cbd0edb64f7..2c9e5a04e37e4 100644 --- a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -59,7 +60,7 @@ export class LargeShardSizeAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams & { indexPattern: string }, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -70,7 +71,7 @@ export class LargeShardSizeAlert extends BaseAlert { const { threshold, indexPattern: shardIndexPatterns } = params; const stats = await fetchIndexShardSize( - callCluster, + esClient, clusters, esIndexPattern, threshold!, diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts index 0d1c1d20097e5..0bb8ba23cd490 100644 --- a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts @@ -10,6 +10,7 @@ import { ALERT_LICENSE_EXPIRATION } from '../../common/constants'; import { AlertSeverity } from '../../common/enums'; import { fetchLicenses } from '../lib/alerts/fetch_licenses'; import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; const RealDate = Date; @@ -85,7 +86,7 @@ describe('LicenseExpirationAlert', () => { const getState = jest.fn(); const executorOptions = { services: { - callCluster: jest.fn(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { replaceState, diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts index 9cf50f372ce4f..f5a6f2f7c7e1d 100644 --- a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts @@ -6,6 +6,7 @@ */ import moment from 'moment'; import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -78,7 +79,7 @@ export class LicenseExpirationAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -86,7 +87,7 @@ export class LicenseExpirationAlert extends BaseAlert { if (availableCcs) { esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs); } - const licenses = await fetchLicenses(callCluster, clusters, esIndexPattern); + const licenses = await fetchLicenses(esClient, clusters, esIndexPattern); return licenses.map((license) => { const { clusterUuid, type, expiryDateMS, status, ccs } = license; diff --git a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts index 50a826b36d58f..7c73c63f293f3 100644 --- a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.test.ts @@ -9,6 +9,7 @@ import { LogstashVersionMismatchAlert } from './logstash_version_mismatch_alert' import { ALERT_LOGSTASH_VERSION_MISMATCH } from '../../common/constants'; import { fetchLogstashVersions } from '../lib/alerts/fetch_logstash_versions'; import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; const RealDate = Date; @@ -85,7 +86,7 @@ describe('LogstashVersionMismatchAlert', () => { const getState = jest.fn(); const executorOptions = { services: { - callCluster: jest.fn(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { replaceState, diff --git a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.ts b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.ts index 99080b8230ff3..7ee478b17fff8 100644 --- a/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/logstash_version_mismatch_alert.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -53,7 +54,7 @@ export class LogstashVersionMismatchAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -62,7 +63,7 @@ export class LogstashVersionMismatchAlert extends BaseAlert { logstashIndexPattern = getCcsIndexPattern(logstashIndexPattern, availableCcs); } const logstashVersions = await fetchLogstashVersions( - callCluster, + esClient, clusters, logstashIndexPattern, Globals.app.config.ui.max_bucket_size diff --git a/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts index 05dc0271cf3f7..06cd90ca80729 100644 --- a/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import numeral from '@elastic/numeral'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -68,7 +69,7 @@ export class MemoryUsageAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -82,7 +83,7 @@ export class MemoryUsageAlert extends BaseAlert { const startMs = endMs - parsedDuration; const stats = await fetchMemoryUsageNodeStats( - callCluster, + esClient, clusters, esIndexPattern, startMs, diff --git a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts index 28085f8b5e388..87790ee111326 100644 --- a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts @@ -9,6 +9,7 @@ import { MissingMonitoringDataAlert } from './missing_monitoring_data_alert'; import { ALERT_MISSING_MONITORING_DATA } from '../../common/constants'; import { fetchMissingMonitoringData } from '../lib/alerts/fetch_missing_monitoring_data'; import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; const RealDate = Date; @@ -87,7 +88,7 @@ describe('MissingMonitoringDataAlert', () => { const getState = jest.fn(); const executorOptions = { services: { - callCluster: jest.fn(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { replaceState, diff --git a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts index adf10e4e56dbc..ed35f775b249c 100644 --- a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -66,7 +67,7 @@ export class MissingMonitoringDataAlert extends BaseAlert { } protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -78,7 +79,7 @@ export class MissingMonitoringDataAlert extends BaseAlert { const limit = parseDuration(params.limit!); const now = +new Date(); const missingData = await fetchMissingMonitoringData( - callCluster, + esClient, clusters, indexPattern, Globals.app.config.ui.max_bucket_size, diff --git a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts index 848436573fab9..fa97de364d792 100644 --- a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.test.ts @@ -9,6 +9,7 @@ import { NodesChangedAlert } from './nodes_changed_alert'; import { ALERT_NODES_CHANGED } from '../../common/constants'; import { fetchNodesFromClusterStats } from '../lib/alerts/fetch_nodes_from_cluster_stats'; import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; const RealDate = Date; @@ -106,7 +107,7 @@ describe('NodesChangedAlert', () => { const getState = jest.fn(); const executorOptions = { services: { - callCluster: jest.fn(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { replaceState, diff --git a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.ts b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.ts index d040ce1a890ae..b26008ff3860d 100644 --- a/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/nodes_changed_alert.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -102,7 +103,7 @@ export class NodesChangedAlert extends BaseAlert { protected async fetchData( params: CommonAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -111,7 +112,7 @@ export class NodesChangedAlert extends BaseAlert { esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs); } const nodesFromClusterStats = await fetchNodesFromClusterStats( - callCluster, + esClient, clusters, esIndexPattern ); diff --git a/x-pack/plugins/monitoring/server/alerts/thread_pool_rejections_alert_base.ts b/x-pack/plugins/monitoring/server/alerts/thread_pool_rejections_alert_base.ts index cee319504e461..bb91418fc2090 100644 --- a/x-pack/plugins/monitoring/server/alerts/thread_pool_rejections_alert_base.ts +++ b/x-pack/plugins/monitoring/server/alerts/thread_pool_rejections_alert_base.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; import { BaseAlert } from './base_alert'; import { AlertData, @@ -66,7 +67,7 @@ export class ThreadPoolRejectionsAlertBase extends BaseAlert { protected async fetchData( params: ThreadPoolRejectionsAlertParams, - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], availableCcs: string[] ): Promise<AlertData[]> { @@ -78,7 +79,7 @@ export class ThreadPoolRejectionsAlertBase extends BaseAlert { const { threshold, duration } = params; const stats = await fetchThreadPoolRejectionStats( - callCluster, + esClient, clusters, esIndexPattern, Globals.app.config.ui.max_bucket_size, diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts index 4080367d0e75d..7096647854c15 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts @@ -93,7 +93,7 @@ export function getSettingsCollector( false, KibanaSettingsCollectorExtraOptions >({ - type: KIBANA_SETTINGS_TYPE, + type: 'kibana_settings', isReady: () => true, schema: { xpack: { diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.test.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.test.ts index e1a93dec8aaee..03a3659b49ce1 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.test.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.test.ts @@ -6,11 +6,11 @@ */ import { getMonitoringUsageCollector } from './get_usage_collector'; -import { fetchClusters } from '../../lib/alerts/fetch_clusters'; +import { fetchClustersLegacy } from '../../lib/alerts/fetch_clusters'; import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; jest.mock('../../lib/alerts/fetch_clusters', () => ({ - fetchClusters: jest.fn().mockImplementation(() => { + fetchClustersLegacy: jest.fn().mockImplementation(() => { return [ { clusterUuid: '1abc', @@ -153,7 +153,7 @@ describe('getMonitoringUsageCollector', () => { const mock = (usageCollection.makeUsageCollector as jest.Mock).mock; const args = mock.calls[0]; - (fetchClusters as jest.Mock).mockImplementation(() => { + (fetchClustersLegacy as jest.Mock).mockImplementation(() => { return []; }); @@ -173,7 +173,7 @@ describe('getMonitoringUsageCollector', () => { const mock = (usageCollection.makeUsageCollector as jest.Mock).mock; const args = mock.calls[0]; - (fetchClusters as jest.Mock).mockImplementation(() => { + (fetchClustersLegacy as jest.Mock).mockImplementation(() => { return []; }); diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts index 1ea7b9b8ac407..6f638b6ff8f0e 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_usage_collector.ts @@ -8,13 +8,13 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ILegacyClusterClient } from 'src/core/server'; import { MonitoringConfig } from '../../config'; -import { fetchAvailableCcs } from '../../lib/alerts/fetch_available_ccs'; +import { fetchAvailableCcsLegacy } from '../../lib/alerts/fetch_available_ccs'; import { getStackProductsUsage } from './lib/get_stack_products_usage'; import { fetchLicenseType } from './lib/fetch_license_type'; import { MonitoringUsage, StackProductUsage, MonitoringClusterStackProductUsage } from './types'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../common/constants'; import { getCcsIndexPattern } from '../../lib/alerts/get_ccs_index_pattern'; -import { fetchClusters } from '../../lib/alerts/fetch_clusters'; +import { fetchClustersLegacy } from '../../lib/alerts/fetch_clusters'; export function getMonitoringUsageCollector( usageCollection: UsageCollectionSetup, @@ -106,9 +106,9 @@ export function getMonitoringUsageCollector( ? legacyEsClient.asScoped(kibanaRequest).callAsCurrentUser : legacyEsClient.callAsInternalUser; const usageClusters: MonitoringClusterStackProductUsage[] = []; - const availableCcs = config.ui.ccs.enabled ? await fetchAvailableCcs(callCluster) : []; + const availableCcs = config.ui.ccs.enabled ? await fetchAvailableCcsLegacy(callCluster) : []; const elasticsearchIndex = getCcsIndexPattern(INDEX_PATTERN_ELASTICSEARCH, availableCcs); - const clusters = await fetchClusters(callCluster, elasticsearchIndex); + const clusters = await fetchClustersLegacy(callCluster, elasticsearchIndex); for (const cluster of clusters) { const license = await fetchLicenseType(callCluster, availableCcs, cluster.clusterUuid); const stackProducts = await getStackProductsUsage( 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 20eea1b5ed8e1..ecfb5fc50a16d 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,34 +5,46 @@ * 2.0. */ +// 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 { fetchAvailableCcs } from './fetch_available_ccs'; describe('fetchAvailableCcs', () => { it('should call the `cluster.remoteInfo` api', async () => { - const callCluster = jest.fn(); - await fetchAvailableCcs(callCluster); - expect(callCluster).toHaveBeenCalledWith('cluster.remoteInfo'); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; + + await fetchAvailableCcs(esClient); + expect(esClient.cluster.remoteInfo).toHaveBeenCalled(); }); it('should return clusters that are connected', async () => { const connectedRemote = 'myRemote'; - const callCluster = jest.fn().mockImplementation(() => ({ - [connectedRemote]: { - connected: true, - }, - })); - const result = await fetchAvailableCcs(callCluster); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; + esClient.cluster.remoteInfo.mockImplementation(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ + [connectedRemote]: { + connected: true, + }, + }) + ); + + const result = await fetchAvailableCcs(esClient); expect(result).toEqual([connectedRemote]); }); it('should not return clusters that are connected', async () => { const disconnectedRemote = 'myRemote'; - const callCluster = jest.fn().mockImplementation(() => ({ - [disconnectedRemote]: { - connected: false, - }, - })); - const result = await fetchAvailableCcs(callCluster); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; + esClient.cluster.remoteInfo.mockImplementation(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ + [disconnectedRemote]: { + connected: false, + }, + }) + ); + + const result = await fetchAvailableCcs(esClient); expect(result.length).toBe(0); }); }); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts index 10bc2ead2cb11..0dd0def028e36 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts @@ -5,7 +5,24 @@ * 2.0. */ -export async function fetchAvailableCcs(callCluster: any): Promise<string[]> { +import { ElasticsearchClient } from 'kibana/server'; + +export async function fetchAvailableCcs(esClient: ElasticsearchClient): Promise<string[]> { + const availableCcs = []; + const { body: response } = await esClient.cluster.remoteInfo(); + for (const remoteName in response) { + if (!response.hasOwnProperty(remoteName)) { + continue; + } + const remoteInfo = response[remoteName]; + if (remoteInfo.connected) { + availableCcs.push(remoteName); + } + } + return availableCcs; +} + +export async function fetchAvailableCcsLegacy(callCluster: any): Promise<string[]> { const availableCcs = []; const response = await callCluster('cluster.remoteInfo'); for (const remoteName in response) { 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 8aede7a73e61d..330be4e90ed56 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 @@ -5,11 +5,12 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import { CCRReadExceptionsStats } from '../../../common/types/alerts'; export async function fetchCCRReadExceptions( - callCluster: any, + esClient: ElasticsearchClient, index: string, startMs: number, endMs: number, @@ -92,7 +93,7 @@ export async function fetchCCRReadExceptions( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const stats: CCRReadExceptionsStats[] = []; const { buckets: remoteClusterBuckets = [] } = response.aggregations.remote_clusters; 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 2fdbbe80b7e89..d326c7f4bedda 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,32 +5,37 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from '../../../../../../src/core/server/elasticsearch/client/mocks'; import { fetchClusterHealth } from './fetch_cluster_health'; describe('fetchClusterHealth', () => { it('should return the cluster health', async () => { const status = 'green'; const clusterUuid = 'sdfdsaj34434'; - const callCluster = jest.fn(() => ({ - hits: { - hits: [ - { - _index: '.monitoring-es-7', - _source: { - cluster_state: { - status, + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + hits: { + hits: [ + { + _index: '.monitoring-es-7', + _source: { + cluster_state: { + status, + }, + cluster_uuid: clusterUuid, }, - cluster_uuid: clusterUuid, }, - }, - ], - }, - })); + ], + }, + }) + ); const clusters = [{ clusterUuid, clusterName: 'foo' }]; const index = '.monitoring-es-*'; - const health = await fetchClusterHealth(callCluster, clusters, index); + const health = await fetchClusterHealth(esClient, clusters, index); expect(health).toEqual([ { health: status, 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 bcfa2da0958a2..be91aaa6ec983 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 @@ -4,11 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { AlertCluster, AlertClusterHealth } from '../../../common/types/alerts'; import { ElasticsearchSource } from '../../../common/types/es'; export async function fetchClusterHealth( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string ): Promise<AlertClusterHealth[]> { @@ -58,7 +59,7 @@ export async function fetchClusterHealth( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); return response.hits.hits.map((hit: { _source: ElasticsearchSource; _index: string }) => { return { health: hit._source.cluster_state?.status, 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 7a1d0acd73b12..54aa2e68d4ef2 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,9 @@ * 2.0. */ +// 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 { fetchClusters } from './fetch_clusters'; describe('fetchClusters', () => { @@ -12,54 +15,60 @@ describe('fetchClusters', () => { const clusterName = 'monitoring'; it('return a list of clusters', async () => { - const callCluster = jest.fn().mockImplementation(() => ({ - hits: { - hits: [ - { - _source: { - cluster_uuid: clusterUuid, - cluster_name: clusterName, + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + hits: { + hits: [ + { + _source: { + cluster_uuid: clusterUuid, + cluster_name: clusterName, + }, }, - }, - ], - }, - })); + ], + }, + }) + ); const index = '.monitoring-es-*'; - const result = await fetchClusters(callCluster, index); + const result = await fetchClusters(esClient, index); expect(result).toEqual([{ clusterUuid, clusterName }]); }); it('return the metadata name if available', async () => { const metadataName = 'custom-monitoring'; - const callCluster = jest.fn().mockImplementation(() => ({ - hits: { - hits: [ - { - _source: { - cluster_uuid: clusterUuid, - cluster_name: clusterName, - cluster_settings: { - cluster: { - metadata: { - display_name: metadataName, + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + hits: { + hits: [ + { + _source: { + cluster_uuid: clusterUuid, + cluster_name: clusterName, + cluster_settings: { + cluster: { + metadata: { + display_name: metadataName, + }, }, }, }, }, - }, - ], - }, - })); + ], + }, + }) + ); const index = '.monitoring-es-*'; - const result = await fetchClusters(callCluster, index); + const result = await fetchClusters(esClient, index); expect(result).toEqual([{ clusterUuid, clusterName: metadataName }]); }); it('should limit the time period in the query', async () => { - const callCluster = jest.fn(); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; const index = '.monitoring-es-*'; - await fetchClusters(callCluster, index); - const params = callCluster.mock.calls[0][1]; - expect(params.body.query.bool.filter[1].range.timestamp.gte).toBe('now-2m'); + await fetchClusters(esClient, index); + const params = esClient.search.mock.calls[0][0] as any; + expect(params?.body?.query.bool.filter[1].range.timestamp.gte).toBe('now-2m'); }); }); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts index 871370977bb38..bbaea8d9f206e 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import { AlertCluster } from '../../../common/types/alerts'; @@ -16,7 +17,7 @@ interface RangeFilter { } export async function fetchClusters( - callCluster: any, + esClient: ElasticsearchClient, index: string, rangeFilter: RangeFilter = { timestamp: { gte: 'now-2m' } } ): Promise<AlertCluster[]> { @@ -49,6 +50,52 @@ export async function fetchClusters( }, }; + const { body: response } = await esClient.search(params); + return get(response, 'hits.hits', []).map((hit: any) => { + const clusterName: string = + get(hit, '_source.cluster_settings.cluster.metadata.display_name') || + get(hit, '_source.cluster_name') || + get(hit, '_source.cluster_uuid'); + return { + clusterUuid: get(hit, '_source.cluster_uuid'), + clusterName, + }; + }); +} + +export async function fetchClustersLegacy( + callCluster: any, + index: string, + rangeFilter: RangeFilter = { timestamp: { gte: 'now-2m' } } +): Promise<AlertCluster[]> { + const params = { + index, + filterPath: [ + 'hits.hits._source.cluster_settings.cluster.metadata.display_name', + 'hits.hits._source.cluster_uuid', + 'hits.hits._source.cluster_name', + ], + body: { + size: 1000, + query: { + bool: { + filter: [ + { + term: { + type: 'cluster_stats', + }, + }, + { + range: rangeFilter, + }, + ], + }, + }, + collapse: { + field: 'cluster_uuid', + }, + }, + }; const response = await callCluster('search', params); return get(response, 'hits.hits', []).map((hit: any) => { const clusterName: string = 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 0f66217180133..2ff9ae3854e4a 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,10 +5,12 @@ * 2.0. */ +// 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'; describe('fetchCpuUsageNodeStats', () => { - let callCluster = jest.fn(); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; const clusters = [ { clusterUuid: 'abc123', @@ -21,8 +23,8 @@ describe('fetchCpuUsageNodeStats', () => { const size = 10; it('fetch normal stats', async () => { - callCluster = jest.fn().mockImplementation((...args) => { - return { + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { buckets: [ @@ -56,9 +58,9 @@ describe('fetchCpuUsageNodeStats', () => { ], }, }, - }; - }); - const result = await fetchCpuUsageNodeStats(callCluster, clusters, index, startMs, endMs, size); + }) + ); + const result = await fetchCpuUsageNodeStats(esClient, clusters, index, startMs, endMs, size); expect(result).toEqual([ { clusterUuid: clusters[0].clusterUuid, @@ -74,8 +76,8 @@ describe('fetchCpuUsageNodeStats', () => { }); it('fetch container stats', async () => { - callCluster = jest.fn().mockImplementation((...args) => { - return { + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { buckets: [ @@ -122,9 +124,9 @@ describe('fetchCpuUsageNodeStats', () => { ], }, }, - }; - }); - const result = await fetchCpuUsageNodeStats(callCluster, clusters, index, startMs, endMs, size); + }) + ); + const result = await fetchCpuUsageNodeStats(esClient, clusters, index, startMs, endMs, size); expect(result).toEqual([ { clusterUuid: clusters[0].clusterUuid, @@ -140,8 +142,8 @@ describe('fetchCpuUsageNodeStats', () => { }); it('fetch properly return ccs', async () => { - callCluster = jest.fn().mockImplementation((...args) => { - return { + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { buckets: [ @@ -181,18 +183,19 @@ describe('fetchCpuUsageNodeStats', () => { ], }, }, - }; - }); - const result = await fetchCpuUsageNodeStats(callCluster, clusters, index, startMs, endMs, size); + }) + ); + const result = await fetchCpuUsageNodeStats(esClient, clusters, index, startMs, endMs, size); expect(result[0].ccs).toBe('foo'); }); it('should use consistent params', async () => { let params = null; - callCluster = jest.fn().mockImplementation((...args) => { - params = args[1]; + esClient.search.mockImplementation((...args) => { + params = args[0]; + return elasticsearchClientMock.createSuccessTransportRequestPromise({}); }); - await fetchCpuUsageNodeStats(callCluster, clusters, index, startMs, endMs, size); + await fetchCpuUsageNodeStats(esClient, clusters, index, startMs, endMs, size); expect(params).toStrictEqual({ index: '.monitoring-es-*', filterPath: ['aggregations'], 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 dc8e5fc52eadf..1dfbe381b9956 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import moment from 'moment'; import { NORMALIZED_DERIVATIVE_UNIT } from '../../../common/constants'; @@ -23,7 +24,7 @@ interface ClusterBucketESResponse { } export async function fetchCpuUsageNodeStats( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string, startMs: number, @@ -140,7 +141,7 @@ export async function fetchCpuUsageNodeStats( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const stats: AlertCpuUsageNodeStats[] = []; const clusterBuckets = get( response, 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 56b599f73b939..7664d73f6009b 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 @@ -5,10 +5,14 @@ * 2.0. */ +// 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 { fetchDiskUsageNodeStats } from './fetch_disk_usage_node_stats'; describe('fetchDiskUsageNodeStats', () => { - let callCluster = jest.fn(); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; + const clusters = [ { clusterUuid: 'cluster123', @@ -20,8 +24,8 @@ describe('fetchDiskUsageNodeStats', () => { const size = 10; it('fetch normal stats', async () => { - callCluster = jest.fn().mockImplementation(() => { - return { + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { buckets: [ @@ -55,10 +59,10 @@ describe('fetchDiskUsageNodeStats', () => { ], }, }, - }; - }); + }) + ); - const result = await fetchDiskUsageNodeStats(callCluster, clusters, index, duration, size); + const result = await fetchDiskUsageNodeStats(esClient, clusters, index, duration, size); expect(result).toEqual([ { clusterUuid: clusters[0].clusterUuid, 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 912fab19951df..aea4ede825d67 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 @@ -5,11 +5,12 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import { AlertCluster, AlertDiskUsageNodeStats } from '../../../common/types/alerts'; export async function fetchDiskUsageNodeStats( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string, duration: string, @@ -98,7 +99,7 @@ export async function fetchDiskUsageNodeStats( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const stats: AlertDiskUsageNodeStats[] = []; const { buckets: clusterBuckets = [] } = response.aggregations.clusters; 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 e4f4a4d364ebf..be501ee3d5280 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 @@ -5,10 +5,14 @@ * 2.0. */ +// 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 { fetchElasticsearchVersions } from './fetch_elasticsearch_versions'; describe('fetchElasticsearchVersions', () => { - let callCluster = jest.fn(); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; + const clusters = [ { clusterUuid: 'cluster123', @@ -20,8 +24,8 @@ describe('fetchElasticsearchVersions', () => { const versions = ['8.0.0', '7.2.1']; it('fetch as expected', async () => { - callCluster = jest.fn().mockImplementation(() => { - return { + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { hits: [ { @@ -37,10 +41,10 @@ describe('fetchElasticsearchVersions', () => { }, ], }, - }; - }); + }) + ); - const result = await fetchElasticsearchVersions(callCluster, clusters, index, size); + const result = await fetchElasticsearchVersions(esClient, clusters, index, size); expect(result).toEqual([ { clusterUuid: clusters[0].clusterUuid, 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 373ddb62aaee8..b4b7739f6731b 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 @@ -4,11 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { AlertCluster, AlertVersions } from '../../../common/types/alerts'; import { ElasticsearchSource } from '../../../common/types/es'; export async function fetchElasticsearchVersions( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string, size: number @@ -59,7 +60,7 @@ export async function fetchElasticsearchVersions( }, }; - const response = await callCluster('search', params); + 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; return { 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 63c2910c46e5d..dfba0c42eef3d 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { AlertCluster, IndexShardSizeStats } from '../../../common/types/alerts'; import { ElasticsearchIndexStats, ElasticsearchResponseHit } from '../../../common/types/es'; import { ESGlobPatterns, RegExPatterns } from '../../../common/es_glob_patterns'; @@ -29,7 +30,7 @@ const memoizedIndexPatterns = (globPatterns: string) => { const gbMultiplier = 1000000000; export async function fetchIndexShardSize( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string, threshold: number, @@ -113,7 +114,7 @@ export async function fetchIndexShardSize( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const stats: IndexShardSizeStats[] = []; 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 518828ef0b1c8..901851d766512 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 @@ -6,9 +6,12 @@ */ import { fetchKibanaVersions } from './fetch_kibana_versions'; +// 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'; describe('fetchKibanaVersions', () => { - let callCluster = jest.fn(); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; const clusters = [ { clusterUuid: 'cluster123', @@ -19,8 +22,8 @@ describe('fetchKibanaVersions', () => { const size = 10; it('fetch as expected', async () => { - callCluster = jest.fn().mockImplementation(() => { - return { + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { index: { buckets: [ @@ -59,10 +62,10 @@ describe('fetchKibanaVersions', () => { ], }, }, - }; - }); + }) + ); - const result = await fetchKibanaVersions(callCluster, clusters, index, size); + const result = await fetchKibanaVersions(esClient, clusters, index, size); expect(result).toEqual([ { clusterUuid: clusters[0].clusterUuid, 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 2e7fe192df656..a4e1e606702ec 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 @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import { AlertCluster, AlertVersions } from '../../../common/types/alerts'; @@ -12,7 +13,7 @@ interface ESAggResponse { } export async function fetchKibanaVersions( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string, size: number @@ -88,7 +89,7 @@ export async function fetchKibanaVersions( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const indexName = get(response, 'aggregations.index.buckets[0].key', ''); const clusterList = get(response, 'aggregations.cluster.buckets', []) as ESAggResponse[]; return clusterList.map((cluster) => { 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 715c8c50a45e7..69a42812bfe88 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 @@ -5,6 +5,9 @@ * 2.0. */ 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'; describe('fetchLicenses', () => { const clusterName = 'MyCluster'; @@ -16,21 +19,24 @@ describe('fetchLicenses', () => { }; it('return a list of licenses', async () => { - const callCluster = jest.fn().mockImplementation(() => ({ - hits: { - hits: [ - { - _source: { - license, - cluster_uuid: clusterUuid, + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + hits: { + hits: [ + { + _source: { + license, + cluster_uuid: clusterUuid, + }, }, - }, - ], - }, - })); + ], + }, + }) + ); const clusters = [{ clusterUuid, clusterName }]; const index = '.monitoring-es-*'; - const result = await fetchLicenses(callCluster, clusters, index); + const result = await fetchLicenses(esClient, clusters, index); expect(result).toEqual([ { status: license.status, @@ -42,20 +48,20 @@ describe('fetchLicenses', () => { }); it('should only search for the clusters provided', async () => { - const callCluster = jest.fn(); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; const clusters = [{ clusterUuid, clusterName }]; const index = '.monitoring-es-*'; - await fetchLicenses(callCluster, clusters, index); - const params = callCluster.mock.calls[0][1]; - expect(params.body.query.bool.filter[0].terms.cluster_uuid).toEqual([clusterUuid]); + await fetchLicenses(esClient, clusters, index); + const params = esClient.search.mock.calls[0][0] as any; + expect(params?.body?.query.bool.filter[0].terms.cluster_uuid).toEqual([clusterUuid]); }); it('should limit the time period in the query', async () => { - const callCluster = jest.fn(); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; const clusters = [{ clusterUuid, clusterName }]; const index = '.monitoring-es-*'; - await fetchLicenses(callCluster, clusters, index); - const params = callCluster.mock.calls[0][1]; - expect(params.body.query.bool.filter[2].range.timestamp.gte).toBe('now-2m'); + await fetchLicenses(esClient, clusters, index); + const params = esClient.search.mock.calls[0][0] as any; + expect(params?.body?.query.bool.filter[2].range.timestamp.gte).toBe('now-2m'); }); }); 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 6cec7f3296926..5cd4378f0a747 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts @@ -4,11 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { AlertLicense, AlertCluster } from '../../../common/types/alerts'; import { ElasticsearchResponse } from '../../../common/types/es'; export async function fetchLicenses( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string ): Promise<AlertLicense[]> { @@ -58,7 +59,7 @@ export async function fetchLicenses( }, }; - const response: ElasticsearchResponse = await callCluster('search', params); + const { body: response } = await esClient.search<ElasticsearchResponse>(params); return ( response?.hits?.hits.map((hit) => { const rawLicense = hit._source.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 a739593df27e9..e35de6e68866d 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 @@ -6,9 +6,12 @@ */ import { fetchLogstashVersions } from './fetch_logstash_versions'; +// 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'; describe('fetchLogstashVersions', () => { - let callCluster = jest.fn(); + const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; const clusters = [ { clusterUuid: 'cluster123', @@ -19,8 +22,8 @@ describe('fetchLogstashVersions', () => { const size = 10; it('fetch as expected', async () => { - callCluster = jest.fn().mockImplementation(() => { - return { + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { index: { buckets: [ @@ -59,10 +62,10 @@ describe('fetchLogstashVersions', () => { ], }, }, - }; - }); + }) + ); - const result = await fetchLogstashVersions(callCluster, clusters, index, size); + const result = await fetchLogstashVersions(esClient, clusters, index, size); expect(result).toEqual([ { clusterUuid: clusters[0].clusterUuid, 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 8f20c64d6243e..6090ba36d9749 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 @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import { AlertCluster, AlertVersions } from '../../../common/types/alerts'; @@ -12,7 +13,7 @@ interface ESAggResponse { } export async function fetchLogstashVersions( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string, size: number @@ -88,7 +89,7 @@ export async function fetchLogstashVersions( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const indexName = get(response, 'aggregations.index.buckets[0].key', ''); const clusterList = get(response, 'aggregations.cluster.buckets', []) as ESAggResponse[]; return clusterList.map((cluster) => { 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 2b2af9572390e..77c17a8ebf3ef 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 @@ -5,11 +5,12 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import { AlertCluster, AlertMemoryUsageNodeStats } from '../../../common/types/alerts'; export async function fetchMemoryUsageNodeStats( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string, startMs: number, @@ -91,7 +92,7 @@ export async function fetchMemoryUsageNodeStats( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const stats: AlertMemoryUsageNodeStats[] = []; const { buckets: clusterBuckets = [] } = response.aggregations.clusters; 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 4f907aa628c43..2388abf024eb9 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 @@ -5,6 +5,8 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from '../../../../../../src/core/server/elasticsearch/client/mocks'; import { fetchMissingMonitoringData } from './fetch_missing_monitoring_data'; function getResponse( @@ -38,7 +40,8 @@ function getResponse( } describe('fetchMissingMonitoringData', () => { - let callCluster = jest.fn(); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + const index = '.monitoring-*'; const startMs = 100; const size = 10; @@ -51,8 +54,9 @@ describe('fetchMissingMonitoringData', () => { clusterName: 'clusterName1', }, ]; - callCluster = jest.fn().mockImplementation((...args) => { - return { + + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { buckets: clusters.map((cluster) => ({ @@ -80,16 +84,9 @@ describe('fetchMissingMonitoringData', () => { })), }, }, - }; - }); - const result = await fetchMissingMonitoringData( - callCluster, - clusters, - index, - size, - now, - startMs + }) ); + const result = await fetchMissingMonitoringData(esClient, clusters, index, size, now, startMs); expect(result).toEqual([ { nodeId: 'nodeUuid1', @@ -116,8 +113,8 @@ describe('fetchMissingMonitoringData', () => { clusterName: 'clusterName1', }, ]; - callCluster = jest.fn().mockImplementation((...args) => { - return { + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { buckets: clusters.map((cluster) => ({ @@ -136,16 +133,9 @@ describe('fetchMissingMonitoringData', () => { })), }, }, - }; - }); - const result = await fetchMissingMonitoringData( - callCluster, - clusters, - index, - size, - now, - startMs + }) ); + const result = await fetchMissingMonitoringData(esClient, clusters, index, size, now, startMs); expect(result).toEqual([ { nodeId: 'nodeUuid1', 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 fa5f9c6620cf5..cb274848e6c5a 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import { AlertCluster, AlertMissingData } from '../../../common/types/alerts'; @@ -41,7 +42,7 @@ interface TopHitESResponse { // TODO: only Elasticsearch until we can figure out how to handle upgrades for the rest of the stack // https://github.com/elastic/kibana/issues/83309 export async function fetchMissingMonitoringData( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string, size: number, @@ -116,7 +117,7 @@ export async function fetchMissingMonitoringData( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const clusterBuckets = get( response, 'aggregations.clusters.buckets', 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 c399594c170fa..a97594c8ca995 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 @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { AlertCluster, AlertClusterStatsNodes } from '../../../common/types/alerts'; import { ElasticsearchSource } from '../../../common/types/es'; @@ -23,7 +24,7 @@ function formatNode( } export async function fetchNodesFromClusterStats( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string ): Promise<AlertClusterStatsNodes[]> { @@ -87,7 +88,7 @@ export async function fetchNodesFromClusterStats( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const nodes = []; const clusterBuckets = response.aggregations.clusters.buckets; for (const clusterBucket of clusterBuckets) { 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 80624b6d5233c..5770721195e14 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { ElasticsearchClient } from 'kibana/server'; import { get } from 'lodash'; import { AlertCluster, AlertThreadPoolRejectionsStats } from '../../../common/types/alerts'; @@ -30,7 +31,7 @@ const getTopHits = (threadType: string, order: string) => ({ }); export async function fetchThreadPoolRejectionStats( - callCluster: any, + esClient: ElasticsearchClient, clusters: AlertCluster[], index: string, size: number, @@ -93,7 +94,7 @@ export async function fetchThreadPoolRejectionStats( }, }; - const response = await callCluster('search', params); + const { body: response } = await esClient.search(params); const stats: AlertThreadPoolRejectionsStats[] = []; const { buckets: clusterBuckets = [] } = response.aggregations.clusters; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts index facb6e29236e3..f5f9c80e0e4d3 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts @@ -34,13 +34,12 @@ export class AlertingSecurity { enabled: isSecurityEnabled = false, ssl: { http: { enabled: isTLSEnabled = false } = {} } = {}, } = {}, - }: XPackUsageSecurity = await context.core.elasticsearch.legacy.client.callAsInternalUser( - 'transport.request', - { + } = ( + await context.core.elasticsearch.client.asInternalUser.transport.request({ method: 'GET', path: '/_xpack/usage', - } - ); + }) + ).body as XPackUsageSecurity; return { isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts index 1723fe46e50f7..ba83584e34654 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts @@ -77,7 +77,7 @@ export interface BeatsStats { queue?: { name?: string; }; - heartbeat?: HeartbeatBase; + heartbeat?: Heartbeat; functionbeat?: { functions?: { count?: number; @@ -91,11 +91,11 @@ export interface BeatsStats { }; } +type Heartbeat = HeartbeatBase & { [key: string]: HeartbeatBase | undefined }; + interface HeartbeatBase { monitors: number; endpoints: number; - // I have to add the '| number' bit because otherwise TS complains about 'monitors' and 'endpoints' not being of type HeartbeatBase - [key: string]: HeartbeatBase | number | undefined; } export interface BeatsBaseStats { @@ -122,7 +122,7 @@ export interface BeatsBaseStats { count: number; architectures: BeatsArchitecture[]; }; - heartbeat?: HeartbeatBase; + heartbeat?: Heartbeat; functionbeat?: { functions: { count: number; @@ -237,7 +237,7 @@ export function processResults( clusters[clusterUuid].heartbeat = { monitors: 0, endpoints: 0, - }; + } as Heartbeat; // Needed because TS complains about the additional index signature } const clusterHb = clusters[clusterUuid].heartbeat!; diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts index 158b869b9264f..23c6139389917 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_telemetry_collection.ts @@ -5,16 +5,28 @@ * 2.0. */ -import { ILegacyClusterClient } from 'kibana/server'; -import { UsageStatsPayload } from 'src/plugins/telemetry_collection_manager/server'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import type { ILegacyClusterClient } from 'kibana/server'; +import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import type { UsageStatsPayload } from '../../../../../src/plugins/telemetry_collection_manager/server'; +import type { LogstashBaseStats } from './get_logstash_stats'; +import type { BeatsBaseStats } from './get_beats_stats'; import { getAllStats } from './get_all_stats'; import { getClusterUuids } from './get_cluster_uuids'; import { getLicenses } from './get_licenses'; -// TODO: To be removed in https://github.com/elastic/kibana/pull/83546 -interface MonitoringCollectorOptions { - ignoreForInternalUploader: boolean; // Allow the additional property required by bulk_uploader to be filtered out +interface MonitoringStats extends UsageStatsPayload { + stack_stats: { + logstash?: LogstashBaseStats; + beats?: BeatsBaseStats; + // Intentionally not declaring "kibana" to avoid repetition with "local" telemetry, + // and since it should only report it for old versions reporting "too much" monitoring data + // [KIBANA_SYSTEM_ID]?: KibanaClusterStat; + }; +} + +// We need to nest it under a property because fetch must return an object (the schema mandates that) +interface MonitoringTelemetryUsage { + stats: MonitoringStats[]; } export function registerMonitoringTelemetryCollection( @@ -23,14 +35,108 @@ export function registerMonitoringTelemetryCollection( maxBucketSize: number ) { const monitoringStatsCollector = usageCollection.makeStatsCollector< - UsageStatsPayload[], - true, - MonitoringCollectorOptions + MonitoringTelemetryUsage, + true >({ type: 'monitoringTelemetry', isReady: () => true, - ignoreForInternalUploader: true, // Used only by monitoring's bulk_uploader to filter out unwanted collectors extendFetchContext: { kibanaRequest: true }, + schema: { + stats: { + type: 'array', + items: { + timestamp: { type: 'date' }, + cluster_uuid: { type: 'keyword' }, + cluster_name: { type: 'keyword' }, + version: { type: 'keyword' }, + cluster_stats: {}, + stack_stats: { + logstash: { + versions: { + type: 'array', + items: { + version: { type: 'keyword' }, + count: { type: 'long' }, + }, + }, + count: { type: 'long' }, + cluster_stats: { + collection_types: { + DYNAMIC_KEY: { type: 'long' }, + }, + queues: { + DYNAMIC_KEY: { type: 'long' }, + }, + plugins: { + type: 'array', + items: { + name: { type: 'keyword' }, + count: { type: 'long' }, + }, + }, + pipelines: { + count: { type: 'long' }, + batch_size_max: { type: 'long' }, + batch_size_avg: { type: 'long' }, + batch_size_min: { type: 'long' }, + batch_size_total: { type: 'long' }, + workers_max: { type: 'long' }, + workers_avg: { type: 'long' }, + workers_min: { type: 'long' }, + workers_total: { type: 'long' }, + sources: { + DYNAMIC_KEY: { type: 'boolean' }, + }, + }, + }, + }, + beats: { + versions: { DYNAMIC_KEY: { type: 'long' } }, + types: { DYNAMIC_KEY: { type: 'long' } }, + outputs: { DYNAMIC_KEY: { type: 'long' } }, + queue: { DYNAMIC_KEY: { type: 'long' } }, + count: { type: 'long' }, + eventsPublished: { type: 'long' }, + hosts: { type: 'long' }, + input: { + count: { type: 'long' }, + names: { type: 'array', items: { type: 'keyword' } }, + }, + module: { + count: { type: 'long' }, + names: { type: 'array', items: { type: 'keyword' } }, + }, + architecture: { + count: { type: 'long' }, + architectures: { + type: 'array', + items: { + name: { type: 'keyword' }, + architecture: { type: 'keyword' }, + count: { type: 'long' }, + }, + }, + }, + heartbeat: { + monitors: { type: 'long' }, + endpoints: { type: 'long' }, + DYNAMIC_KEY: { + monitors: { type: 'long' }, + endpoints: { type: 'long' }, + }, + }, + functionbeat: { + functions: { + count: { type: 'long' }, + }, + }, + }, + }, + collection: { type: 'keyword' }, + collectionSource: { type: 'keyword' }, + }, + }, + }, fetch: async ({ kibanaRequest }) => { const timestamp = Date.now(); // Collect the telemetry from the monitoring indices for this moment. // NOTE: Usually, the monitoring indices index stats for each product every 10s (by default). @@ -45,14 +151,16 @@ export function registerMonitoringTelemetryCollection( getLicenses(clusterDetails, callCluster, maxBucketSize), getAllStats(clusterDetails, callCluster, timestamp, maxBucketSize), ]); - return stats.map((stat) => { - const license = licenses[stat.cluster_uuid]; - return { - ...(license ? { license } : {}), - ...stat, - collectionSource: 'monitoring', - }; - }); + return { + stats: stats.map((stat) => { + const license = licenses[stat.cluster_uuid]; + return { + ...(license ? { license } : {}), + ...stat, + collectionSource: 'monitoring', + }; + }), + }; }, }); usageCollection.registerCollector(monitoringStatsCollector); diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index e3f58dd20cecb..2a95557473fc0 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -50,6 +50,7 @@ export const KBN_SCREENSHOT_HEADER_BLOCK_LIST_STARTS_WITH_PATTERN = ['proxy-']; export const UI_SETTINGS_CUSTOM_PDF_LOGO = 'xpackReporting:customPdfLogo'; export const UI_SETTINGS_CSV_SEPARATOR = 'csv:separator'; export const UI_SETTINGS_CSV_QUOTE_VALUES = 'csv:quoteValues'; +export const UI_SETTINGS_DATEFORMAT_TZ = 'dateFormat:tz'; export const LAYOUT_TYPES = { PRESERVE_LAYOUT: 'preserve_layout', @@ -57,13 +58,16 @@ export const LAYOUT_TYPES = { }; // Export Type Definitions +export const CSV_REPORT_TYPE = 'CSV'; +export const CSV_JOB_TYPE = 'csv_searchsource'; + export const PDF_REPORT_TYPE = 'printablePdf'; export const PDF_JOB_TYPE = 'printable_pdf'; export const PNG_REPORT_TYPE = 'PNG'; export const PNG_JOB_TYPE = 'PNG'; -export const CSV_FROM_SAVEDOBJECT_JOB_TYPE = 'csv_from_savedobject'; +export const CSV_SEARCHSOURCE_IMMEDIATE_TYPE = 'csv_searchsource_immediate'; // This is deprecated because it lacks support for runtime fields // but the extension points are still needed for pre-existing scripted automation, until 8.0 @@ -86,9 +90,9 @@ export const API_BASE_GENERATE = `${API_BASE_URL}/generate`; export const API_LIST_URL = `${API_BASE_URL}/jobs`; export const API_DIAGNOSE_URL = `${API_BASE_URL}/diagnose`; -// hacky endpoint +// hacky endpoint: download CSV without queueing a report export const API_BASE_URL_V1 = '/api/reporting/v1'; // -export const API_GENERATE_IMMEDIATE = `${API_BASE_URL_V1}/generate/immediate/csv/saved-object`; +export const API_GENERATE_IMMEDIATE = `${API_BASE_URL_V1}/generate/immediate/csv_searchsource`; // Management UI route export const REPORTING_MANAGEMENT_HOME = '/app/management/insightsAndAlerting/reporting'; diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 3af329cbf0303..5e20381e35898 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -47,9 +47,10 @@ export interface ReportDocumentHead { export interface TaskRunResult { content_type: string | null; content: string | null; - csv_contains_formulas?: boolean; size: number; + csv_contains_formulas?: boolean; max_size_reached?: boolean; + needs_sorting?: boolean; warnings?: string[]; } diff --git a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx index 6f6cf2dc9351b..399b503fe48d3 100644 --- a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx @@ -11,11 +11,7 @@ import React, { Component, ReactElement } from 'react'; import { ToastsSetup } from 'src/core/public'; import url from 'url'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { - CSV_REPORT_TYPE_DEPRECATED, - PDF_REPORT_TYPE, - PNG_REPORT_TYPE, -} from '../../common/constants'; +import { CSV_REPORT_TYPE, PDF_REPORT_TYPE, PNG_REPORT_TYPE } from '../../common/constants'; import { BaseParams } from '../../common/types'; import { ReportingAPIClient } from '../lib/reporting_api_client'; @@ -177,8 +173,8 @@ class ReportingPanelContentUi extends Component<Props, State> { switch (this.props.reportType) { case PDF_REPORT_TYPE: return 'PDF'; - case 'csv': - return CSV_REPORT_TYPE_DEPRECATED; + case 'csv_searchsource': + return CSV_REPORT_TYPE; case 'png': return PNG_REPORT_TYPE; default: diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts index f452719e91713..4e1b9ccd2642f 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts @@ -52,7 +52,20 @@ describe('GetCsvReportPanelAction', () => { context = { embeddable: { type: 'search', - getSavedSearch: () => ({ id: 'lebowski' }), + getSavedSearch: () => { + const searchSource = { + createCopy: () => searchSource, + removeField: jest.fn(), + setField: jest.fn(), + getField: jest.fn().mockImplementation((key: string) => { + if (key === 'index') { + return 'my-test-index-*'; + } + }), + getSerializedFields: jest.fn().mockImplementation(() => ({})), + }; + return { searchSource }; + }, getTitle: () => `The Dude`, getInspectorAdapters: () => null, getInput: () => ({ diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index cc1da146eff32..d440edc3f3fe9 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import dateMath from '@elastic/datemath'; import { i18n } from '@kbn/i18n'; -import _ from 'lodash'; import moment from 'moment-timezone'; import { CoreSetup } from 'src/core/public'; import { + loadSharingDataHelpers, ISearchEmbeddable, + SavedSearch, SEARCH_EMBEDDABLE_TYPE, } from '../../../../../src/plugins/discover/public'; import { IEmbeddable, ViewMode } from '../../../../../src/plugins/embeddable/public'; @@ -21,6 +21,7 @@ import { } from '../../../../../src/plugins/ui_actions/public'; import { LicensingPluginSetup } from '../../../licensing/public'; import { API_GENERATE_IMMEDIATE, CSV_REPORTING_ACTION } from '../../common/constants'; +import { JobParamsDownloadCSV } from '../../server/export_types/csv_searchsource_immediate/types'; import { checkLicense } from '../lib/license_check'; function isSavedSearchEmbeddable( @@ -61,17 +62,16 @@ export class GetCsvReportPanelAction implements ActionDefinition<ActionContext> }); } - public getSearchRequestBody({ searchEmbeddable }: { searchEmbeddable: any }) { - const adapters = searchEmbeddable.getInspectorAdapters(); - if (!adapters) { - return {}; - } - - if (adapters.requests.requests.length === 0) { - return {}; - } + public async getSearchSource(savedSearch: SavedSearch, embeddable: ISearchEmbeddable) { + const { getSharingData } = await loadSharingDataHelpers(); + const searchSource = savedSearch.searchSource.createCopy(); + const { searchSource: serializedSearchSource } = await getSharingData( + searchSource, + savedSearch, // TODO: get unsaved state (using embeddale.searchScope): https://github.com/elastic/kibana/issues/43977 + this.core.uiSettings + ); - return searchEmbeddable.getSavedSearch().searchSource.getSearchRequestBody(); + return serializedSearchSource; } public isCompatible = async (context: ActionContext) => { @@ -95,34 +95,18 @@ export class GetCsvReportPanelAction implements ActionDefinition<ActionContext> return; } - const { - timeRange: { to, from }, - } = embeddable.getInput(); + const savedSearch = embeddable.getSavedSearch(); + const searchSource = await this.getSearchSource(savedSearch, embeddable); - const searchEmbeddable = embeddable; - const searchRequestBody = await this.getSearchRequestBody({ searchEmbeddable }); - const state = _.pick(searchRequestBody, ['sort', 'docvalue_fields', 'query']); const kibanaTimezone = this.core.uiSettings.get('dateFormat:tz'); + const browserTimezone = kibanaTimezone === 'Browser' ? moment.tz.guess() : kibanaTimezone; + const immediateJobParams: JobParamsDownloadCSV = { + searchSource, + browserTimezone, + title: savedSearch.title, + }; - const id = `search:${embeddable.getSavedSearch().id}`; - const timezone = kibanaTimezone === 'Browser' ? moment.tz.guess() : kibanaTimezone; - const fromTime = dateMath.parse(from); - const toTime = dateMath.parse(to, { roundUp: true }); - - if (!fromTime || !toTime) { - return this.onGenerationFail( - new Error(`Invalid time range: From: ${fromTime}, To: ${toTime}`) - ); - } - - const body = JSON.stringify({ - timerange: { - min: fromTime.format(), - max: toTime.format(), - timezone, - }, - state, - }); + const body = JSON.stringify(immediateJobParams); this.isDownloading = true; @@ -137,11 +121,11 @@ export class GetCsvReportPanelAction implements ActionDefinition<ActionContext> }); await this.core.http - .post(`${API_GENERATE_IMMEDIATE}/${id}`, { body }) + .post(`${API_GENERATE_IMMEDIATE}`, { body }) .then((rawResponse: string) => { this.isDownloading = false; - const download = `${embeddable.getSavedSearch().title}.csv`; + const download = `${savedSearch.title}.csv`; const blob = new Blob([rawResponse], { type: 'text/csv;charset=utf-8;' }); // Hack for IE11 Support diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index 31c86ae4c5669..97433f7a4f0c1 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -11,10 +11,8 @@ import React from 'react'; import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; import { ShareContext } from '../../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../../licensing/public'; -import { - JobParamsDeprecatedCSV, - SearchRequestDeprecatedCSV, -} from '../../server/export_types/csv/types'; +import { CSV_JOB_TYPE } from '../../common/constants'; +import { JobParamsCSV } from '../../server/export_types/csv_searchsource/types'; import { ReportingPanelContent } from '../components/reporting_panel_content_lazy'; import { checkLicense } from '../lib/license_check'; import { ReportingAPIClient } from '../lib/reporting_api_client'; @@ -56,22 +54,18 @@ export const csvReportingProvider = ({ objectType, objectId, sharingData, - isDirty, onClose, + isDirty, }: ShareContext) => { if ('search' !== objectType) { return []; } - const jobParams: JobParamsDeprecatedCSV = { + const jobParams: JobParamsCSV = { browserTimezone, - objectType, title: sharingData.title as string, - indexPatternId: sharingData.indexPatternId as string, - searchRequest: sharingData.searchRequest as SearchRequestDeprecatedCSV, - fields: sharingData.fields as string[], - metaFields: sharingData.metaFields as string[], - conflictedTypesFields: sharingData.conflictedTypesFields as string[], + objectType, + searchSource: sharingData.searchSource, }; const getJobParams = () => jobParams; @@ -99,7 +93,7 @@ export const csvReportingProvider = ({ <ReportingPanelContent apiClient={apiClient} toasts={toasts} - reportType="csv" + reportType={CSV_JOB_TYPE} layoutId={undefined} objectId={objectId} getJobParams={getJobParams} diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts index f907d637462e6..06975aa85f1e4 100644 --- a/x-pack/plugins/reporting/server/config/index.ts +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -5,10 +5,12 @@ * 2.0. */ -import { get } from 'lodash'; import { PluginConfigDescriptor } from 'kibana/server'; +import { get } from 'lodash'; + import { ConfigSchema, ReportingConfigType } from './schema'; export { buildConfig } from './config'; +export { registerUiSettings } from './ui_settings'; export { ConfigSchema, ReportingConfigType }; export const config: PluginConfigDescriptor<ReportingConfigType> = { diff --git a/x-pack/plugins/reporting/server/config/ui_settings.test.ts b/x-pack/plugins/reporting/server/config/ui_settings.test.ts new file mode 100644 index 0000000000000..dcd12e4c05f3f --- /dev/null +++ b/x-pack/plugins/reporting/server/config/ui_settings.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { range } from 'lodash'; +import { PdfLogoSchema } from './ui_settings'; + +test('validates when provided with image data', () => { + const jpgString = + `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoHCBcUFBUUExUYGRUaGRsZGxsZHB8bIh0iGhgbGxkbGx8dIy0kGx0rIiIbJTcoKi8xNDU0ISY6Pzo2` + + `+8snFz9eWgvYKS4ZsvS05zRQsDveIzH4Er4iDtr6iICIiAiIgIiICIiD//2Q==`; + expect(PdfLogoSchema.validate(jpgString)).toBe(jpgString); + + const pngString = + `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAO4AAADUCAMAAACs0e/bAAAAjVBMVEX////8/Pz4+Pj5+fnb29vz8/Px8fFeXl7r6+u/v79nZ` + + `tcAAAAASUVORK5CYII=`; + expect(PdfLogoSchema.validate(pngString)).toBe(pngString); + + const gifString = + `data:image/gif;base64,R0lGODlhoADIAPYAAO/w7wgFBwsLCxMTExsbGyMjI5SUlLS0tLu7u9vb2+Hh4e/v7/Ds7////0NDQ2RkZCkXJO/w8PLy8g8QD` + + `53IIefTH3WR4N8lXzvKWu/zlMI+5zGdO85rb/OY4z7nOd87znvv850APutCHTvSiG/3oSE+60pfO9KY7/elQj7rU5xIIADs=`; + expect(PdfLogoSchema.validate(gifString)).toBe(gifString); + + const svgString = + `data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ3JlYXRlZCB3aXR` + + `AgPC9nPgogIDwvZz4KPC9zdmc+Cg==`; + expect(PdfLogoSchema.validate(svgString)).toBe(svgString); +}); + +test('validates if provided with null / undefined value', () => { + expect(() => PdfLogoSchema.validate(undefined)).not.toThrow(); + expect(() => PdfLogoSchema.validate(null)).not.toThrow(); +}); + +test('throws validation error if provided with data over max size', () => { + const largeJpgMock = + `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoHCBcUFBUUExUYGRUaGRsZGxsZHB8bIh0iGhgbGxkbGx8dIy0kGx0rIiIbJTcoKi8xNDU0ISY6Pzo2` + + range(0, 2050) + .map( + () => + `Pi0zNDMBCwsLBgYGEAYGEDEcFRwxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMf/AABEIAOgA2gMBIgACEQEDEQH/xAAcAAEAAgMBAQE` + ) + .join('') + + `+8snFz9eWgvYKS4ZsvS05zRQsDveIzH4Er4iDtr6iICIiAiIgIiICIiD//2Q==`; + expect(() => PdfLogoSchema.validate(largeJpgMock)).toThrowError(/too large/); +}); + +test('throws validation error if provided with non-image data', () => { + const invalidErrorMatcher = /try a different image/; + + expect(() => PdfLogoSchema.validate('')).toThrowError(invalidErrorMatcher); + expect(() => PdfLogoSchema.validate(true)).toThrow(invalidErrorMatcher); + expect(() => PdfLogoSchema.validate(false)).toThrow(invalidErrorMatcher); + expect(() => PdfLogoSchema.validate({})).toThrow(invalidErrorMatcher); + expect(() => PdfLogoSchema.validate([])).toThrow(invalidErrorMatcher); + expect(() => PdfLogoSchema.validate(0)).toThrow(invalidErrorMatcher); + expect(() => PdfLogoSchema.validate(0x00f)).toThrow(invalidErrorMatcher); + + const csvString = + `data:text/csv;base64,Il9pZCIsIl9pbmRleCIsIl9zY29yZSIsIl90eXBlIiwiZm9vLmJhciIsImZvby5iYXIua2V5d29yZCIKZjY1QU9IZ0J5bFZmWW04W` + + `TRvb1EsYmVlLDEsIi0iLGJheixiYXoKbks1QU9IZ0J5bFZmWW04WTdZcUcsYmVlLDEsIi0iLGJvbyxib28K`; + expect(() => PdfLogoSchema.validate(csvString)).toThrow(invalidErrorMatcher); + + const scriptString = + `data:application/octet-stream;base64,QEVDSE8gT0ZGCldFRUtPRllSLkNPTSB8IEZJTkQgIlRoaXMgaXMiID4gVEVNUC5CQV` + + `QKRUNITz5USElTLkJBVCBTRVQgV0VFSz0lJTMKQ0FMTCBURU1QLkJBVApERUwgIFRFTVAuQkFUCkRFTCAgVEhJUy5CQVQKRUNITyBXZWVrICVXRUVLJQo=`; + expect(() => PdfLogoSchema.validate(scriptString)).toThrow(invalidErrorMatcher); +}); diff --git a/x-pack/plugins/reporting/server/config/ui_settings.ts b/x-pack/plugins/reporting/server/config/ui_settings.ts new file mode 100644 index 0000000000000..337dbf4036b44 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/ui_settings.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import { CoreSetup, UiSettingsParams } from 'kibana/server'; +import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../common/constants'; + +const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); +const maxLogoSizeInKilobytes = kbToBase64Length(200); + +// inspired by x-pack/plugins/canvas/common/lib/dataurl.ts +const dataurlRegex = /^data:([a-z]+\/[a-z0-9-+.]+)(;[a-z-]+=[a-z0-9-]+)?(;([a-z0-9]+))?,/; +const imageTypes = ['image/svg+xml', 'image/jpeg', 'image/png', 'image/gif']; + +const isImageData = (str: any): boolean => { + const matches = str.match(dataurlRegex); + + if (!matches) { + return false; + } + + const [, mimetype, , , encoding] = matches; + const imageTypeIndex = imageTypes.indexOf(mimetype); + if (imageTypeIndex < 0 || encoding !== 'base64') { + return false; + } + + return true; +}; + +const isLessThanMaxSize = (str: any) => { + if (str.length > maxLogoSizeInKilobytes) { + return false; + } + + return true; +}; + +const validatePdfLogoBase64String = (str: any) => { + if (typeof str !== 'string' || !isImageData(str)) { + return i18n.translate('xpack.reporting.uiSettings.validate.customLogo.badFile', { + defaultMessage: `Sorry, that file will not work. Please try a different image file.`, + }); + } + if (!isLessThanMaxSize(str)) { + return i18n.translate('xpack.reporting.uiSettings.validate.customLogo.tooLarge', { + defaultMessage: `Sorry, that file is too large. The image file must be less than 200 kilobytes.`, + }); + } +}; + +export const PdfLogoSchema = schema.nullable(schema.any({ validate: validatePdfLogoBase64String })); + +export function registerUiSettings(core: CoreSetup<object, unknown>) { + core.uiSettings.register({ + [UI_SETTINGS_CUSTOM_PDF_LOGO]: { + name: i18n.translate('xpack.reporting.pdfFooterImageLabel', { + defaultMessage: 'PDF footer image', + }), + value: null, + description: i18n.translate('xpack.reporting.pdfFooterImageDescription', { + defaultMessage: `Custom image to use in the PDF's footer`, + }), + sensitive: true, + type: 'image', + schema: PdfLogoSchema, + category: [PLUGIN_ID], + validation: { + maxSize: { + length: maxLogoSizeInKilobytes, + description: '200 kB', + }, + }, + }, + } as Record<string, UiSettingsParams<null>>); +} diff --git a/x-pack/plugins/reporting/server/core.ts b/x-pack/plugins/reporting/server/core.ts index 4527547ef79b2..b0f0a8c8c7ece 100644 --- a/x-pack/plugins/reporting/server/core.ts +++ b/x-pack/plugins/reporting/server/core.ts @@ -11,6 +11,7 @@ import { first, map, take } from 'rxjs/operators'; import { BasePath, ElasticsearchServiceSetup, + IClusterClient, KibanaRequest, PluginInitializerContext, SavedObjectsClientContract, @@ -31,6 +32,7 @@ import { screenshotsObservableFactory, ScreenshotsObservableFn } from './lib/scr import { ReportingStore } from './lib/store'; import { ExecuteReportTask, MonitorReportsTask, ReportTaskParams } from './lib/tasks'; import { ReportingPluginRouter } from './types'; +import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server'; export interface ReportingInternalSetup { basePath: Pick<BasePath, 'set'>; @@ -48,6 +50,8 @@ export interface ReportingInternalStart { store: ReportingStore; savedObjects: SavedObjectsServiceStart; uiSettings: UiSettingsServiceStart; + esClient: IClusterClient; + data: DataPluginStart; taskManager: TaskManagerStartContract; } @@ -208,6 +212,7 @@ export class ReportingCore { return this.pluginSetupDeps; } + // NOTE: Uses the Legacy API public getElasticsearchService() { return this.getPluginSetupDeps().elasticsearch; } @@ -267,6 +272,16 @@ export class ReportingCore { return await this.getUiSettingsServiceFactory(savedObjectsClient); } + public async getDataService() { + const startDeps = await this.getPluginStartDeps(); + return startDeps.data; + } + + public async getEsClient() { + const startDeps = await this.getPluginStartDeps(); + return startDeps.esClient; + } + public trackReport(reportId: string) { this.executing.add(reportId); } diff --git a/x-pack/plugins/reporting/server/export_types/common/index.ts b/x-pack/plugins/reporting/server/export_types/common/index.ts index 1003ecf83601c..8832577281bb2 100644 --- a/x-pack/plugins/reporting/server/export_types/common/index.ts +++ b/x-pack/plugins/reporting/server/export_types/common/index.ts @@ -12,7 +12,6 @@ export { omitBlockedHeaders } from './omit_blocked_headers'; export { validateUrls } from './validate_urls'; export interface TimeRangeParams { - timezone: string; min?: Date | string | number | null; max?: Date | string | number | null; } diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/check_cells_for_formulas.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/check_cells_for_formulas.ts index 942739f0d9945..f650bbaed1271 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/check_cells_for_formulas.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/check_cells_for_formulas.ts @@ -6,7 +6,7 @@ */ import { pick, keys, values, some } from 'lodash'; -import { cellHasFormulas } from './cell_has_formula'; +import { cellHasFormulas } from '../../csv_searchsource/generate_csv/cell_has_formula'; interface IFlattened { [header: string]: string; diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts index ed05180501e32..629a81df350be 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts @@ -13,15 +13,18 @@ import { CSV_BOM_CHARS } from '../../../../common/constants'; import { byteSizeValueToNumber } from '../../../../common/schema_utils'; import { LevelLogger } from '../../../lib'; import { getFieldFormats } from '../../../services'; -import { IndexPatternSavedObjectDeprecatedCSV, SavedSearchGeneratorResult } from '../types'; +import { createEscapeValue } from '../../csv_searchsource/generate_csv/escape_value'; +import { MaxSizeStringBuilder } from '../../csv_searchsource/generate_csv/max_size_string_builder'; +import { + IndexPatternSavedObjectDeprecatedCSV, + SavedSearchGeneratorResultDeprecatedCSV, +} from '../types'; import { checkIfRowsHaveFormulas } from './check_cells_for_formulas'; -import { createEscapeValue } from './escape_value'; import { fieldFormatMapFactory } from './field_format_map'; import { createFlattenHit } from './flatten_hit'; import { createFormatCsvValues } from './format_csv_values'; import { getUiSettings } from './get_ui_settings'; import { createHitIterator, EndpointCaller } from './hit_iterator'; -import { MaxSizeStringBuilder } from './max_size_string_builder'; interface SearchRequest { index: string; @@ -55,7 +58,7 @@ export function createGenerateCsv(logger: LevelLogger) { uiSettingsClient: IUiSettingsClient, callEndpoint: EndpointCaller, cancellationToken: CancellationToken - ): Promise<SavedSearchGeneratorResult> { + ): Promise<SavedSearchGeneratorResultDeprecatedCSV> { const settings = await getUiSettings(job.browserTimezone, uiSettingsClient, config, logger); const escapeValue = createEscapeValue(settings.quoteValues, settings.escapeFormulaValues); const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : ''; diff --git a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts index 4c4f33d0ee9f7..604d451d822b6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts @@ -77,15 +77,10 @@ type FormatsMapDeprecatedCSV = Map< } >; -export interface SavedSearchGeneratorResult { +export interface SavedSearchGeneratorResultDeprecatedCSV { content: string; size: number; maxSizeReached: boolean; csvContainsFormulas?: boolean; warnings: string[]; } - -export interface CsvResultFromSearch { - type: string; - result: SavedSearchGeneratorResult; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts deleted file mode 100644 index 75b07e5bca8c8..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { notFound, notImplemented } from '@hapi/boom'; -import { get } from 'lodash'; -import { CsvFromSavedObjectRequest } from '../../routes/generate_from_savedobject_immediate'; -import type { ReportingRequestHandlerContext } from '../../types'; -import { CreateJobFnFactory } from '../../types'; -import { - JobParamsPanelCsv, - JobPayloadPanelCsv, - SavedObject, - SavedObjectReference, - SavedObjectServiceError, - VisObjectAttributesJSON, -} from './types'; - -export type ImmediateCreateJobFn = ( - jobParams: JobParamsPanelCsv, - context: ReportingRequestHandlerContext, - req: CsvFromSavedObjectRequest -) => Promise<JobPayloadPanelCsv>; - -export const createJobFnFactory: CreateJobFnFactory<ImmediateCreateJobFn> = function createJobFactoryFn( - reporting, - parentLogger -) { - const logger = parentLogger.clone(['create-job']); - - return async function createJob(jobParams, context, req) { - const { savedObjectType, savedObjectId } = jobParams; - - const panel = await Promise.resolve() - .then(() => context.core.savedObjects.client.get(savedObjectType, savedObjectId)) - .then(async (savedObject: SavedObject) => { - const { attributes, references } = savedObject; - const { kibanaSavedObjectMeta: kibanaSavedObjectMetaJSON } = attributes; - const { timerange } = req.body; - - if (!kibanaSavedObjectMetaJSON) { - throw new Error('Could not parse saved object data!'); - } - - const kibanaSavedObjectMeta = { - ...kibanaSavedObjectMetaJSON, - searchSource: JSON.parse(kibanaSavedObjectMetaJSON.searchSourceJSON), - }; - - const { visState: visStateJSON } = attributes as VisObjectAttributesJSON; - if (visStateJSON) { - throw notImplemented('Visualization types are not yet implemented'); - } - - // saved search type - const { searchSource } = kibanaSavedObjectMeta; - if (!searchSource || !references) { - throw new Error('The saved search object is missing configuration fields!'); - } - - const indexPatternMeta = references.find( - (ref: SavedObjectReference) => ref.type === 'index-pattern' - ); - if (!indexPatternMeta) { - throw new Error('Could not find index pattern for the saved search!'); - } - - return { - attributes: { - ...attributes, - kibanaSavedObjectMeta: { searchSource }, - }, - indexPatternSavedObjectId: indexPatternMeta.id, - timerange, - }; - }) - .catch((err: Error) => { - const boomErr = (err as unknown) as { isBoom: boolean }; - if (boomErr.isBoom) { - throw err; - } - const errPayload: SavedObjectServiceError = get(err, 'output.payload', { statusCode: 0 }); - if (errPayload.statusCode === 404) { - throw notFound(errPayload.message); - } - logger.error(err); - throw new Error(`Unable to create a job from saved object data! Error: ${err}`); - }); - - return { ...jobParams, panel }; - }; -}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts deleted file mode 100644 index b79bb063c26f8..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { KibanaRequest } from 'src/core/server'; -import { CancellationToken } from '../../../common'; -import { CONTENT_TYPE_CSV } from '../../../common/constants'; -import { TaskRunResult } from '../../lib/tasks'; -import type { ReportingRequestHandlerContext } from '../../types'; -import { RunTaskFnFactory } from '../../types'; -import { createGenerateCsv } from '../csv/generate_csv'; -import { getGenerateCsvParams } from './lib/get_csv_job'; -import { JobPayloadPanelCsv } from './types'; - -/* - * ImmediateExecuteFn receives the job doc payload because the payload was - * generated in the ScheduleFn - */ -export type ImmediateExecuteFn = ( - jobId: null, - job: JobPayloadPanelCsv, - context: ReportingRequestHandlerContext, - req: KibanaRequest -) => Promise<TaskRunResult>; - -export const runTaskFnFactory: RunTaskFnFactory<ImmediateExecuteFn> = function executeJobFactoryFn( - reporting, - parentLogger -) { - const config = reporting.getConfig(); - const logger = parentLogger.clone(['execute-job']); - - return async function runTask(jobId, jobPayload, context, req) { - const generateCsv = createGenerateCsv(logger); - const { panel } = jobPayload; - - logger.debug(`Execute job generating saved search CSV`); - - const savedObjectsClient = context.core.savedObjects.client; - const uiSettingsClient = await reporting.getUiSettingsServiceFactory(savedObjectsClient); - const job = await getGenerateCsvParams( - jobPayload, - panel, - savedObjectsClient, - uiSettingsClient, - logger - ); - - const elasticsearch = reporting.getElasticsearchService(); - const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req); - - const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv( - job, - config, - uiSettingsClient, - callAsCurrentUser, - new CancellationToken() // can not be cancelled - ); - - if (csvContainsFormulas) { - logger.warn(`CSV may contain formulas whose values have been escaped`); - } - - if (maxSizeReached) { - logger.warn(`Max size reached: CSV output truncated to ${size} bytes`); - } - - return { - content_type: CONTENT_TYPE_CSV, - content, - max_size_reached: maxSizeReached, - size, - csv_contains_formulas: csvContainsFormulas, - warnings, - }; - }; -}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts deleted file mode 100644 index fc6e092962d3b..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { createMockLevelLogger } from '../../../test_helpers'; -import { JobParamsPanelCsv, SearchPanel } from '../types'; -import { getGenerateCsvParams } from './get_csv_job'; - -const logger = createMockLevelLogger(); - -describe('Get CSV Job', () => { - let mockJobParams: JobParamsPanelCsv; - let mockSearchPanel: SearchPanel; - let mockSavedObjectsClient: any; - let mockUiSettingsClient: any; - beforeEach(() => { - mockJobParams = { savedObjectType: 'search', savedObjectId: '234-ididid' }; - mockSearchPanel = { - indexPatternSavedObjectId: '123-indexId', - attributes: { - title: 'my search', - sort: [], - kibanaSavedObjectMeta: { - searchSource: { query: { isSearchSourceQuery: true }, filter: [] }, - }, - uiState: 56, - }, - timerange: { timezone: 'PST', min: 0, max: 100 }, - }; - mockSavedObjectsClient = { - get: () => ({ - attributes: { fields: null, title: null, timeFieldName: null }, - }), - }; - mockUiSettingsClient = { - get: () => ({}), - }; - }); - - it('creates a data structure needed by generateCsv', async () => { - const result = await getGenerateCsvParams( - mockJobParams, - mockSearchPanel, - mockSavedObjectsClient, - mockUiSettingsClient, - logger - ); - expect(result).toMatchInlineSnapshot(` - Object { - "browserTimezone": "PST", - "conflictedTypesFields": Array [], - "fields": Array [], - "indexPatternSavedObject": Object { - "attributes": Object { - "fields": null, - "timeFieldName": null, - "title": null, - }, - "fields": Array [], - "timeFieldName": null, - "title": null, - }, - "metaFields": Array [], - "searchRequest": Object { - "body": Object { - "_source": Object { - "includes": Array [], - }, - "docvalue_fields": undefined, - "query": Object { - "bool": Object { - "filter": Array [], - "must": Array [], - "must_not": Array [], - "should": Array [], - }, - }, - "script_fields": Object {}, - "sort": Array [], - }, - "index": null, - }, - } - `); - }); - - it('uses query and sort from the payload', async () => { - mockJobParams.post = { - state: { - query: ['this is the query'], - sort: ['this is the sort'], - }, - }; - const result = await getGenerateCsvParams( - mockJobParams, - mockSearchPanel, - mockSavedObjectsClient, - mockUiSettingsClient, - logger - ); - expect(result).toMatchInlineSnapshot(` - Object { - "browserTimezone": "PST", - "conflictedTypesFields": Array [], - "fields": Array [], - "indexPatternSavedObject": Object { - "attributes": Object { - "fields": null, - "timeFieldName": null, - "title": null, - }, - "fields": Array [], - "timeFieldName": null, - "title": null, - }, - "metaFields": Array [], - "searchRequest": Object { - "body": Object { - "_source": Object { - "includes": Array [], - }, - "docvalue_fields": undefined, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "0": "this is the query", - }, - ], - "must": Array [], - "must_not": Array [], - "should": Array [], - }, - }, - "script_fields": Object {}, - "sort": Array [ - "this is the sort", - ], - }, - "index": null, - }, - } - `); - }); - - it('uses timerange timezone from the payload', async () => { - mockJobParams.post = { - timerange: { timezone: 'Africa/Timbuktu', min: 0, max: 9000 }, - }; - const result = await getGenerateCsvParams( - mockJobParams, - mockSearchPanel, - mockSavedObjectsClient, - mockUiSettingsClient, - logger - ); - expect(result).toMatchInlineSnapshot(` - Object { - "browserTimezone": "Africa/Timbuktu", - "conflictedTypesFields": Array [], - "fields": Array [], - "indexPatternSavedObject": Object { - "attributes": Object { - "fields": null, - "timeFieldName": null, - "title": null, - }, - "fields": Array [], - "timeFieldName": null, - "title": null, - }, - "metaFields": Array [], - "searchRequest": Object { - "body": Object { - "_source": Object { - "includes": Array [], - }, - "docvalue_fields": undefined, - "query": Object { - "bool": Object { - "filter": Array [], - "must": Array [], - "must_not": Array [], - "should": Array [], - }, - }, - "script_fields": Object {}, - "sort": Array [], - }, - "index": null, - }, - } - `); - }); - - it('uses timerange min and max (numeric) when index pattern has timefieldName', async () => { - mockJobParams.post = { - timerange: { timezone: 'Africa/Timbuktu', min: 0, max: 900000000 }, - }; - mockSavedObjectsClient = { - get: () => ({ - attributes: { fields: null, title: 'test search', timeFieldName: '@test_time' }, - }), - }; - const result = await getGenerateCsvParams( - mockJobParams, - mockSearchPanel, - mockSavedObjectsClient, - mockUiSettingsClient, - logger - ); - expect(result).toMatchInlineSnapshot(` - Object { - "browserTimezone": "Africa/Timbuktu", - "conflictedTypesFields": Array [], - "fields": Array [ - "@test_time", - ], - "indexPatternSavedObject": Object { - "attributes": Object { - "fields": null, - "timeFieldName": "@test_time", - "title": "test search", - }, - "fields": Array [], - "timeFieldName": "@test_time", - "title": "test search", - }, - "metaFields": Array [], - "searchRequest": Object { - "body": Object { - "_source": Object { - "includes": Array [ - "@test_time", - ], - }, - "docvalue_fields": undefined, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "@test_time": Object { - "format": "strict_date_time", - "gte": "1970-01-01T00:00:00Z", - "lte": "1970-01-11T10:00:00Z", - }, - }, - }, - ], - "must": Array [], - "must_not": Array [], - "should": Array [], - }, - }, - "script_fields": Object {}, - "sort": Array [], - }, - "index": "test search", - }, - } - `); - }); - - it('uses timerange min and max (string) when index pattern has timefieldName', async () => { - mockJobParams.post = { - timerange: { - timezone: 'Africa/Timbuktu', - min: '1980-01-01T00:00:00Z', - max: '1990-01-01T00:00:00Z', - }, - }; - mockSavedObjectsClient = { - get: () => ({ - attributes: { fields: null, title: 'test search', timeFieldName: '@test_time' }, - }), - }; - const result = await getGenerateCsvParams( - mockJobParams, - mockSearchPanel, - mockSavedObjectsClient, - mockUiSettingsClient, - logger - ); - expect(result).toMatchInlineSnapshot(` - Object { - "browserTimezone": "Africa/Timbuktu", - "conflictedTypesFields": Array [], - "fields": Array [ - "@test_time", - ], - "indexPatternSavedObject": Object { - "attributes": Object { - "fields": null, - "timeFieldName": "@test_time", - "title": "test search", - }, - "fields": Array [], - "timeFieldName": "@test_time", - "title": "test search", - }, - "metaFields": Array [], - "searchRequest": Object { - "body": Object { - "_source": Object { - "includes": Array [ - "@test_time", - ], - }, - "docvalue_fields": undefined, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "@test_time": Object { - "format": "strict_date_time", - "gte": "1980-01-01T00:00:00Z", - "lte": "1990-01-01T00:00:00Z", - }, - }, - }, - ], - "must": Array [], - "must_not": Array [], - "should": Array [], - }, - }, - "script_fields": Object {}, - "sort": Array [], - }, - "index": "test search", - }, - } - `); - }); -}); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts deleted file mode 100644 index e4570816e26ff..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts +++ /dev/null @@ -1,155 +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 { IUiSettingsClient, SavedObjectsClientContract } from 'kibana/server'; -import { EsQueryConfig } from 'src/plugins/data/server'; -import { esQuery, Filter, Query } from '../../../../../../../src/plugins/data/server'; -import { LevelLogger } from '../../../lib'; -import { TimeRangeParams } from '../../common'; -import { GenerateCsvParams } from '../../csv/generate_csv'; -import { - DocValueFields, - IndexPatternField, - JobParamsPanelCsv, - QueryFilter, - SavedSearchObjectAttributes, - SearchPanel, - SearchSource, -} from '../types'; -import { getDataSource } from './get_data_source'; -import { getFilters } from './get_filters'; - -export const getEsQueryConfig = async (config: IUiSettingsClient) => { - const configs = await Promise.all([ - config.get('query:allowLeadingWildcards'), - config.get('query:queryString:options'), - config.get('courier:ignoreFilterIfFieldNotInIndex'), - ]); - const [allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex] = configs; - return { - allowLeadingWildcards, - queryStringOptions, - ignoreFilterIfFieldNotInIndex, - } as EsQueryConfig; -}; - -/* - * Create a CSV Job object for CSV From SavedObject to use as a job parameter - * for generateCsv - */ -export const getGenerateCsvParams = async ( - jobParams: JobParamsPanelCsv, - panel: SearchPanel, - savedObjectsClient: SavedObjectsClientContract, - uiConfig: IUiSettingsClient, - logger: LevelLogger -): Promise<GenerateCsvParams> => { - let timerange: TimeRangeParams | null; - if (jobParams.post?.timerange) { - timerange = jobParams.post?.timerange; - } else { - timerange = panel.timerange || null; - } - const { indexPatternSavedObjectId } = panel; - const savedSearchObjectAttr = panel.attributes as SavedSearchObjectAttributes; - const { indexPatternSavedObject } = await getDataSource( - savedObjectsClient, - indexPatternSavedObjectId - ); - const esQueryConfig = await getEsQueryConfig(uiConfig); - - const { - kibanaSavedObjectMeta: { - searchSource: { - filter: [searchSourceFilter], - query: searchSourceQuery, - }, - }, - } = savedSearchObjectAttr as { kibanaSavedObjectMeta: { searchSource: SearchSource } }; - - const { - timeFieldName: indexPatternTimeField, - title: esIndex, - fields: indexPatternFields, - } = indexPatternSavedObject; - - if (!indexPatternFields || indexPatternFields.length === 0) { - logger.error( - new Error( - `No fields are selected in the saved search! Please select fields as columns in the saved search and try again.` - ) - ); - } - - let payloadQuery: QueryFilter | undefined; - let payloadSort: any[] = []; - let docValueFields: DocValueFields[] | undefined; - if (jobParams.post && jobParams.post.state) { - ({ - post: { - state: { query: payloadQuery, sort: payloadSort = [], docvalue_fields: docValueFields }, - }, - } = jobParams); - } - const { includes, combinedFilter } = getFilters( - indexPatternSavedObjectId, - indexPatternTimeField, - timerange, - savedSearchObjectAttr, - searchSourceFilter, - payloadQuery - ); - - const savedSortConfigs = savedSearchObjectAttr.sort; - const sortConfig = [...payloadSort]; - savedSortConfigs.forEach(([savedSortField, savedSortOrder]) => { - sortConfig.push({ [savedSortField]: { order: savedSortOrder } }); - }); - - const scriptFieldsConfig = - indexPatternFields && - indexPatternFields - .filter((f: IndexPatternField) => f.scripted) - .reduce((accum: any, curr: IndexPatternField) => { - return { - ...accum, - [curr.name]: { - script: { - source: curr.script, - lang: curr.lang, - }, - }, - }; - }, {}); - - const searchRequest = { - index: esIndex, - body: { - _source: { includes }, - docvalue_fields: docValueFields, - query: esQuery.buildEsQuery( - // compromise made while factoring out IIndexPattern type - // @ts-expect-error - indexPatternSavedObject, - (searchSourceQuery as unknown) as Query, - (combinedFilter as unknown) as Filter, - esQueryConfig - ), - script_fields: scriptFieldsConfig, - sort: sortConfig, - }, - }; - - return { - browserTimezone: timerange?.timezone, - indexPatternSavedObject, - searchRequest, - fields: includes, - metaFields: [], - conflictedTypesFields: [], - }; -}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_data_source.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_data_source.ts deleted file mode 100644 index d903a1d8ba9e8..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_data_source.ts +++ /dev/null @@ -1,56 +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 { IndexPatternSavedObjectDeprecatedCSV } from '../../csv/types'; -import { SavedObjectReference, SavedSearchObjectAttributesJSON, SearchSource } from '../types'; - -export async function getDataSource( - savedObjectsClient: any, - indexPatternId?: string, - savedSearchObjectId?: string -): Promise<{ - indexPatternSavedObject: IndexPatternSavedObjectDeprecatedCSV; - searchSource: SearchSource | null; -}> { - let indexPatternSavedObject: IndexPatternSavedObjectDeprecatedCSV; - let searchSource: SearchSource | null = null; - - if (savedSearchObjectId) { - try { - const { attributes, references } = (await savedObjectsClient.get( - 'search', - savedSearchObjectId - )) as { attributes: SavedSearchObjectAttributesJSON; references: SavedObjectReference[] }; - searchSource = JSON.parse(attributes.kibanaSavedObjectMeta.searchSourceJSON); - const { id: indexPatternFromSearchId } = references.find( - ({ type }) => type === 'index-pattern' - ) as { id: string }; - ({ indexPatternSavedObject } = await getDataSource( - savedObjectsClient, - indexPatternFromSearchId - )); - return { searchSource, indexPatternSavedObject }; - } catch (err) { - throw new Error(`Could not get saved search info! ${err}`); - } - } - try { - const { attributes } = await savedObjectsClient.get('index-pattern', indexPatternId); - const { fields, title, timeFieldName } = attributes; - const parsedFields = fields ? JSON.parse(fields) : []; - - indexPatternSavedObject = { - fields: parsedFields, - title, - timeFieldName, - attributes, - }; - } catch (err) { - throw new Error(`Could not get index pattern saved object! ${err}`); - } - return { indexPatternSavedObject, searchSource }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts deleted file mode 100644 index ca5bf12e1d510..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { TimeRangeParams } from '../../common'; -import { QueryFilter, SavedSearchObjectAttributes, SearchSourceFilter } from '../types'; -import { getFilters } from './get_filters'; - -interface Args { - indexPatternId: string; - indexPatternTimeField: string | null; - timerange: TimeRangeParams | null; - savedSearchObjectAttr: SavedSearchObjectAttributes; - searchSourceFilter: SearchSourceFilter; - queryFilter: QueryFilter; -} - -describe('CSV from Saved Object: get_filters', () => { - let args: Args; - beforeEach(() => { - args = { - indexPatternId: 'logs-test-*', - indexPatternTimeField: 'testtimestamp', - timerange: { - timezone: 'UTC', - min: '1901-01-01T00:00:00.000Z', - max: '1902-01-01T00:00:00.000Z', - }, - savedSearchObjectAttr: { - title: 'test', - sort: [{ sortField: { order: 'asc' } }], - kibanaSavedObjectMeta: { - searchSource: { - query: { isSearchSourceQuery: true }, - filter: ['hello searchSource filter 1'], - }, - }, - columns: ['larry'], - uiState: null, - }, - searchSourceFilter: { isSearchSourceFilter: true, isFilter: true }, - queryFilter: { isQueryFilter: true, isFilter: true }, - }; - }); - - describe('search', () => { - it('for timebased search', () => { - const filters = getFilters( - args.indexPatternId, - args.indexPatternTimeField, - args.timerange, - args.savedSearchObjectAttr, - args.searchSourceFilter, - args.queryFilter - ); - - expect(filters).toEqual({ - combinedFilter: [ - { - range: { - testtimestamp: { - format: 'strict_date_time', - gte: '1901-01-01T00:00:00Z', - lte: '1902-01-01T00:00:00Z', - }, - }, - }, - { isFilter: true, isSearchSourceFilter: true }, - { isFilter: true, isQueryFilter: true }, - ], - includes: ['testtimestamp', 'larry'], - timezone: 'UTC', - }); - }); - - it('for non-timebased search', () => { - args.indexPatternTimeField = null; - args.timerange = null; - - const filters = getFilters( - args.indexPatternId, - args.indexPatternTimeField, - args.timerange, - args.savedSearchObjectAttr, - args.searchSourceFilter, - args.queryFilter - ); - - expect(filters).toEqual({ - combinedFilter: [ - { isFilter: true, isSearchSourceFilter: true }, - { isFilter: true, isQueryFilter: true }, - ], - includes: ['larry'], - timezone: null, - }); - }); - }); - - describe('errors', () => { - it('throw if timebased and timerange is missing', () => { - args.timerange = null; - - const throwFn = () => - getFilters( - args.indexPatternId, - args.indexPatternTimeField, - args.timerange, - args.savedSearchObjectAttr, - args.searchSourceFilter, - args.queryFilter - ); - - expect(throwFn).toThrow( - 'Time range params are required for index pattern [logs-test-*], using time field [testtimestamp]' - ); - }); - }); - - it('composes the defined filters', () => { - expect( - getFilters( - args.indexPatternId, - args.indexPatternTimeField, - args.timerange, - args.savedSearchObjectAttr, - undefined, - undefined - ) - ).toEqual({ - combinedFilter: [ - { - range: { - testtimestamp: { - format: 'strict_date_time', - gte: '1901-01-01T00:00:00Z', - lte: '1902-01-01T00:00:00Z', - }, - }, - }, - ], - includes: ['testtimestamp', 'larry'], - timezone: 'UTC', - }); - - expect( - getFilters( - args.indexPatternId, - args.indexPatternTimeField, - args.timerange, - args.savedSearchObjectAttr, - undefined, - args.queryFilter - ) - ).toEqual({ - combinedFilter: [ - { - range: { - testtimestamp: { - format: 'strict_date_time', - gte: '1901-01-01T00:00:00Z', - lte: '1902-01-01T00:00:00Z', - }, - }, - }, - { isFilter: true, isQueryFilter: true }, - ], - includes: ['testtimestamp', 'larry'], - timezone: 'UTC', - }); - }); - - describe('timefilter', () => { - it('formats the datetime to the provided timezone', () => { - args.timerange = { - timezone: 'MST', - min: '1901-01-01T00:00:00Z', - max: '1902-01-01T00:00:00Z', - }; - - expect( - getFilters( - args.indexPatternId, - args.indexPatternTimeField, - args.timerange, - args.savedSearchObjectAttr - ) - ).toEqual({ - combinedFilter: [ - { - range: { - testtimestamp: { - format: 'strict_date_time', - gte: '1900-12-31T17:00:00-07:00', - lte: '1901-12-31T17:00:00-07:00', - }, - }, - }, - ], - includes: ['testtimestamp', 'larry'], - timezone: 'MST', - }); - }); - }); -}); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts deleted file mode 100644 index c252b66952360..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { badRequest } from '@hapi/boom'; -import moment from 'moment-timezone'; -import { TimeRangeParams } from '../../common'; -import { Filter, QueryFilter, SavedSearchObjectAttributes, SearchSourceFilter } from '../types'; - -export function getFilters( - indexPatternId: string, - indexPatternTimeField: string | null, - timerange: TimeRangeParams | null, - savedSearchObjectAttr: SavedSearchObjectAttributes, - searchSourceFilter?: SearchSourceFilter, - queryFilter?: QueryFilter -) { - let includes: string[]; - let timeFilter: any | null; - let timezone: string | null; - - if (indexPatternTimeField) { - if (!timerange || timerange.min == null || timerange.max == null) { - throw badRequest( - `Time range params are required for index pattern [${indexPatternId}], using time field [${indexPatternTimeField}]` - ); - } - - timezone = timerange.timezone; - const { min: gte, max: lte } = timerange; - timeFilter = { - range: { - [indexPatternTimeField]: { - format: 'strict_date_time', - gte: moment.tz(moment(gte), timezone).format(), - lte: moment.tz(moment(lte), timezone).format(), - }, - }, - }; - - const savedSearchCols = savedSearchObjectAttr.columns || []; - includes = [indexPatternTimeField, ...savedSearchCols]; - } else { - includes = savedSearchObjectAttr.columns || []; - timeFilter = null; - timezone = null; - } - - const combinedFilter: Filter[] = [timeFilter, searchSourceFilter, queryFilter].filter(Boolean); // builds an array of defined filters - - return { timezone, combinedFilter, includes }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts deleted file mode 100644 index a4fbdb69bbbba..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { TimeRangeParams } from '../common'; - -export interface FakeRequest { - headers: Record<string, string>; -} - -export interface JobParamsPanelCsvPost { - timerange?: TimeRangeParams; - state?: any; -} - -export interface SearchPanel { - indexPatternSavedObjectId: string; - attributes: SavedSearchObjectAttributes; - timerange?: TimeRangeParams; -} - -export interface JobPayloadPanelCsv extends JobParamsPanelCsv { - panel: SearchPanel; -} - -export interface JobParamsPanelCsv { - savedObjectType: string; - savedObjectId: string; - post?: JobParamsPanelCsvPost; - visType?: string; -} - -export interface SavedObjectServiceError { - statusCode: number; - error?: string; - message?: string; -} - -export interface SavedObjectMetaJSON { - searchSourceJSON: string; -} - -export interface SavedObjectMeta { - searchSource: SearchSource; -} - -export interface SavedSearchObjectAttributesJSON { - title: string; - sort: any[]; - columns: string[]; - kibanaSavedObjectMeta: SavedObjectMetaJSON; - uiState: any; -} - -export interface SavedSearchObjectAttributes { - title: string; - sort: any[]; - columns?: string[]; - kibanaSavedObjectMeta: SavedObjectMeta; - uiState: any; -} - -export interface VisObjectAttributesJSON { - title: string; - visState: string; // JSON string - type: string; - params: any; - uiStateJSON: string; // also JSON string - aggs: any[]; - sort: any[]; - kibanaSavedObjectMeta: SavedObjectMeta; -} - -export interface VisObjectAttributes { - title: string; - visState: string; // JSON string - type: string; - params: any; - uiState: { - vis: { - params: { - sort: { - columnIndex: string; - direction: string; - }; - }; - }; - }; - aggs: any[]; - sort: any[]; - kibanaSavedObjectMeta: SavedObjectMeta; -} - -export interface SavedObjectReference { - name: string; // should be kibanaSavedObjectMeta.searchSourceJSON.index - type: string; // should be index-pattern - id: string; -} - -export interface SavedObject { - attributes: any; - references: SavedObjectReference[]; -} - -export interface VisPanel { - indexPatternSavedObjectId?: string; - savedSearchObjectId?: string; - attributes: VisObjectAttributes; - timerange: TimeRangeParams; -} - -export interface DocValueFields { - field: string; - format: string; -} - -export interface SearchSourceQuery { - isSearchSourceQuery: boolean; -} - -export interface SearchSource { - query: SearchSourceQuery; - filter: any[]; -} - -/* - * These filter types are stub types to help ensure things get passed to - * non-Typescript functions in the right order. An actual structure is not - * needed because the code doesn't look into the properties; just combines them - * and passes them through to other non-TS modules. - */ -export interface Filter { - isFilter: boolean; -} -export interface TimeFilter extends Filter { - isTimeFilter: boolean; -} -export interface QueryFilter extends Filter { - isQueryFilter: boolean; -} -export interface SearchSourceFilter extends Filter { - isSearchSourceFilter: boolean; -} - -export interface IndexPatternField { - scripted: boolean; - lang?: string; - script?: string; - name: string; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/create_job.ts new file mode 100644 index 0000000000000..a389f2a3252ca --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/create_job.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 { CSV_JOB_TYPE } from '../../../common/constants'; +import { cryptoFactory } from '../../lib'; +import { CreateJobFn, CreateJobFnFactory } from '../../types'; +import { JobParamsCSV, TaskPayloadCSV } from './types'; + +export const createJobFnFactory: CreateJobFnFactory< + CreateJobFn<JobParamsCSV, TaskPayloadCSV> +> = function createJobFactoryFn(reporting, parentLogger) { + const logger = parentLogger.clone([CSV_JOB_TYPE, 'create-job']); + + const config = reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); + + return async function createJob(jobParams, context, request) { + const serializedEncryptedHeaders = await crypto.encrypt(request.headers); + + return { + headers: serializedEncryptedHeaders, + spaceId: reporting.getSpaceId(request, logger), + ...jobParams, + }; + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts new file mode 100644 index 0000000000000..1c2e15ebc5d9b --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('./generate_csv/generate_csv', () => ({ + CsvGenerator: class CsvGeneratorMock { + generateData() { + return { + content: 'test\n123', + }; + } + }, +})); + +import nodeCrypto from '@elastic/node-crypto'; +import { ReportingCore } from '../../'; +import { CancellationToken } from '../../../common'; +import { + createMockConfig, + createMockConfigSchema, + createMockLevelLogger, + createMockReportingCore, +} from '../../test_helpers'; +import { runTaskFnFactory } from './execute_job'; + +const logger = createMockLevelLogger(); +const encryptionKey = 'tetkey'; +const headers = { sid: 'cooltestheaders' }; +let encryptedHeaders: string; +let reportingCore: ReportingCore; + +beforeAll(async () => { + const crypto = nodeCrypto({ encryptionKey }); + const config = createMockConfig( + createMockConfigSchema({ + encryptionKey, + csv: { + checkForFormulas: true, + escapeFormulaValues: true, + maxSizeBytes: 180000, + scroll: { size: 500, duration: '30s' }, + }, + }) + ); + + encryptedHeaders = await crypto.encrypt(headers); + + reportingCore = await createMockReportingCore(config); +}); + +test('gets the csv content from job parameters', async () => { + const runTask = runTaskFnFactory(reportingCore, logger); + + const payload = await runTask( + 'cool-job-id', + { + headers: encryptedHeaders, + browserTimezone: 'US/Alaska', + searchSource: {}, + objectType: 'search', + title: 'Test Search', + }, + new CancellationToken() + ); + + expect(payload).toMatchInlineSnapshot(` + Object { + "content": "test + 123", + } + `); +}); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.ts new file mode 100644 index 0000000000000..ff50377ab13c5 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CSV_JOB_TYPE } from '../../../common/constants'; +import { getFieldFormats } from '../../services'; +import { RunTaskFn, RunTaskFnFactory } from '../../types'; +import { decryptJobHeaders } from '../common'; +import { CsvGenerator } from './generate_csv/generate_csv'; +import { TaskPayloadCSV } from './types'; + +export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadCSV>> = ( + reporting, + parentLogger +) => { + const config = reporting.getConfig(); + + return async function runTask(jobId, job, cancellationToken) { + const logger = parentLogger.clone([CSV_JOB_TYPE, 'execute-job', jobId]); + + const encryptionKey = config.get('encryptionKey'); + const headers = await decryptJobHeaders(encryptionKey, job.headers, logger); + const fakeRequest = reporting.getFakeRequest({ headers }, job.spaceId, logger); + const uiSettings = await reporting.getUiSettingsClient(fakeRequest, logger); + const dataPluginStart = await reporting.getDataService(); + const fieldFormatsRegistry = await getFieldFormats().fieldFormatServiceFactory(uiSettings); + + const [es, searchSourceStart] = await Promise.all([ + (await reporting.getEsClient()).asScoped(fakeRequest), + await dataPluginStart.search.searchSource.asScoped(fakeRequest), + ]); + + const clients = { + uiSettings, + data: dataPluginStart.search.asScoped(fakeRequest), + es, + }; + const dependencies = { + searchSourceStart, + fieldFormatsRegistry, + }; + + const csv = new CsvGenerator(job, config, clients, dependencies, cancellationToken, logger); + return await csv.generateData(); + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap new file mode 100644 index 0000000000000..62c9ecff830ff --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fields cells can be multi-value 1`] = ` +"\\"_id\\",sku +\\"my-cool-id\\",\\"This is a cool SKU., This is also a cool SKU.\\" +" +`; + +exports[`fields provides top-level underscored fields as columns 1`] = ` +"\\"_id\\",\\"_index\\",date,message +\\"my-cool-id\\",\\"my-cool-index\\",\\"2020-12-31T00:14:28.000Z\\",\\"it's nice to see you\\" +" +`; + +exports[`fields sorts the fields when they are to be used as table column names 1`] = ` +"\\"_id\\",\\"_index\\",\\"_score\\",\\"_type\\",date,\\"message_t\\",\\"message_u\\",\\"message_v\\",\\"message_w\\",\\"message_x\\",\\"message_y\\",\\"message_z\\" +\\"my-cool-id\\",\\"my-cool-index\\",\\"'-\\",\\"'-\\",\\"2020-12-31T00:14:28.000Z\\",\\"test field T\\",\\"test field U\\",\\"test field V\\",\\"test field W\\",\\"test field X\\",\\"test field Y\\",\\"test field Z\\" +" +`; + +exports[`formats a search result to CSV content 1`] = ` +"date,ip,message +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"This is a great message!\\" +" +`; + +exports[`formats an empty search result to CSV content 1`] = ` +"date,ip,message +" +`; + +exports[`formulas can check for formulas, without escaping them 1`] = ` +"date,ip,message +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"=SUM(A1:A2)\\" +" +`; + +exports[`formulas escapes formula values in a cell, doesn't warn the csv contains formulas 1`] = ` +"date,ip,message +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"'=SUM(A1:A2)\\" +" +`; + +exports[`formulas escapes formula values in a header, doesn't warn the csv contains formulas 1`] = ` +"date,ip,\\"'=SUM(A1:A2)\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"This is great data\\" +" +`; + +exports[`uses the scrollId to page all the data 1`] = ` +"date,ip,message +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from a subsequent scroll\\" +" +`; + +exports[`warns if max size was reached 1`] = ` +"date,ip,message +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"super cali fragile istic XPLA docious\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"super cali fragile istic XPLA docious\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"super cali fragile istic XPLA docious\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"super cali fragile istic XPLA docious\\" +\\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"super cali fragile istic XPLA docious\\" +" +`; diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/cell_has_formula.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/cell_has_formula.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/generate_csv/cell_has_formula.ts rename to x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/cell_has_formula.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/escape_value.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/escape_value.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/generate_csv/escape_value.test.ts rename to x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/escape_value.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/escape_value.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/escape_value.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/generate_csv/escape_value.ts rename to x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/escape_value.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts new file mode 100644 index 0000000000000..0193eaaff2c8d --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts @@ -0,0 +1,645 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 Rx from 'rxjs'; +import { identity, range } from 'lodash'; +import { IScopedClusterClient, IUiSettingsClient, SearchResponse } from 'src/core/server'; +import { + elasticsearchServiceMock, + savedObjectsClientMock, + uiSettingsServiceMock, +} from 'src/core/server/mocks'; +import { FieldFormatsRegistry, ISearchStartSearchSource } from 'src/plugins/data/common'; +import { searchSourceInstanceMock } from 'src/plugins/data/common/search/search_source/mocks'; +import { IScopedSearchClient } from 'src/plugins/data/server'; +import { dataPluginMock } from 'src/plugins/data/server/mocks'; +import { ReportingConfig } from '../../../'; +import { CancellationToken } from '../../../../common'; +import { + UI_SETTINGS_CSV_QUOTE_VALUES, + UI_SETTINGS_CSV_SEPARATOR, + UI_SETTINGS_DATEFORMAT_TZ, +} from '../../../../common/constants'; +import { + createMockConfig, + createMockConfigSchema, + createMockLevelLogger, +} from '../../../test_helpers'; +import { JobParamsCSV } from '../types'; +import { CsvGenerator } from './generate_csv'; + +const createMockJob = (baseObj: any = {}): JobParamsCSV => ({ + ...baseObj, +}); + +let mockEsClient: IScopedClusterClient; +let mockDataClient: IScopedSearchClient; +let mockConfig: ReportingConfig; +let uiSettingsClient: IUiSettingsClient; + +const searchSourceMock = { ...searchSourceInstanceMock }; +const mockSearchSourceService: jest.Mocked<ISearchStartSearchSource> = { + create: jest.fn().mockReturnValue(searchSourceMock), + createEmpty: jest.fn().mockReturnValue(searchSourceMock), +}; +const mockDataClientSearchDefault = jest.fn().mockImplementation( + (): Rx.Observable<{ rawResponse: SearchResponse<unknown> }> => + Rx.of({ + rawResponse: { + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, + hits: { + hits: [], + total: 0, + max_score: 0, + }, + }, + }) +); +const mockSearchSourceGetFieldDefault = jest.fn().mockImplementation((key: string) => { + switch (key) { + case 'fields': + return ['date', 'ip', 'message']; + case 'index': + return { + fields: { + getByName: jest.fn().mockImplementation(() => []), + getByType: jest.fn().mockImplementation(() => []), + }, + getFormatterForField: jest.fn(), + }; + } +}); + +const mockFieldFormatsRegistry = ({ + deserialize: jest + .fn() + .mockImplementation(() => ({ id: 'string', convert: jest.fn().mockImplementation(identity) })), +} as unknown) as FieldFormatsRegistry; + +beforeEach(async () => { + mockEsClient = elasticsearchServiceMock.createScopedClusterClient(); + mockDataClient = dataPluginMock.createStartContract().search.asScoped({} as any); + mockDataClient.search = mockDataClientSearchDefault; + + uiSettingsClient = uiSettingsServiceMock + .createStartContract() + .asScopedToClient(savedObjectsClientMock.create()); + uiSettingsClient.get = jest.fn().mockImplementation((key): any => { + switch (key) { + case UI_SETTINGS_CSV_QUOTE_VALUES: + return true; + case UI_SETTINGS_CSV_SEPARATOR: + return ','; + case UI_SETTINGS_DATEFORMAT_TZ: + return 'Browser'; + } + }); + + mockConfig = createMockConfig( + createMockConfigSchema({ + csv: { + checkForFormulas: true, + escapeFormulaValues: true, + maxSizeBytes: 180000, + scroll: { size: 500, duration: '30s' }, + }, + }) + ); + + searchSourceMock.getField = mockSearchSourceGetFieldDefault; +}); + +const logger = createMockLevelLogger(); + +it('formats an empty search result to CSV content', async () => { + const generateCsv = new CsvGenerator( + createMockJob({}), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + const csvResult = await generateCsv.generateData(); + expect(csvResult.content).toMatchSnapshot(); + expect(csvResult.csv_contains_formulas).toBe(false); +}); + +it('formats a search result to CSV content', async () => { + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + fields: { + date: `["2020-12-31T00:14:28.000Z"]`, + ip: `["110.135.176.89"]`, + message: `["This is a great message!"]`, + }, + }, + ], + total: 1, + }, + }, + }) + ); + const generateCsv = new CsvGenerator( + createMockJob({}), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + const csvResult = await generateCsv.generateData(); + expect(csvResult.content).toMatchSnapshot(); + expect(csvResult.csv_contains_formulas).toBe(false); +}); + +const HITS_TOTAL = 100; + +it('calculates the bytes of the content', async () => { + searchSourceMock.getField = jest.fn().mockImplementation((key: string) => { + if (key === 'fields') { + return ['message']; + } + return mockSearchSourceGetFieldDefault(key); + }); + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: range(0, HITS_TOTAL).map((hit, i) => ({ + fields: { + message: ['this is a great message'], + }, + })), + total: HITS_TOTAL, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({}), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + const csvResult = await generateCsv.generateData(); + expect(csvResult.size).toBe(2608); + expect(csvResult.max_size_reached).toBe(false); + expect(csvResult.warnings).toEqual([]); +}); + +it('warns if max size was reached', async () => { + const TEST_MAX_SIZE = 500; + + mockConfig = createMockConfig( + createMockConfigSchema({ + csv: { + checkForFormulas: true, + escapeFormulaValues: true, + maxSizeBytes: TEST_MAX_SIZE, + scroll: { size: 500, duration: '30s' }, + }, + }) + ); + + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: range(0, HITS_TOTAL).map((hit, i) => ({ + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: ['super cali fragile istic XPLA docious'], + }, + })), + total: HITS_TOTAL, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({}), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + const csvResult = await generateCsv.generateData(); + expect(csvResult.max_size_reached).toBe(true); + expect(csvResult.warnings).toEqual([]); + expect(csvResult.content).toMatchSnapshot(); +}); + +it('uses the scrollId to page all the data', async () => { + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + _scroll_id: 'awesome-scroll-hero', + hits: { + hits: range(0, HITS_TOTAL / 10).map((hit, i) => ({ + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: ['hit from the initial search'], + }, + })), + total: HITS_TOTAL, + }, + }, + }) + ); + mockEsClient.asCurrentUser.scroll = jest.fn().mockResolvedValue({ + body: { + hits: { + hits: range(0, HITS_TOTAL / 10).map((hit, i) => ({ + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: ['hit from a subsequent scroll'], + }, + })), + }, + }, + }); + + const generateCsv = new CsvGenerator( + createMockJob({}), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + const csvResult = await generateCsv.generateData(); + expect(csvResult.warnings).toEqual([]); + expect(csvResult.content).toMatchSnapshot(); +}); + +describe('fields', () => { + it('cells can be multi-value', async () => { + searchSourceMock.getField = jest.fn().mockImplementation((key: string) => { + if (key === 'fields') { + return ['_id', 'sku']; + } + return mockSearchSourceGetFieldDefault(key); + }); + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + sku: [`This is a cool SKU.`, `This is also a cool SKU.`], + }, + }, + ], + total: 1, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({ searchSource: {} }), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + const csvResult = await generateCsv.generateData(); + + expect(csvResult.content).toMatchSnapshot(); + }); + + it('provides top-level underscored fields as columns', async () => { + searchSourceMock.getField = jest.fn().mockImplementation((key: string) => { + if (key === 'fields') { + return ['_id', '_index', 'date', 'message']; + } + return mockSearchSourceGetFieldDefault(key); + }); + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + date: ['2020-12-31T00:14:28.000Z'], + message: [`it's nice to see you`], + }, + }, + ], + total: 1, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({ + searchSource: { + query: { query: '', language: 'kuery' }, + sort: [{ '@date': 'desc' }], + index: '93f4bc50-6662-11eb-98bc-f550e2308366', + fields: ['_id', '_index', '@date', 'message'], + filter: [], + }, + }), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + + const csvResult = await generateCsv.generateData(); + + expect(csvResult.content).toMatchSnapshot(); + expect(csvResult.csv_contains_formulas).toBe(false); + }); + + it('sorts the fields when they are to be used as table column names', async () => { + searchSourceMock.getField = jest.fn().mockImplementation((key: string) => { + if (key === 'fields') { + return ['*']; + } + return mockSearchSourceGetFieldDefault(key); + }); + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + date: ['2020-12-31T00:14:28.000Z'], + message_z: [`test field Z`], + message_y: [`test field Y`], + message_x: [`test field X`], + message_w: [`test field W`], + message_v: [`test field V`], + message_u: [`test field U`], + message_t: [`test field T`], + }, + }, + ], + total: 1, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({ + searchSource: { + query: { query: '', language: 'kuery' }, + sort: [{ '@date': 'desc' }], + index: '93f4bc50-6662-11eb-98bc-f550e2308366', + fields: ['*'], + filter: [], + }, + }), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + + const csvResult = await generateCsv.generateData(); + + expect(csvResult.content).toMatchSnapshot(); + expect(csvResult.csv_contains_formulas).toBe(false); + }); +}); + +describe('formulas', () => { + const TEST_FORMULA = '=SUM(A1:A2)'; + + it(`escapes formula values in a cell, doesn't warn the csv contains formulas`, async () => { + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: [TEST_FORMULA], + }, + }, + ], + total: 1, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({}), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + + const csvResult = await generateCsv.generateData(); + + expect(csvResult.content).toMatchSnapshot(); + expect(csvResult.csv_contains_formulas).toBe(false); + }); + + it(`escapes formula values in a header, doesn't warn the csv contains formulas`, async () => { + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + [TEST_FORMULA]: 'This is great data', + }, + }, + ], + total: 1, + }, + }, + }) + ); + + searchSourceMock.getField = jest.fn().mockImplementation((key: string) => { + if (key === 'fields') { + return ['date', 'ip', TEST_FORMULA]; + } + return mockSearchSourceGetFieldDefault(key); + }); + + const generateCsv = new CsvGenerator( + createMockJob({}), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + + const csvResult = await generateCsv.generateData(); + + expect(csvResult.content).toMatchSnapshot(); + expect(csvResult.csv_contains_formulas).toBe(false); + }); + + it('can check for formulas, without escaping them', async () => { + mockConfig = createMockConfig( + createMockConfigSchema({ + csv: { + checkForFormulas: true, + escapeFormulaValues: false, + maxSizeBytes: 180000, + scroll: { size: 500, duration: '30s' }, + }, + }) + ); + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: [TEST_FORMULA], + }, + }, + ], + total: 1, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({}), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + + const csvResult = await generateCsv.generateData(); + + expect(csvResult.content).toMatchSnapshot(); + expect(csvResult.csv_contains_formulas).toBe(true); + }); +}); 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 new file mode 100644 index 0000000000000..370fc42921acf --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -0,0 +1,400 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { SearchResponse } from 'elasticsearch'; +import { IScopedClusterClient, IUiSettingsClient } from 'src/core/server'; +import { IScopedSearchClient } from 'src/plugins/data/server'; +import { Datatable } from 'src/plugins/expressions/server'; +import { ReportingConfig } from '../../..'; +import { + ES_SEARCH_STRATEGY, + FieldFormat, + FieldFormatConfig, + IFieldFormatsRegistry, + IndexPattern, + ISearchSource, + ISearchStartSearchSource, + SearchFieldValue, + tabifyDocs, +} from '../../../../../../../src/plugins/data/common'; +import { KbnServerError } from '../../../../../../../src/plugins/kibana_utils/server'; +import { CancellationToken } from '../../../../common'; +import { CONTENT_TYPE_CSV } from '../../../../common/constants'; +import { byteSizeValueToNumber } from '../../../../common/schema_utils'; +import { LevelLogger } from '../../../lib'; +import { TaskRunResult } from '../../../lib/tasks'; +import { JobParamsCSV } from '../types'; +import { cellHasFormulas } from './cell_has_formula'; +import { CsvExportSettings, getExportSettings } from './get_export_settings'; +import { MaxSizeStringBuilder } from './max_size_string_builder'; + +interface Clients { + es: IScopedClusterClient; + data: IScopedSearchClient; + uiSettings: IUiSettingsClient; +} + +interface Dependencies { + searchSourceStart: ISearchStartSearchSource; + fieldFormatsRegistry: IFieldFormatsRegistry; +} + +// Function to check if the field name values can be used as the header row +function isPlainStringArray( + fields: SearchFieldValue[] | string | boolean | undefined +): fields is string[] { + let result = true; + if (Array.isArray(fields)) { + fields.forEach((field) => { + if (typeof field !== 'string' || field === '*' || field === '_source') { + result = false; + } + }); + } + return result; +} + +export class CsvGenerator { + private _formatters: Record<string, FieldFormat> | null = null; + private csvContainsFormulas = false; + private maxSizeReached = false; + private csvRowCount = 0; + + constructor( + private job: JobParamsCSV, + private config: ReportingConfig, + private clients: Clients, + private dependencies: Dependencies, + private cancellationToken: CancellationToken, + private logger: LevelLogger + ) {} + + private async scan( + index: IndexPattern, + searchSource: ISearchSource, + scrollSettings: CsvExportSettings['scroll'] + ) { + const searchBody = await searchSource.getSearchRequestBody(); + this.logger.debug(`executing search request`); + const searchParams = { + params: { + body: searchBody, + index: index.title, + scroll: scrollSettings.duration, + size: scrollSettings.size, + }, + }; + const results = ( + await this.clients.data.search(searchParams, { strategy: ES_SEARCH_STRATEGY }).toPromise() + ).rawResponse; + + return results; + } + + private async scroll(scrollId: string, scrollSettings: CsvExportSettings['scroll']) { + this.logger.debug(`executing scroll request`); + const results = ( + await this.clients.es.asCurrentUser.scroll({ + scroll: scrollSettings.duration, + scroll_id: scrollId, + }) + ).body as SearchResponse<unknown>; + return results; + } + + /* + * Load field formats for each field in the list + */ + private getFormatters(table: Datatable) { + if (this._formatters) { + return this._formatters; + } + + // initialize field formats + const formatters: Record<string, FieldFormat> = {}; + table.columns.forEach((c) => { + const fieldFormat = this.dependencies.fieldFormatsRegistry.deserialize(c.meta.params); + formatters[c.id] = fieldFormat; + }); + + this._formatters = formatters; + return this._formatters; + } + + private escapeValues(settings: CsvExportSettings) { + return (value: string) => { + if (settings.checkForFormulas && cellHasFormulas(value)) { + this.csvContainsFormulas = true; // set warning if cell value has a formula + } + return settings.escapeValue(value); + }; + } + + // use fields/fieldsFromSource from the searchSource to get the ordering of columns + // otherwise use the table columns as they are + private getFields(searchSource: ISearchSource, table: Datatable): string[] { + const fieldValues: Record<string, string | boolean | SearchFieldValue[] | undefined> = { + fields: searchSource.getField('fields'), + fieldsFromSource: searchSource.getField('fieldsFromSource'), + }; + const fieldSource = fieldValues.fieldsFromSource ? 'fieldsFromSource' : 'fields'; + this.logger.debug(`Getting search source fields from: '${fieldSource}'`); + + const fields = fieldValues[fieldSource]; + // Check if field name values are string[] and if the fields are user-defined + if (isPlainStringArray(fields)) { + return fields; + } + + // Default to using the table column IDs as the fields + const columnIds = table.columns.map((c) => c.id); + // Fields in the API response don't come sorted - they need to be sorted client-side + columnIds.sort(); + return columnIds; + } + + private formatCellValues(formatters: Record<string, FieldFormat>) { + return ({ + column: tableColumn, + data: dataTableCell, + }: { + column: string; + data: any; + }): string => { + let cell: string[] | string | object; + // check truthiness to guard against _score, _type, etc + if (tableColumn && dataTableCell) { + try { + cell = formatters[tableColumn].convert(dataTableCell); + } catch (err) { + this.logger.error(err); + cell = '-'; + } + + try { + // expected values are a string of JSON where the value(s) is in an array + cell = JSON.parse(cell); + } catch (e) { + // ignore + } + + // We have to strip singular array values out of their array wrapper, + // So that the value appears the visually the same as seen in Discover + if (Array.isArray(cell)) { + cell = cell.map((c) => (typeof c === 'object' ? JSON.stringify(c) : c)).join(', '); + } + + // Check for object-type value (geoip) + if (typeof cell === 'object') { + cell = JSON.stringify(cell); + } + + return cell; + } + + return '-'; // Unknown field: it existed in searchSource but has no value in the result + }; + } + + /* + * Use the list of fields to generate the header row + */ + private generateHeader( + fields: string[], + table: Datatable, + builder: MaxSizeStringBuilder, + settings: CsvExportSettings + ) { + this.logger.debug(`Building CSV header row...`); + const header = fields.map(this.escapeValues(settings)).join(settings.separator) + '\n'; + + if (!builder.tryAppend(header)) { + return { + size: 0, + content: '', + maxSizeReached: true, + warnings: [], + }; + } + } + + /* + * Format a Datatable into rows of CSV content + */ + private generateRows( + fields: string[], + table: Datatable, + builder: MaxSizeStringBuilder, + formatters: Record<string, FieldFormat>, + settings: CsvExportSettings + ) { + this.logger.debug(`Building ${table.rows.length} CSV data rows...`); + for (const dataTableRow of table.rows) { + if (this.cancellationToken.isCancelled()) { + break; + } + + const row = + fields + .map((f) => ({ column: f, data: dataTableRow[f] })) + .map(this.formatCellValues(formatters)) + .map(this.escapeValues(settings)) + .join(settings.separator) + '\n'; + + if (!builder.tryAppend(row)) { + this.logger.warn(`Max Size Reached after ${this.csvRowCount} rows.`); + this.maxSizeReached = true; + if (this.cancellationToken) { + this.cancellationToken.cancel(); + } + break; + } + + this.csvRowCount++; + } + } + + public async generateData(): Promise<TaskRunResult> { + const [settings, searchSource] = await Promise.all([ + getExportSettings( + this.clients.uiSettings, + this.config, + this.job.browserTimezone, + this.logger + ), + this.dependencies.searchSourceStart.create(this.job.searchSource), + ]); + + const index = searchSource.getField('index'); + + if (!index) { + throw new Error(`The search must have a revference to an index pattern!`); + } + + const { maxSizeBytes, bom, escapeFormulaValues, scroll: scrollSettings } = settings; + + const builder = new MaxSizeStringBuilder(byteSizeValueToNumber(maxSizeBytes), bom); + const warnings: string[] = []; + let first = true; + let currentRecord = -1; + let totalRecords = 0; + let scrollId: string | undefined; + + // apply timezone from the job to all date field formatters + try { + index.fields.getByType('date').forEach(({ name }) => { + this.logger.debug(`setting timezone on ${name}`); + const format: FieldFormatConfig = { + ...index.fieldFormatMap[name], + id: index.fieldFormatMap[name]?.id || 'date', // allow id: date_nanos + params: { + ...index.fieldFormatMap[name]?.params, + timezone: settings.timezone, + }, + }; + index.setFieldFormat(name, format); + }); + } catch (err) { + this.logger.error(err); + } + + try { + do { + if (this.cancellationToken.isCancelled()) { + break; + } + let results: SearchResponse<unknown> | undefined; + if (scrollId == null) { + // open a scroll cursor in Elasticsearch + results = await this.scan(index, searchSource, scrollSettings); + scrollId = results?._scroll_id; + if (results.hits?.total != null) { + totalRecords = results.hits.total; + this.logger.debug(`Total search results: ${totalRecords}`); + } + } else { + // use the scroll cursor in Elasticsearch + results = await this.scroll(scrollId, scrollSettings); + } + + if (!results) { + this.logger.warning(`Search results are undefined!`); + break; + } + + let table: Datatable | undefined; + try { + table = tabifyDocs(results, index, { shallow: true, meta: true }); + } catch (err) { + this.logger.error(err); + } + + if (!table) { + break; + } + + const fields = this.getFields(searchSource, table); + + if (first) { + first = false; + this.generateHeader(fields, table, builder, settings); + } + + if (table.rows.length < 1) { + break; // empty report with just the header + } + + const formatters = this.getFormatters(table); + this.generateRows(fields, table, builder, formatters, settings); + + // update iterator + currentRecord += table.rows.length; + } while (currentRecord < totalRecords - 1); + + // Add warnings to be logged + if (this.csvContainsFormulas && escapeFormulaValues) { + warnings.push( + i18n.translate('xpack.reporting.exportTypes.csv.generateCsv.escapedFormulaValues', { + defaultMessage: 'CSV may contain formulas whose values have been escaped', + }) + ); + } + } catch (err) { + this.logger.error(err); + if (err instanceof KbnServerError && err.errBody) { + throw JSON.stringify(err.errBody.error); + } + } finally { + // clear scrollID + if (scrollId) { + this.logger.debug(`executing clearScroll request`); + try { + await this.clients.es.asCurrentUser.clearScroll({ scroll_id: [scrollId] }); + } catch (err) { + this.logger.error(err); + } + } else { + this.logger.warn(`No scrollId to clear!`); + } + } + + const size = builder.getSizeInBytes(); + this.logger.debug( + `Finished generating. Total size in bytes: ${size}. Row count: ${this.csvRowCount}.` + ); + + return { + content: builder.getString(), + content_type: CONTENT_TYPE_CSV, + csv_contains_formulas: this.csvContainsFormulas && !escapeFormulaValues, + max_size_reached: this.maxSizeReached, + size, + warnings, + }; + } +} diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.test.ts new file mode 100644 index 0000000000000..efdb583a89dc8 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + UI_SETTINGS_DATEFORMAT_TZ, + UI_SETTINGS_CSV_QUOTE_VALUES, + UI_SETTINGS_CSV_SEPARATOR, +} from '../../../../common/constants'; +import { IUiSettingsClient } from 'kibana/server'; +import { savedObjectsClientMock, uiSettingsServiceMock } from 'src/core/server/mocks'; +import { + createMockConfig, + createMockConfigSchema, + createMockLevelLogger, +} from '../../../test_helpers'; +import { getExportSettings } from './get_export_settings'; + +describe('getExportSettings', () => { + let uiSettingsClient: IUiSettingsClient; + const config = createMockConfig(createMockConfigSchema({})); + const logger = createMockLevelLogger(); + + beforeEach(() => { + uiSettingsClient = uiSettingsServiceMock + .createStartContract() + .asScopedToClient(savedObjectsClientMock.create()); + uiSettingsClient.get = jest.fn().mockImplementation((key: string) => { + switch (key) { + case UI_SETTINGS_CSV_QUOTE_VALUES: + return true; + case UI_SETTINGS_CSV_SEPARATOR: + return ','; + case UI_SETTINGS_DATEFORMAT_TZ: + return 'Browser'; + } + + return 'helo world'; + }); + }); + + test('getExportSettings: returns the expected result', async () => { + expect(await getExportSettings(uiSettingsClient, config, '', logger)).toMatchInlineSnapshot(` + Object { + "bom": "", + "checkForFormulas": undefined, + "escapeFormulaValues": undefined, + "escapeValue": [Function], + "maxSizeBytes": undefined, + "scroll": Object { + "duration": undefined, + "size": undefined, + }, + "separator": ",", + "timezone": "UTC", + } + `); + }); + + test('escapeValue function', async () => { + const { escapeValue } = await getExportSettings(uiSettingsClient, config, '', logger); + expect(escapeValue(`test`)).toBe(`test`); + expect(escapeValue(`this is, a test`)).toBe(`"this is, a test"`); + expect(escapeValue(`"tet"`)).toBe(`"""tet"""`); + expect(escapeValue(`@foo`)).toBe(`"@foo"`); + }); + + test('non-default timezone', async () => { + uiSettingsClient.get = jest.fn().mockImplementation((key: string) => { + switch (key) { + case UI_SETTINGS_DATEFORMAT_TZ: + return `America/Aruba`; + } + }); + + expect( + await getExportSettings(uiSettingsClient, config, '', logger).then(({ timezone }) => timezone) + ).toBe(`America/Aruba`); + }); +}); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.ts new file mode 100644 index 0000000000000..17a10f3034242 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/get_export_settings.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ByteSizeValue } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from 'kibana/server'; +import { ReportingConfig } from '../../../'; +import { + CSV_BOM_CHARS, + UI_SETTINGS_DATEFORMAT_TZ, + UI_SETTINGS_CSV_QUOTE_VALUES, + UI_SETTINGS_CSV_SEPARATOR, +} from '../../../../common/constants'; +import { LevelLogger } from '../../../lib'; +import { createEscapeValue } from './escape_value'; + +export interface CsvExportSettings { + timezone: string; + scroll: { + size: number; + duration: string; + }; + bom: string; + separator: string; + maxSizeBytes: number | ByteSizeValue; + checkForFormulas: boolean; + escapeFormulaValues: boolean; + escapeValue: (value: string) => string; +} + +export const getExportSettings = async ( + client: IUiSettingsClient, + config: ReportingConfig, + timezone: string | undefined, + logger: LevelLogger +): Promise<CsvExportSettings> => { + // Timezone + let setTimezone: string; + // timezone in job params? + if (timezone) { + setTimezone = timezone; + } else { + // timezone in settings? + setTimezone = await client.get(UI_SETTINGS_DATEFORMAT_TZ); + if (setTimezone === 'Browser') { + // if `Browser`, hardcode it to 'UTC' so the export has data that makes sense + logger.warn( + i18n.translate('xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting', { + defaultMessage: + 'Kibana Advanced Setting "{dateFormatTimezone}" is set to "Browser". Dates will be formatted as UTC to avoid ambiguity.', + values: { dateFormatTimezone: 'dateFormat:tz' }, + }) + ); + setTimezone = 'UTC'; + } + } + + // Separator, QuoteValues + const [separator, quoteValues] = await Promise.all([ + client.get(UI_SETTINGS_CSV_SEPARATOR), + client.get(UI_SETTINGS_CSV_QUOTE_VALUES), + ]); + + const escapeFormulaValues = config.get('csv', 'escapeFormulaValues'); + const escapeValue = createEscapeValue(quoteValues, escapeFormulaValues); + const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : ''; + + return { + timezone: setTimezone, + scroll: { + size: config.get('csv', 'scroll', 'size'), + duration: config.get('csv', 'scroll', 'duration'), + }, + bom, + separator, + maxSizeBytes: config.get('csv', 'maxSizeBytes'), + checkForFormulas: config.get('csv', 'checkForFormulas'), + escapeFormulaValues, + escapeValue, + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/index.ts new file mode 100644 index 0000000000000..4e08ff2a222dc --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CsvGenerator } from './generate_csv'; diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/max_size_string_builder.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/max_size_string_builder.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/generate_csv/max_size_string_builder.test.ts rename to x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/max_size_string_builder.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/max_size_string_builder.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/max_size_string_builder.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/generate_csv/max_size_string_builder.ts rename to x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/max_size_string_builder.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/index.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/index.ts new file mode 100644 index 0000000000000..65126a0a62cb8 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/index.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CSV_JOB_TYPE as jobType, + LICENSE_TYPE_BASIC, + LICENSE_TYPE_ENTERPRISE, + LICENSE_TYPE_GOLD, + LICENSE_TYPE_PLATINUM, + LICENSE_TYPE_STANDARD, + LICENSE_TYPE_TRIAL, +} from '../../../common/constants'; +import { CreateJobFn, ExportTypeDefinition, RunTaskFn } from '../../types'; +import { createJobFnFactory } from './create_job'; +import { runTaskFnFactory } from './execute_job'; +import { metadata } from './metadata'; +import { JobParamsCSV, TaskPayloadCSV } from './types'; + +export const getExportType = (): ExportTypeDefinition< + CreateJobFn<JobParamsCSV>, + RunTaskFn<TaskPayloadCSV> +> => ({ + ...metadata, + jobType, + jobContentExtension: 'csv', + createJobFnFactory, + runTaskFnFactory, + validLicenses: [ + LICENSE_TYPE_TRIAL, + LICENSE_TYPE_BASIC, + LICENSE_TYPE_STANDARD, + LICENSE_TYPE_GOLD, + LICENSE_TYPE_PLATINUM, + LICENSE_TYPE_ENTERPRISE, + ], +}); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/metadata.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/metadata.ts similarity index 65% rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/metadata.ts rename to x-pack/plugins/reporting/server/export_types/csv_searchsource/metadata.ts index 76bf106acb5de..187d64d872a9d 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/metadata.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/metadata.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; +import { CSV_JOB_TYPE } from '../../../common/constants'; export const metadata = { - id: CSV_FROM_SAVEDOBJECT_JOB_TYPE, - name: CSV_FROM_SAVEDOBJECT_JOB_TYPE, + id: 'csv_searchsource', + name: CSV_JOB_TYPE, }; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/types.d.ts new file mode 100644 index 0000000000000..f0ad4e00ebd5c --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/types.d.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BaseParams, BasePayload } from '../../types'; + +export type RawValue = string | object | null | undefined; + +interface BaseParamsCSV { + browserTimezone: string; + searchSource: any; +} + +export type JobParamsCSV = BaseParamsCSV & BaseParams; +export type TaskPayloadCSV = BaseParamsCSV & BasePayload; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.ts new file mode 100644 index 0000000000000..c8475e85bd847 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/execute_job.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 { KibanaRequest } from 'src/core/server'; +import { CancellationToken } from '../../../common'; +import { CSV_SEARCHSOURCE_IMMEDIATE_TYPE } from '../../../common/constants'; +import { TaskRunResult } from '../../lib/tasks'; +import { getFieldFormats } from '../../services'; +import { ReportingRequestHandlerContext, RunTaskFnFactory } from '../../types'; +import { CsvGenerator } from '../csv_searchsource/generate_csv/generate_csv'; +import { JobParamsDownloadCSV } from './types'; + +/* + * ImmediateExecuteFn receives the job doc payload because the payload was + * generated in the ScheduleFn + */ +export type ImmediateExecuteFn = ( + jobId: null, + job: JobParamsDownloadCSV, + context: ReportingRequestHandlerContext, + req: KibanaRequest +) => Promise<TaskRunResult>; + +export const runTaskFnFactory: RunTaskFnFactory<ImmediateExecuteFn> = function executeJobFactoryFn( + reporting, + parentLogger +) { + const config = reporting.getConfig(); + const logger = parentLogger.clone([CSV_SEARCHSOURCE_IMMEDIATE_TYPE, 'execute-job']); + + return async function runTask(jobId, immediateJobParams, context, req) { + const job = { + objectType: 'immediate-search', + ...immediateJobParams, + }; + + const savedObjectsClient = context.core.savedObjects.client; + const uiSettings = await reporting.getUiSettingsServiceFactory(savedObjectsClient); + const dataPluginStart = await reporting.getDataService(); + const fieldFormatsRegistry = await getFieldFormats().fieldFormatServiceFactory(uiSettings); + + const [es, searchSourceStart] = await Promise.all([ + (await reporting.getEsClient()).asScoped(req), + await dataPluginStart.search.searchSource.asScoped(req), + ]); + const clients = { + uiSettings, + data: dataPluginStart.search.asScoped(req), + es, + }; + const dependencies = { + fieldFormatsRegistry, + searchSourceStart, + }; + const cancellationToken = new CancellationToken(); + + const csv = new CsvGenerator(job, config, clients, dependencies, cancellationToken, logger); + const result = await csv.generateData(); + + if (result.csv_contains_formulas) { + logger.warn(`CSV may contain formulas whose values have been escaped`); + } + + if (result.max_size_reached) { + logger.warn(`Max size reached: CSV output truncated to ${result.size} bytes`); + } + + const { warnings } = result; + if (warnings) { + warnings.forEach((warning) => { + logger.warning(warning); + }); + } + + return result; + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/index.ts similarity index 75% rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts rename to x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/index.ts index c3a0df9529a4d..9d915db4797b3 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/index.ts @@ -6,7 +6,7 @@ */ import { - CSV_FROM_SAVEDOBJECT_JOB_TYPE, + CSV_SEARCHSOURCE_IMMEDIATE_TYPE, LICENSE_TYPE_BASIC, LICENSE_TYPE_ENTERPRISE, LICENSE_TYPE_GOLD, @@ -15,7 +15,6 @@ import { LICENSE_TYPE_TRIAL, } from '../../../common/constants'; import { ExportTypeDefinition } from '../../types'; -import { createJobFnFactory, ImmediateCreateJobFn } from './create_job'; import { ImmediateExecuteFn, runTaskFnFactory } from './execute_job'; import { metadata } from './metadata'; @@ -23,17 +22,13 @@ import { metadata } from './metadata'; * These functions are exported to share with the API route handler that * generates csv from saved object immediately on request. */ -export { createJobFnFactory } from './create_job'; export { runTaskFnFactory } from './execute_job'; -export const getExportType = (): ExportTypeDefinition< - ImmediateCreateJobFn, - ImmediateExecuteFn -> => ({ +export const getExportType = (): ExportTypeDefinition<null, ImmediateExecuteFn> => ({ ...metadata, - jobType: CSV_FROM_SAVEDOBJECT_JOB_TYPE, + jobType: CSV_SEARCHSOURCE_IMMEDIATE_TYPE, jobContentExtension: 'csv', - createJobFnFactory, + createJobFnFactory: null, runTaskFnFactory, validLicenses: [ LICENSE_TYPE_TRIAL, diff --git a/x-pack/plugins/security_solution/scripts/unoptimize_tsconfig.js b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/metadata.ts similarity index 55% rename from x-pack/plugins/security_solution/scripts/unoptimize_tsconfig.js rename to x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/metadata.ts index f7f6e7936fbbc..c27b8484697dd 100644 --- a/x-pack/plugins/security_solution/scripts/unoptimize_tsconfig.js +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/metadata.ts @@ -5,10 +5,9 @@ * 2.0. */ -const { unoptimizeTsConfig } = require('./optimize_tsconfig/unoptimize'); +import { CSV_SEARCHSOURCE_IMMEDIATE_TYPE } from '../../../common/constants'; -unoptimizeTsConfig().catch((err) => { - console.error(err); - // eslint-disable-next-line no-process-exit - process.exit(1); -}); +export const metadata = { + id: CSV_SEARCHSOURCE_IMMEDIATE_TYPE, + name: CSV_SEARCHSOURCE_IMMEDIATE_TYPE, +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/types.d.ts new file mode 100644 index 0000000000000..276016dd61233 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/types.d.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TimeRangeParams } from '../common'; + +export interface FakeRequest { + headers: Record<string, string>; +} + +export interface JobParamsDownloadCSV { + browserTimezone: string; + title: string; + searchSource: any; +} + +export interface SavedObjectServiceError { + statusCode: number; + error?: string; + message?: string; +} diff --git a/x-pack/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/plugins/reporting/server/lib/enqueue_job.ts index 5ac644298796d..b0e5d7bafb03c 100644 --- a/x-pack/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/plugins/reporting/server/lib/enqueue_job.ts @@ -38,13 +38,17 @@ export function enqueueJobFactory( throw new Error(`Export type ${exportTypeId} does not exist in the registry!`); } + if (!exportType.createJobFnFactory) { + throw new Error(`Export type ${exportTypeId} is not an async job type!`); + } + const [createJob, store] = await Promise.all([ exportType.createJobFnFactory(reporting, logger.clone([exportType.id])), reporting.getStore(), ]); const config = reporting.getConfig(); - const job = await createJob(jobParams, context, request); + const job = await createJob!(jobParams, context, request); // 1. Add the report to ReportingStore to show as pending const report = await store.addReport( diff --git a/x-pack/plugins/reporting/server/lib/export_types_registry.ts b/x-pack/plugins/reporting/server/lib/export_types_registry.ts index 5502692306319..890af43297751 100644 --- a/x-pack/plugins/reporting/server/lib/export_types_registry.ts +++ b/x-pack/plugins/reporting/server/lib/export_types_registry.ts @@ -6,8 +6,9 @@ */ import { isString } from 'lodash'; -import { getExportType as getTypeCsv } from '../export_types/csv'; -import { getExportType as getTypeCsvFromSavedObject } from '../export_types/csv_from_savedobject'; +import { getExportType as getTypeCsvDeprecated } from '../export_types/csv'; +import { getExportType as getTypeCsvFromSavedObject } from '../export_types/csv_searchsource_immediate'; +import { getExportType as getTypeCsv } from '../export_types/csv_searchsource'; import { getExportType as getTypePng } from '../export_types/png'; import { getExportType as getTypePrintablePdf } from '../export_types/printable_pdf'; import { CreateJobFn, ExportTypeDefinition } from '../types'; @@ -82,8 +83,9 @@ export function getExportTypesRegistry(): ExportTypesRegistry { const registry = new ExportTypesRegistry(); type CreateFnType = CreateJobFn<any, any>; // can not specify params types because different type of params are not assignable to each other type RunFnType = any; // can not specify because ImmediateExecuteFn is not assignable to RunTaskFn - const getTypeFns: Array<() => ExportTypeDefinition<CreateFnType, RunFnType>> = [ + const getTypeFns: Array<() => ExportTypeDefinition<CreateFnType | null, RunFnType>> = [ getTypeCsv, + getTypeCsvDeprecated, getTypeCsvFromSavedObject, getTypePng, getTypePrintablePdf, diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index e910fecb76988..3dc7e7ef3df92 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -5,21 +5,22 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; -import { i18n } from '@kbn/i18n'; -import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server'; -import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from '../common/constants'; +import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server'; +import { PLUGIN_ID } from '../common/constants'; import { ReportingCore } from './'; import { initializeBrowserDriverFactory } from './browsers'; -import { buildConfig, ReportingConfigType } from './config'; +import { buildConfig, registerUiSettings, ReportingConfigType } from './config'; import { LevelLogger, ReportingStore } from './lib'; import { registerRoutes } from './routes'; import { setFieldFormats } from './services'; -import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; +import type { + ReportingRequestHandlerContext, + ReportingSetup, + ReportingSetupDeps, + ReportingStart, + ReportingStartDeps, +} from './types'; import { registerReportingUsageCollector } from './usage'; -import type { ReportingRequestHandlerContext } from './types'; - -const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); export class ReportingPlugin implements Plugin<ReportingSetup, ReportingStart, ReportingSetupDeps, ReportingStartDeps> { @@ -44,28 +45,7 @@ export class ReportingPlugin } }); - core.uiSettings.register({ - [UI_SETTINGS_CUSTOM_PDF_LOGO]: { - name: i18n.translate('xpack.reporting.pdfFooterImageLabel', { - defaultMessage: 'PDF footer image', - }), - value: null, - description: i18n.translate('xpack.reporting.pdfFooterImageDescription', { - defaultMessage: `Custom image to use in the PDF's footer`, - }), - sensitive: true, - type: 'image', - schema: schema.nullable(schema.byteSize({ max: '200kb' })), - category: [PLUGIN_ID], - // Used client-side for size validation - validation: { - maxSize: { - length: kbToBase64Length(200), - description: '200 kB', - }, - }, - }, - }); + registerUiSettings(core); const { elasticsearch, http } = core; const { features, licensing, security, spaces, taskManager } = plugins; @@ -122,6 +102,8 @@ export class ReportingPlugin savedObjects: core.savedObjects, uiSettings: core.uiSettings, store, + esClient: core.elasticsearch.client, + data: plugins.data, taskManager: plugins.taskManager, }); diff --git a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts similarity index 55% rename from x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts rename to x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts index 6d000cffb9195..55092b5236ce6 100644 --- a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts @@ -8,26 +8,17 @@ import { schema } from '@kbn/config-schema'; import { KibanaRequest } from 'src/core/server'; import { ReportingCore } from '../'; -import { createJobFnFactory } from '../export_types/csv_from_savedobject/create_job'; -import { runTaskFnFactory } from '../export_types/csv_from_savedobject/execute_job'; -import { - JobParamsPanelCsv, - JobParamsPanelCsvPost, -} from '../export_types/csv_from_savedobject/types'; +import { runTaskFnFactory } from '../export_types/csv_searchsource_immediate/execute_job'; +import { JobParamsDownloadCSV } from '../export_types/csv_searchsource_immediate/types'; import { LevelLogger as Logger } from '../lib'; import { TaskRunResult } from '../lib/tasks'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; -import { getJobParamsFromRequest } from './lib/get_job_params_from_request'; import { HandlerErrorFunction } from './types'; const API_BASE_URL_V1 = '/api/reporting/v1'; const API_BASE_GENERATE_V1 = `${API_BASE_URL_V1}/generate`; -export type CsvFromSavedObjectRequest = KibanaRequest< - JobParamsPanelCsv, - unknown, - JobParamsPanelCsvPost ->; +export type CsvFromSavedObjectRequest = KibanaRequest<unknown, unknown, JobParamsDownloadCSV>; /* * This function registers API Endpoints for immediate Reporting jobs. The API inputs are: @@ -47,43 +38,28 @@ export function registerGenerateCsvFromSavedObjectImmediate( const userHandler = authorizedUserPreRoutingFactory(reporting); const { router } = setupDeps; - /* - * CSV export with the `immediate` option does not queue a job with Reporting's ESQueue to run the job async. Instead, this does: - * - re-use the createJob function to build up es query config - * - re-use the runTask function to run the scan and scroll queries and capture the entire CSV in a result object. - */ + // This API calls run the SearchSourceImmediate export type's runTaskFn directly router.post( { - path: `${API_BASE_GENERATE_V1}/immediate/csv/saved-object/{savedObjectType}:{savedObjectId}`, + path: `${API_BASE_GENERATE_V1}/immediate/csv_searchsource`, validate: { - params: schema.object({ - savedObjectType: schema.string({ minLength: 5 }), - savedObjectId: schema.string({ minLength: 5 }), - }), body: schema.object({ - state: schema.object({}, { unknowns: 'allow' }), - timerange: schema.object({ - timezone: schema.string({ defaultValue: 'UTC' }), - min: schema.nullable(schema.oneOf([schema.number(), schema.string({ minLength: 5 })])), - max: schema.nullable(schema.oneOf([schema.number(), schema.string({ minLength: 5 })])), - }), + searchSource: schema.object({}, { unknowns: 'allow' }), + browserTimezone: schema.string({ defaultValue: 'UTC' }), + title: schema.string(), }), }, }, userHandler(async (user, context, req: CsvFromSavedObjectRequest, res) => { - const logger = parentLogger.clone(['savedobject-csv']); - const jobParams = getJobParamsFromRequest(req); - const createJob = createJobFnFactory(reporting, logger); + const logger = parentLogger.clone(['csv_searchsource_immediate']); const runTaskFn = runTaskFnFactory(reporting, logger); try { - // FIXME: no create job for immediate download - const payload = await createJob(jobParams, context, req); const { content_type: jobOutputContentType, content: jobOutputContent, size: jobOutputSize, - }: TaskRunResult = await runTaskFn(null, payload, context, req); + }: TaskRunResult = await runTaskFn(null, req.body, context, req); logger.info(`Job output size: ${jobOutputSize} bytes`); diff --git a/x-pack/plugins/reporting/server/routes/generation.ts b/x-pack/plugins/reporting/server/routes/generation.ts index 3edd898609f8c..5c9fd25b76c39 100644 --- a/x-pack/plugins/reporting/server/routes/generation.ts +++ b/x-pack/plugins/reporting/server/routes/generation.ts @@ -13,7 +13,7 @@ import { API_BASE_URL } from '../../common/constants'; import { LevelLogger as Logger } from '../lib'; import { enqueueJobFactory } from '../lib/enqueue_job'; import { registerGenerateFromJobParams } from './generate_from_jobparams'; -import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; +import { registerGenerateCsvFromSavedObjectImmediate } from './csv_searchsource_immediate'; import { HandlerFunction } from './types'; const esErrors = elasticsearchErrors as Record<string, any>; diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index 26f1a64a7ef63..4e8e888e4e266 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -8,7 +8,7 @@ // @ts-ignore import contentDisposition from 'content-disposition'; import { get } from 'lodash'; -import { CSV_JOB_TYPE_DEPRECATED } from '../../../common/constants'; +import { CSV_JOB_TYPE, CSV_JOB_TYPE_DEPRECATED } from '../../../common/constants'; import { ExportTypesRegistry, statuses } from '../../lib'; import { ReportDocument } from '../../lib/store'; import { TaskRunResult } from '../../lib/tasks'; @@ -34,7 +34,7 @@ const getTitle = (exportType: ExportTypeDefinition, title?: string): string => const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeDefinition) => { const metaDataHeaders: Record<string, boolean> = {}; - if (exportType.jobType === CSV_JOB_TYPE_DEPRECATED) { + if (exportType.jobType === CSV_JOB_TYPE || exportType.jobType === CSV_JOB_TYPE_DEPRECATED) { const csvContainsFormulas = get(output, 'csv_contains_formulas', false); const maxSizedReach = get(output, 'max_size_reached', false); diff --git a/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts deleted file mode 100644 index 8dce491e3df09..0000000000000 --- a/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { JobParamsPanelCsv } from '../../export_types/csv_from_savedobject/types'; -import { CsvFromSavedObjectRequest } from '../generate_from_savedobject_immediate'; - -export function getJobParamsFromRequest(request: CsvFromSavedObjectRequest): JobParamsPanelCsv { - const { savedObjectType, savedObjectId } = request.params; - const { timerange, state } = request.body; - - const post = timerange || state ? { timerange, state } : undefined; - - return { - savedObjectType, - savedObjectId, - post, - }; -} diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index 0700fbaff0fe3..e42d87c50e118 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -11,7 +11,10 @@ jest.mock('../browsers'); import _ from 'lodash'; import * as Rx from 'rxjs'; -import { coreMock } from 'src/core/server/mocks'; +import { coreMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { fieldFormats } from 'src/plugins/data/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { dataPluginMock } from 'src/plugins/data/server/mocks'; import { ReportingConfig, ReportingCore } from '../'; import { featuresPluginMock } from '../../../features/server/mocks'; import { @@ -22,6 +25,7 @@ import { import { ReportingConfigType } from '../config'; import { ReportingInternalSetup, ReportingInternalStart } from '../core'; import { ReportingStore } from '../lib'; +import { setFieldFormats } from '../services'; import { createMockLevelLogger } from './create_mock_levellogger'; (initializeBrowserDriverFactory as jest.Mock< @@ -45,15 +49,22 @@ export const createMockPluginSetup = (setupMock?: any): ReportingInternalSetup = const logger = createMockLevelLogger(); -const createMockPluginStart = ( - mockReportingCore: ReportingCore, +const createMockReportingStore = () => ({} as ReportingStore); + +export const createMockPluginStart = ( + mockReportingCore: ReportingCore | undefined, startMock?: any ): ReportingInternalStart => { - const store = new ReportingStore(mockReportingCore, logger); + const store = mockReportingCore + ? new ReportingStore(mockReportingCore, logger) + : createMockReportingStore(); + return { browserDriverFactory: startMock.browserDriverFactory, + esClient: elasticsearchServiceMock.createClusterClient(), savedObjects: startMock.savedObjects || { getScopedClient: jest.fn() }, uiSettings: startMock.uiSettings || { asScopedToClient: () => ({ get: jest.fn() }) }, + data: startMock.data || dataPluginMock.createStartContract(), store, taskManager: { schedule: jest.fn().mockImplementation(() => ({ id: 'taskId' })), @@ -124,11 +135,18 @@ export const createMockReportingCore = async ( setupDepsMock: ReportingInternalSetup | undefined = undefined, startDepsMock: ReportingInternalStart | undefined = undefined ) => { - config = config || {}; + const mockReportingCore = ({ + getConfig: () => config, + getElasticsearchService: () => setupDepsMock?.elasticsearch, + getDataService: () => startDepsMock?.data, + } as unknown) as ReportingCore; if (!setupDepsMock) { setupDepsMock = createMockPluginSetup({}); } + if (!startDepsMock) { + startDepsMock = createMockPluginStart(mockReportingCore, {}); + } const context = coreMock.createPluginInitializerContext(createMockConfigSchema()); const core = new ReportingCore(logger, context); @@ -143,5 +161,12 @@ export const createMockReportingCore = async ( await core.pluginStart(startDepsMock); await core.pluginStartsUp(); + setFieldFormats({ + fieldFormatServiceFactory() { + const fieldFormatsRegistry = new fieldFormats.FieldFormatsRegistry(); + return Promise.resolve(fieldFormatsRegistry); + }, + }); + return core; }; diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index 1b762c96079fa..2a9cbaeaa6755 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -83,13 +83,16 @@ export type RunTaskFnFactory<RunTaskFnType> = ( logger: LevelLogger ) => RunTaskFnType; -export interface ExportTypeDefinition<CreateJobFnType = CreateJobFn, RunTaskFnType = RunTaskFn> { +export interface ExportTypeDefinition< + CreateJobFnType = CreateJobFn | null, + RunTaskFnType = RunTaskFn +> { id: string; name: string; jobType: string; jobContentEncoding?: string; jobContentExtension: string; - createJobFnFactory: CreateJobFnFactory<CreateJobFnType>; + createJobFnFactory: CreateJobFnFactory<CreateJobFnType> | null; // immediate job does not have a "create" phase runTaskFnFactory: RunTaskFnFactory<RunTaskFnType>; validLicenses: string[]; } diff --git a/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap b/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap index ed2637d7a1bcb..150154fa996c5 100644 --- a/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap +++ b/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap @@ -13,6 +13,10 @@ Object { "available": true, "total": 0, }, + "csv_searchsource": Object { + "available": true, + "total": 0, + }, "enabled": true, "last7Days": Object { "PNG": Object { @@ -24,6 +28,10 @@ Object { "available": true, "total": 0, }, + "csv_searchsource": Object { + "available": true, + "total": 0, + }, "printable_pdf": Object { "app": Object { "dashboard": 0, @@ -75,6 +83,10 @@ Object { "available": true, "total": 0, }, + "csv_searchsource": Object { + "available": true, + "total": 0, + }, "enabled": true, "last7Days": Object { "PNG": Object { @@ -86,6 +98,10 @@ Object { "available": true, "total": 0, }, + "csv_searchsource": Object { + "available": true, + "total": 0, + }, "printable_pdf": Object { "app": Object { "dashboard": 0, @@ -166,6 +182,10 @@ Object { "available": true, "total": 1, }, + "csv_searchsource": Object { + "available": true, + "total": 0, + }, "enabled": true, "last7Days": Object { "PNG": Object { @@ -177,6 +197,10 @@ Object { "available": true, "total": 1, }, + "csv_searchsource": Object { + "available": true, + "total": 0, + }, "printable_pdf": Object { "app": Object { "canvas workpad": 1, diff --git a/x-pack/plugins/reporting/server/usage/decorate_range_stats.ts b/x-pack/plugins/reporting/server/usage/decorate_range_stats.ts index a750e9e196b20..9fc0141ab742e 100644 --- a/x-pack/plugins/reporting/server/usage/decorate_range_stats.ts +++ b/x-pack/plugins/reporting/server/usage/decorate_range_stats.ts @@ -6,7 +6,12 @@ */ import { uniq } from 'lodash'; -import { CSV_JOB_TYPE_DEPRECATED, PDF_JOB_TYPE, PNG_JOB_TYPE } from '../../common/constants'; +import { + CSV_JOB_TYPE, + CSV_JOB_TYPE_DEPRECATED, + PDF_JOB_TYPE, + PNG_JOB_TYPE, +} from '../../common/constants'; import { AvailableTotal, ExportType, FeatureAvailabilityMap, RangeStats } from './types'; function getForFeature( @@ -55,6 +60,7 @@ export const decorateRangeStats = ( // combine the known types with any unknown type found in reporting data const keysBasic = uniq([ + CSV_JOB_TYPE, CSV_JOB_TYPE_DEPRECATED, PNG_JOB_TYPE, ...Object.keys(rangeStatsBasic), diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts index 9335fee766740..05b80bc8acc75 100644 --- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts +++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts @@ -354,11 +354,13 @@ describe('data modeling', () => { available: true, browser_type: 'chromium', csv: { available: true, total: 4 }, + csv_searchsource: { available: true, total: 4 }, enabled: true, last7Days: { PNG: { available: true, total: 0 }, _all: 0, csv: { available: true, total: 0 }, + csv_searchsource: { available: true, total: 0 }, printable_pdf: { app: { dashboard: 0, visualization: 0 }, available: true, @@ -389,11 +391,13 @@ describe('data modeling', () => { available: true, browser_type: 'chromium', csv: { available: true, total: 0 }, + csv_searchsource: { available: true, total: 0 }, enabled: true, last7Days: { PNG: { available: true, total: 3 }, _all: 4, csv: { available: true, total: 0 }, + csv_searchsource: { available: true, total: 0 }, printable_pdf: { app: { 'canvas workpad': 1, dashboard: 0, visualization: 0 }, available: true, @@ -431,6 +435,7 @@ describe('data modeling', () => { layout: { preserve_layout: 0, print: 0 }, }, csv: { available: true, total: 0 }, + csv_searchsource: { available: true, total: 0 }, PNG: { available: true, total: 0 }, }, _all: 0, @@ -443,6 +448,7 @@ describe('data modeling', () => { layout: { preserve_layout: 0, print: 0 }, }, csv: { available: true, total: 0 }, + csv_searchsource: { available: true, total: 0 }, PNG: { available: true, total: 0 }, }); }); @@ -491,6 +497,14 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "available": Object { + "type": "boolean", + }, + "total": Object { + "type": "long", + }, + }, "enabled": Object { "type": "boolean", }, @@ -514,6 +528,14 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "available": Object { + "type": "boolean", + }, + "total": Object { + "type": "long", + }, + }, "printable_pdf": Object { "app": Object { "canvas workpad": Object { @@ -585,6 +607,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -620,6 +653,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -655,6 +699,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -690,6 +745,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -725,6 +791,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -760,6 +837,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -845,6 +933,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -880,6 +979,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -915,6 +1025,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -950,6 +1071,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -985,6 +1117,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", @@ -1020,6 +1163,17 @@ describe('Ready for collection observable', () => { "type": "long", }, }, + "csv_searchsource": Object { + "canvas workpad": Object { + "type": "long", + }, + "dashboard": Object { + "type": "long", + }, + "visualization": Object { + "type": "long", + }, + }, "printable_pdf": Object { "canvas workpad": Object { "type": "long", diff --git a/x-pack/plugins/reporting/server/usage/schema.ts b/x-pack/plugins/reporting/server/usage/schema.ts index ec15ae4b1ac47..8528543b09e07 100644 --- a/x-pack/plugins/reporting/server/usage/schema.ts +++ b/x-pack/plugins/reporting/server/usage/schema.ts @@ -16,6 +16,7 @@ const appCountsSchema: MakeSchemaFrom<AppCounts> = { const byAppCountsSchema: MakeSchemaFrom<RangeStats['statuses']['cancelled']> = { csv: appCountsSchema, + csv_searchsource: appCountsSchema, PNG: appCountsSchema, printable_pdf: appCountsSchema, }; @@ -27,6 +28,7 @@ const availableTotalSchema: MakeSchemaFrom<AvailableTotal> = { const jobTypesSchema: MakeSchemaFrom<JobTypes> = { csv: availableTotalSchema, + csv_searchsource: availableTotalSchema, PNG: availableTotalSchema, printable_pdf: { ...availableTotalSchema, diff --git a/x-pack/plugins/reporting/server/usage/types.ts b/x-pack/plugins/reporting/server/usage/types.ts index 5970df6ccae43..58def60a24ccb 100644 --- a/x-pack/plugins/reporting/server/usage/types.ts +++ b/x-pack/plugins/reporting/server/usage/types.ts @@ -59,7 +59,7 @@ export interface AvailableTotal { total: number; } -type BaseJobTypes = 'csv' | 'PNG' | 'printable_pdf'; +type BaseJobTypes = 'csv' | 'csv_searchsource' | 'PNG' | 'printable_pdf'; export interface LayoutCounts { print: number; preserve_layout: number; @@ -106,7 +106,7 @@ export type ReportingUsageType = RangeStats & { last7Days: RangeStats; }; -export type ExportType = 'csv' | 'printable_pdf' | 'PNG'; +export type ExportType = 'csv' | 'csv_searchsource' | 'printable_pdf' | 'PNG'; export type FeatureAvailabilityMap = { [F in ExportType]: boolean }; export interface KeyCountBucket { diff --git a/x-pack/plugins/rollup/server/plugin.ts b/x-pack/plugins/rollup/server/plugin.ts index 5622b8b4ed320..1b982ab45205d 100644 --- a/x-pack/plugins/rollup/server/plugin.ts +++ b/x-pack/plugins/rollup/server/plugin.ts @@ -113,11 +113,11 @@ export class RollupPlugin implements Plugin<void, void, any, any> { value: true, description: i18n.translate('xpack.rollupJobs.rollupIndexPatternsDescription', { defaultMessage: `Enable the creation of index patterns which capture rollup indices, - which in turn enable visualizations based on rollup data. Refresh - the page to apply the changes.`, + which in turn enable visualizations based on rollup data.`, }), category: ['rollups'], schema: schema.boolean(), + requiresPageReload: true, }, }); diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 143384d160471..47606983b8368 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -23,6 +23,8 @@ export const DEFAULT_REFRESH_RATE_INTERVAL = 'timepicker:refreshIntervalDefaults export const DEFAULT_APP_TIME_RANGE = 'securitySolution:timeDefaults'; export const DEFAULT_APP_REFRESH_INTERVAL = 'securitySolution:refreshIntervalDefaults'; export const DEFAULT_SIGNALS_INDEX = '.siem-signals'; +// The DEFAULT_MAX_SIGNALS value exists also in `x-pack/plugins/cases/common/constants.ts` +// If either changes, engineer should ensure both values are updated export const DEFAULT_MAX_SIGNALS = 100; export const DEFAULT_SEARCH_AFTER_PAGE_SIZE = 100; export const DEFAULT_ANOMALY_SCORE = 'securitySolution:defaultAnomalyScore'; @@ -206,3 +208,10 @@ export const showAllOthersBucket: string[] = [ 'destination.ip', 'user.name', ]; + +/* + Feature Flag for Cases RAC UI + DO NOT MERGE to master as true, dev only +*/ + +export const USE_RAC_CASES_UI = false; diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index d4551f76ae390..50a5f62740271 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -7,6 +7,7 @@ "requiredPlugins": [ "actions", "alerting", + "cases", "data", "dataEnhanced", "embeddable", diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx index 9c06fc032f819..6ffce4f2af454 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx @@ -13,7 +13,7 @@ import { noop } from 'lodash/fp'; import { TestProviders } from '../../../common/mock'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; -import { CommentRequest, CommentType } from '../../../../../cases/common/api'; +import { CommentRequest, CommentType } from '../../../../../cases/common'; import { useInsertTimeline } from '../use_insert_timeline'; import { usePostComment } from '../../containers/use_post_comment'; import { AddComment, AddCommentRefObject } from '.'; diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index acd27e99a857f..ff5ef11fd923f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -9,10 +9,10 @@ import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback, forwardRef, useImperativeHandle } from 'react'; import styled from 'styled-components'; -import { CommentType } from '../../../../../cases/common/api'; +import { CommentType } from '../../../../../cases/common'; import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; -import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; +import { MarkdownEditorForm } from '../../../common/components/markdown_editor'; import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx index 2cf7d3c6c555b..bf625fc065089 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { CommentRequestUserType } from '../../../../../cases/common/api'; +import { CommentRequestUserType } from '../../../../../cases/common'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx index daa988641fbab..0353f48e6ee38 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx @@ -8,7 +8,7 @@ import { Dispatch } from 'react'; import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { Case, SubCase } from '../../containers/types'; import { UpdateCase } from '../../containers/use_get_cases'; import { statuses } from '../status'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx index 86f854fd0a145..079943d8cbd3b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx @@ -19,7 +19,7 @@ import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; -import { CaseStatuses, CaseType } from '../../../../../cases/common/api'; +import { CaseStatuses, CaseType } from '../../../../../cases/common'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { Case, SubCase } from '../../containers/types'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx index 43f0d9df49e94..f40e159306e92 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx @@ -10,7 +10,7 @@ import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui'; import styled from 'styled-components'; import { Case, SubCase } from '../../containers/types'; import { CasesColumns } from './columns'; -import { AssociationType } from '../../../../../cases/common/api'; +import { AssociationType } from '../../../../../cases/common'; type ExpandedRowMap = Record<string, Element> | {}; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts index 8962d67319371..0d5eb2c9ba407 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/helpers.ts @@ -6,7 +6,7 @@ */ import { filter } from 'lodash/fp'; -import { AssociationType, CaseStatuses, CaseType } from '../../../../../cases/common/api'; +import { AssociationType, CaseStatuses, CaseType } from '../../../../../cases/common'; import { Case, SubCase } from '../../containers/types'; import { statuses } from '../status'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 0fafdaf81f095..c079bbc991601 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -14,7 +14,7 @@ import { TestProviders } from '../../../common/mock'; import { casesStatus, useGetCasesMockState, collectionCase } from '../../containers/mock'; import * as i18n from './translations'; -import { CaseStatuses, CaseType } from '../../../../../cases/common/api'; +import { CaseStatuses, CaseType } from '../../../../../cases/common'; import { useKibana } from '../../../common/lib/kibana'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index c5748a321c19b..a0820486f423f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -22,7 +22,7 @@ import styled, { css } from 'styled-components'; import classnames from 'classnames'; import * as i18n from './translations'; -import { CaseStatuses, CaseType } from '../../../../../cases/common/api'; +import { CaseStatuses, CaseType } from '../../../../../cases/common'; import { getCasesColumns } from './columns'; import { Case, DeleteCase, FilterOptions, SortFieldCase, SubCase } from '../../containers/types'; import { useGetCases, UpdateCase } from '../../containers/use_get_cases'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx index 5c9f11d1e3a83..f31eda12b3399 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { StatusFilter } from './status_filter'; import { StatusAll } from '../status'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx index 48a642aaf51a9..c4486365cd292 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { TestProviders } from '../../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx index ff5b511ef9026..434ae46fcfb7a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx @@ -10,7 +10,7 @@ import { isEqual } from 'lodash/fp'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { FilterOptions } from '../../containers/types'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; diff --git a/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx b/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx index 24897a14f0754..e90ae2b036866 100644 --- a/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/bulk_actions/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { statuses, CaseStatusWithAllStatus } from '../status'; import * as i18n from './translations'; import { Case } from '../../containers/types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts index 8e26c0fd7a7ff..64d37de0a6ea9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { basicCase } from '../../containers/mock'; import { getStatusDate, getStatusTitle } from './helpers'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts index 68a243040145a..9dd666c72335b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { Case } from '../../containers/types'; import { statuses } from '../status'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx index 63ce441732251..fd4e49400d464 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx @@ -16,7 +16,7 @@ import { EuiFlexItem, EuiIconTip, } from '@elastic/eui'; -import { CaseStatuses, CaseType } from '../../../../../cases/common/api'; +import { CaseStatuses, CaseType } from '../../../../../cases/common'; import * as i18n from '../case_view/translations'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; import { Actions } from './actions'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.test.tsx index 4e414706d1fd7..1f3b9c39017d9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { StatusContextMenu } from './status_context_menu'; describe('SyncAlertsSwitch', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx index 92dcd16a86193..298d0d7695e8e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx @@ -8,7 +8,7 @@ import React, { memo, useCallback, useMemo, useState } from 'react'; import { memoize } from 'lodash/fp'; import { EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; -import { caseStatuses, CaseStatuses } from '../../../../../cases/common/api'; +import { caseStatuses, CaseStatuses } from '../../../../../cases/common'; import { Status } from '../status'; interface Props { diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx index 18a76e2766d8d..657a19d40fdd9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { AssociationType, CommentType } from '../../../../../cases/common/api'; +import { AssociationType, CommentType } from '../../../../../cases/common'; import { Comment } from '../../containers/types'; import { getManualAlertIdsWithNoRuleId, buildAlertsQuery } from './helpers'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts index 7211f4bca6a37..741880d886c89 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts @@ -6,7 +6,7 @@ */ import { isEmpty } from 'lodash'; -import { CommentType } from '../../../../../cases/common/api'; +import { CommentType } from '../../../../../cases/common'; import { Comment } from '../../containers/types'; export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => { diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index f28c7791d0110..75f91c8ef3035 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -28,8 +28,7 @@ import { useConnectors } from '../../containers/configure/use_connectors'; import { connectorsMock } from '../../containers/configure/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; -import { CaseType } from '../../../../../cases/common/api'; +import { CaseType, ConnectorTypes } from '../../../../../cases/common'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 892663c783293..e16f1d7683abc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -17,7 +17,7 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { CaseStatuses, CaseAttributes, CaseType } from '../../../../../cases/common/api'; +import { CaseStatuses, CaseAttributes, CaseType } from '../../../../../cases/common'; import { Case, CaseConnector } from '../../containers/types'; import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to'; import { gutterTimeline } from '../../../common/lib/helpers'; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx index ccc697a2ae84e..e18e0ef004ceb 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypes } from '../../../../../../cases/common/api'; +import { ConnectorTypes } from '../../../../../../cases/common'; import { ActionConnector } from '../../../containers/configure/types'; import { UseConnectorsResponse } from '../../../containers/configure/use_connectors'; import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx index c34651c3e1dc4..1c01bb3fdeb7b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx @@ -12,7 +12,7 @@ import { Connectors, Props } from './connectors'; import { TestProviders } from '../../../common/mock'; import { ConnectorsDropdown } from './connectors_dropdown'; import { connectors } from './__mock__'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../../../cases/common'; describe('Connectors', () => { let wrapper: ReactWrapper; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx index 1e0ae95ff901c..c0a5e3c4c8f72 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx @@ -21,7 +21,7 @@ import * as i18n from './translations'; import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types'; import { Mapping } from './mapping'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../../../cases/common'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx index 01d975a445ab4..27f7f4d50a0c9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; import styled from 'styled-components'; -import { ConnectorTypes } from '../../../../../cases/common/api'; +import { ConnectorTypes } from '../../../../../cases/common'; import { ActionConnector } from '../../containers/configure/types'; import { connectorsConfiguration } from '../connectors'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 8dbefdb731141..e78cd4c509d5d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -33,7 +33,7 @@ import { useConnectorsResponse, useActionTypesResponse, } from './__mock__'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../../../cases/common'; jest.mock('../../../common/lib/kibana'); jest.mock('../../containers/configure/use_connectors'); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx index 25155ff77c2d0..e951498c6c3c9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx @@ -10,7 +10,7 @@ import styled, { css } from 'styled-components'; import { EuiCallOut } from '@elastic/eui'; -import { SUPPORTED_CONNECTORS } from '../../../../../cases/common/constants'; +import { SUPPORTED_CONNECTORS } from '../../../../../cases/common'; import { useKibana } from '../../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useActionTypes } from '../../containers/configure/use_action_types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts b/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts index db14371b625d8..dfb19250f5bd6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypeFields, ConnectorTypes } from '../../../../../cases/common/api'; +import { ConnectorTypeFields, ConnectorTypes } from '../../../../../cases/common'; import { CaseField, ActionType, diff --git a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx b/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx index 63c6f265b1ab2..f0e77648cee6c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx @@ -11,7 +11,7 @@ import { EuiFormRow } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown'; -import { ActionConnector } from '../../../../../cases/common/api'; +import { ActionConnector } from '../../../../../cases/common'; interface ConnectorSelectorProps { connectors: ActionConnector[]; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx index af9a86b0b711b..dded090eb3f98 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx @@ -10,7 +10,7 @@ import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import styled from 'styled-components'; import { connectorsConfiguration } from '.'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../../../cases/common'; interface ConnectorCardProps { connectorType: ConnectorTypes; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx index 05161456976c6..b182c878d78e6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx @@ -12,7 +12,7 @@ import styled from 'styled-components'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { ActionParamsProps } from '../../../../../../triggers_actions_ui/public/types'; -import { CommentType } from '../../../../../../cases/common/api'; +import { CommentType } from '../../../../../../cases/common'; import { CaseActionParams } from './types'; import { ExistingCase } from './existing_case'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx index 3c6c5f47c6d12..c503a62ef515e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx @@ -6,7 +6,7 @@ */ import React, { memo, useMemo, useCallback } from 'react'; -import { CaseType } from '../../../../../../cases/common/api'; +import { CaseType } from '../../../../../../cases/common'; import { useGetCases, DEFAULT_QUERY_PARAMS, diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx index 841c2a9e38f6d..035f1fa2b63ac 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx @@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { CaseActionConnector, ConnectorFieldsProps } from './types'; import { getCaseConnectors } from '.'; -import { ConnectorTypeFields } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypeFields } from '../../../../../cases/common'; interface Props extends Omit<ConnectorFieldsProps<ConnectorTypeFields['fields']>, 'connector'> { connector: CaseActionConnector | null; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts index dad7070aad705..76f6ccb6a1adb 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts @@ -15,7 +15,7 @@ import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType, ResilientFieldsType, -} from '../../../../../cases/common/api/connectors'; +} from '../../../../../cases/common'; export { getActionType as getCaseConnectorUI } from './case'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx index 22e80d43f34e1..985537e799596 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx @@ -10,7 +10,7 @@ import { map } from 'lodash/fp'; import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import * as i18n from './translations'; -import { ConnectorTypes, JiraFieldsType } from '../../../../../../cases/common/api/connectors'; +import { ConnectorTypes, JiraFieldsType } from '../../../../../../cases/common'; import { useKibana } from '../../../../common/lib/kibana'; import { ConnectorFieldsProps } from '../types'; import { useGetIssueTypes } from './use_get_issue_types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/index.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/index.ts index 40e59a081a449..1069e489ada09 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/index.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/index.ts @@ -8,7 +8,7 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { JiraFieldsType } from '../../../../../../cases/common/api/connectors'; +import { JiraFieldsType } from '../../../../../../cases/common'; import * as i18n from './translations'; export * from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx index b1fbfb1169d08..ae9b5a4dd6f49 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx @@ -21,7 +21,7 @@ import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; import * as i18n from './translations'; -import { ConnectorTypes, ResilientFieldsType } from '../../../../../../cases/common/api/connectors'; +import { ConnectorTypes, ResilientFieldsType } from '../../../../../../cases/common'; import { ConnectorCard } from '../card'; const ResilientFieldsComponent: React.FunctionComponent< diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/index.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/index.ts index 8a2603f39e102..21850cdfe4d92 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/index.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/index.ts @@ -8,7 +8,7 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { ResilientFieldsType } from '../../../../../../cases/common/api/connectors'; +import { ResilientFieldsType } from '../../../../../../cases/common'; import * as i18n from './translations'; export * from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts index b342095c39ff0..02441b2b9f7aa 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts @@ -8,10 +8,7 @@ import { lazy } from 'react'; import { CaseConnector } from '../types'; -import { - ServiceNowITSMFieldsType, - ServiceNowSIRFieldsType, -} from '../../../../../../cases/common/api/connectors'; +import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType } from '../../../../../../cases/common'; import * as i18n from './translations'; export const getServiceNowITSMCaseConnector = (): CaseConnector<ServiceNowITSMFieldsType> => { diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index accb8450802d4..f705c9005e480 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -10,10 +10,7 @@ import { EuiFormRow, EuiSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@el import * as i18n from './translations'; import { ConnectorFieldsProps } from '../types'; -import { - ConnectorTypes, - ServiceNowITSMFieldsType, -} from '../../../../../../cases/common/api/connectors'; +import { ConnectorTypes, ServiceNowITSMFieldsType } from '../../../../../../cases/common'; import { useKibana } from '../../../../common/lib/kibana'; import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 63502e3454fcf..2bac7e01a00b2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -8,10 +8,7 @@ import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiCheckbox } from '@elastic/eui'; -import { - ConnectorTypes, - ServiceNowSIRFieldsType, -} from '../../../../../../cases/common/api/connectors'; +import { ConnectorTypes, ServiceNowSIRFieldsType } from '../../../../../../cases/common'; import { useKibana } from '../../../../common/lib/kibana'; import { ConnectorFieldsProps } from '../types'; import { ConnectorCard } from '../card'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts index 11452b966670b..86f0238dd450f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts @@ -12,9 +12,9 @@ import { CaseField, ActionConnector, ConnectorTypeFields, -} from '../../../../../cases/common/api'; +} from '../../../../../cases/common'; -export { ThirdPartyField as AllThirdPartyFields } from '../../../../../cases/common/api'; +export { ThirdPartyField as AllThirdPartyFields } from '../../../../../cases/common'; export type CaseActionConnector = ActionConnector; export interface ThirdPartyField { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx index 7912d97528cd2..516cc5a0d23a5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx @@ -8,7 +8,7 @@ import React, { memo, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { ConnectorTypes } from '../../../../../cases/common/api'; +import { ConnectorTypes } from '../../../../../cases/common'; import { UseField, useFormData, FieldHook, useFormContext } from '../../../shared_imports'; import { useConnectors } from '../../containers/configure/use_connectors'; import { ConnectorSelector } from '../connector_selector/form'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx index 99626c4cfb797..9d14acc96c192 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx @@ -10,7 +10,7 @@ import { mount, ReactWrapper } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { ConnectorTypes } from '../../../../../cases/common/api'; +import { ConnectorTypes } from '../../../../../cases/common'; import { TestProviders } from '../../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; import { useGetTags } from '../../containers/use_get_tags'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index b575dfe42f074..597726e7bb3f3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -19,7 +19,7 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service' import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Case } from '../../containers/types'; -import { CaseType, ConnectorTypes } from '../../../../../cases/common/api'; +import { CaseType, ConnectorTypes } from '../../../../../cases/common'; const initialCaseValue: FormProps = { description: '', diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 9f904350b772e..484a45248d8c0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -18,6 +18,8 @@ import { FormContext } from './form_context'; import { useInsertTimeline } from '../use_insert_timeline'; import { fieldName as descriptionFieldName } from './description'; import { SubmitCaseButton } from './submit_button'; +import { USE_RAC_CASES_UI } from '../../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; export const CommonUseField = getUseField({ component: Field }); @@ -39,6 +41,7 @@ const InsertTimeline = () => { }; export const Create = React.memo(() => { + const { cases } = useKibana().services; const history = useHistory(); const onSuccess = useCallback( async ({ id }) => { @@ -53,32 +56,39 @@ export const Create = React.memo(() => { return ( <EuiPanel> - <FormContext onSuccess={onSuccess}> - <CreateCaseForm /> - <Container> - <EuiFlexGroup - alignItems="center" - justifyContent="flexEnd" - gutterSize="xs" - responsive={false} - > - <EuiFlexItem grow={false}> - <EuiButtonEmpty - data-test-subj="create-case-cancel" - size="s" - onClick={handleSetIsCancel} - iconType="cross" - > - {i18n.CANCEL} - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <SubmitCaseButton /> - </EuiFlexItem> - </EuiFlexGroup> - </Container> - <InsertTimeline /> - </FormContext> + {USE_RAC_CASES_UI ? ( + cases.getCreateCase({ + onCancel: handleSetIsCancel, + onSuccess, + }) + ) : ( + <FormContext onSuccess={onSuccess}> + <CreateCaseForm /> + <Container> + <EuiFlexGroup + alignItems="center" + justifyContent="flexEnd" + gutterSize="xs" + responsive={false} + > + <EuiFlexItem grow={false}> + <EuiButtonEmpty + data-test-subj="create-case-cancel" + size="s" + onClick={handleSetIsCancel} + iconType="cross" + > + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <SubmitCaseButton /> + </EuiFlexItem> + </EuiFlexGroup> + </Container> + <InsertTimeline /> + </FormContext> + )} </EuiPanel> ); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts index 6e17be8d53e5a..a983add030a1e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { CasePostRequest, CaseType } from '../../../../../cases/common/api'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { CasePostRequest, CaseType, ConnectorTypes } from '../../../../../cases/common'; import { choices } from '../connectors/mock'; export const sampleTags = ['coke', 'pepsi']; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx b/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx index b069a484d314c..38321cdbeab50 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { CasePostRequest, ConnectorTypeFields } from '../../../../../cases/common/api'; +import { CasePostRequest, ConnectorTypeFields } from '../../../../../cases/common'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx index f76adfd2a840f..0ecb66d542334 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx @@ -21,9 +21,8 @@ import styled from 'styled-components'; import { noop } from 'lodash/fp'; import { Form, UseField, useForm } from '../../../shared_imports'; -import { ConnectorTypeFields } from '../../../../../cases/common/api/connectors'; +import { ActionConnector, ConnectorTypeFields } from '../../../../../cases/common'; import { ConnectorSelector } from '../connector_selector/form'; -import { ActionConnector } from '../../../../../cases/common/api'; import { ConnectorFieldsForm } from '../connectors/fields_form'; import { getConnectorById } from '../configure_cases/utils'; import { CaseUserActions } from '../../containers/types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx b/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx index 6bf4eb95bc049..3c019369fa08b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { StatusActionButton } from './button'; describe('StatusActionButton', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/button.tsx b/x-pack/plugins/security_solution/public/cases/components/status/button.tsx index 5a0d98fc8a11a..6aa8f540e2e95 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/button.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/button.tsx @@ -8,7 +8,7 @@ import React, { memo, useCallback, useMemo } from 'react'; import { EuiButton } from '@elastic/eui'; -import { CaseStatuses, caseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses, caseStatuses } from '../../../../../cases/common'; import { statuses } from './config'; interface Props { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/config.ts b/x-pack/plugins/security_solution/public/cases/components/status/config.ts index 47a74549f03cc..b7bc7dfa36110 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/config.ts +++ b/x-pack/plugins/security_solution/public/cases/components/status/config.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import * as i18n from './translations'; import { AllCaseStatus, Statuses, StatusAll } from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/status/stats.test.tsx b/x-pack/plugins/security_solution/public/cases/components/status/stats.test.tsx index 266ceb04e4335..0bf3297361446 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/stats.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/stats.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { Stats } from './stats'; describe('Stats', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx b/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx index 43001c2cf5947..93b8479a55d71 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx @@ -7,7 +7,7 @@ import React, { memo, useMemo } from 'react'; import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { statuses } from './config'; export interface Props { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx b/x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx index eff9d73c2adf9..05c3b95e163e6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { Status } from './status'; describe('Stats', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/types.ts b/x-pack/plugins/security_solution/public/cases/components/status/types.ts index 5618e7802579d..bbe44bce55515 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/types.ts +++ b/x-pack/plugins/security_solution/public/cases/components/status/types.ts @@ -6,7 +6,7 @@ */ import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; export const StatusAll = 'all' as const; type StatusAllType = typeof StatusAll; diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index decd37a7646e7..ec5a3825ff652 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -16,7 +16,7 @@ import { EuiToolTip, } from '@elastic/eui'; -import { CommentType, CaseStatuses } from '../../../../../cases/common/api'; +import { CommentType, CaseStatuses } from '../../../../../cases/common'; import { Ecs } from '../../../../common/ecs'; import { ActionIconItem } from '../../../timelines/components/timeline/body/actions/action_icon_item'; import { usePostComment } from '../../containers/use_post_comment'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx index 10ad3d35004ba..54a5dd1263961 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx @@ -10,7 +10,7 @@ import styled from 'styled-components'; import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { Case, SubCase } from '../../containers/types'; import { AllCases } from '../all_cases'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx index 0b30f6ac94e03..23cc11ef2ef28 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx @@ -6,7 +6,7 @@ */ import React, { useState, useCallback, useMemo } from 'react'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { Case, SubCase } from '../../containers/types'; import { AllCasesModal } from './all_cases_modal'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx index 4b5eb00d95a80..627dc61c36b0c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx @@ -14,7 +14,7 @@ import { CreateCaseForm } from '../create/form'; import { SubmitCaseButton } from '../create/submit_button'; import { Case } from '../../containers/types'; import * as i18n from '../../translations'; -import { CaseType } from '../../../../../cases/common/api'; +import { CaseType } from '../../../../../cases/common'; export interface CreateCaseModalProps { isModalOpen: boolean; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx index 5d2f54bd1f142..e29ee3f8712da 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx @@ -6,7 +6,7 @@ */ import React, { useState, useCallback, useMemo } from 'react'; -import { CaseType } from '../../../../../cases/common/api'; +import { CaseType } from '../../../../../cases/common'; import { Case } from '../../containers/types'; import { CreateCaseModal } from './create_case_modal'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx index c058473bbfe3f..928d0167bbe85 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx @@ -13,12 +13,11 @@ import '../../../common/mock/match_media'; import { usePushToService, ReturnUsePushToService, UsePushToService } from '.'; import { TestProviders } from '../../../common/mock'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses, ConnectorTypes } from '../../../../../cases/common'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { basicPush, actionLicenses } from '../../containers/mock'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { connectorsMock } from '../../containers/configure/mock'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx index d83ddb08b51d2..42284cfa7da49 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx @@ -17,7 +17,7 @@ import { getConfigureCasesUrl, useFormatUrl } from '../../../common/components/l import { CaseCallOut } from '../callout'; import { getLicenseError, getKibanaConfigError } from './helpers'; import * as i18n from './translations'; -import { CaseConnector, ActionConnector, CaseStatuses } from '../../../../../cases/common/api'; +import { CaseConnector, ActionConnector, CaseStatuses } from '../../../../../cases/common'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { LinkAnchor } from '../../../common/components/links'; import { SecurityPageName } from '../../../app/types'; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx index a62c6c0ef682d..84408557eb5ae 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses } from '../../../../../cases/common/api'; +import { CaseStatuses } from '../../../../../cases/common'; import { basicPush, getUserAction } from '../../containers/mock'; import { getLabelTitle, getPushedServiceLabelTitle, getConnectorLabelTitle } from './helpers'; import { connectorsMock } from '../../containers/configure/mock'; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index cc8d560f91b1f..a97e2e98cb9af 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -15,7 +15,7 @@ import { ActionConnector, CaseStatuses, CommentType, -} from '../../../../../cases/common/api'; +} from '../../../../../cases/common'; import { CaseUserActions } from '../../containers/types'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index f8d6872a4b740..d372d62ab16bb 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -30,7 +30,7 @@ import { AlertCommentRequestRt, CommentType, ContextTypeUserRt, -} from '../../../../../cases/common/api'; +} from '../../../../../cases/common'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { OnUpdateFields } from '../case_view'; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx index 3bfdf2d2c5e62..25080d61a951b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx @@ -11,7 +11,7 @@ import { mount } from 'enzyme'; import { TestProviders } from '../../../common/mock'; import { useKibana } from '../../../common/lib/kibana'; import { AlertCommentEvent } from './user_action_alert_comment_event'; -import { CommentType } from '../../../../../cases/common/api'; +import { CommentType } from '../../../../../cases/common'; const props = { alertId: 'alert-id-1', diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx index a72bebbaf0999..a1b6587cfeecb 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx @@ -15,7 +15,7 @@ import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link import { SecurityPageName } from '../../../app/types'; import * as i18n from './translations'; -import { CommentType } from '../../../../../cases/common/api'; +import { CommentType } from '../../../../../cases/common'; import { LinkAnchor } from '../../../common/components/links'; interface Props { diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index 644c7dbf716bf..ca7ab5eb9d7dd 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -8,9 +8,14 @@ import { assign, omit } from 'lodash'; import { + ACTION_TYPES_URL, + CASE_REPORTERS_URL, + CASE_STATUS_URL, + CASE_TAGS_URL, CasePatchRequest, CasePostRequest, CaseResponse, + CASES_URL, CasesFindResponse, CasesResponse, CasesStatusResponse, @@ -18,30 +23,19 @@ import { CaseUserActionsResponse, CommentRequest, CommentType, - SubCasePatchRequest, - SubCaseResponse, - SubCasesResponse, - User, -} from '../../../../cases/common/api'; - -import { - ACTION_TYPES_URL, - CASE_REPORTERS_URL, - CASE_STATUS_URL, - CASE_TAGS_URL, - CASES_URL, - SUB_CASE_DETAILS_URL, - SUB_CASES_PATCH_DEL_URL, -} from '../../../../cases/common/constants'; - -import { getCaseCommentsUrl, - getCasePushUrl, getCaseDetailsUrl, + getCasePushUrl, getCaseUserActionUrl, getSubCaseDetailsUrl, getSubCaseUserActionUrl, -} from '../../../../cases/common/api/helpers'; + SUB_CASE_DETAILS_URL, + SUB_CASES_PATCH_DEL_URL, + SubCasePatchRequest, + SubCaseResponse, + SubCasesResponse, + User, +} from '../../../../cases/common'; import { KibanaServices } from '../../common/lib/kibana'; import { StatusAll } from '../components/status'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts index 943724ef08398..c165c493c16d9 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts @@ -7,20 +7,16 @@ import { isEmpty } from 'lodash/fp'; import { + ACTION_TYPES_URL, ActionConnector, ActionTypeConnector, + CASE_CONFIGURE_CONNECTORS_URL, + CASE_CONFIGURE_URL, CasesConfigurePatch, - CasesConfigureResponse, CasesConfigureRequest, -} from '../../../../../cases/common/api'; + CasesConfigureResponse, +} from '../../../../../cases/common'; import { KibanaServices } from '../../../common/lib/kibana'; - -import { - CASE_CONFIGURE_CONNECTORS_URL, - CASE_CONFIGURE_URL, - ACTION_TYPES_URL, -} from '../../../../../cases/common/constants'; - import { ApiProps } from '../types'; import { convertToCamelCase, decodeCaseConfigureResponse } from '../utils'; import { CaseConfigure } from './types'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx index 2ec2a73363bfe..2087753b26039 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx @@ -15,7 +15,7 @@ import { } from '../../../common/components/toasters'; import * as i18n from './translations'; import { ClosureType, CaseConfigure, CaseConnector, CaseConnectorMapping } from './types'; -import { ConnectorTypes } from '../../../../../cases/common/api/connectors'; +import { ConnectorTypes } from '../../../../../cases/common'; export type ConnectorConfiguration = { connector: CaseConnector } & { closureType: CaseConfigure['closureType']; diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index 6feb5a1501a76..d1c17ea56df65 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -6,20 +6,20 @@ */ import { - User, - UserActionField, - UserAction, - CaseConnector, - CommentRequest, - CaseStatuses, + AssociationType, CaseAttributes, + CaseConnector, CasePatchRequest, + CaseStatuses, CaseType, - AssociationType, -} from '../../../../cases/common/api'; + CommentRequest, + User, + UserAction, + UserActionField, +} from '../../../../cases/common'; import { CaseStatusWithAllStatus } from '../components/status'; -export { CaseConnector, ActionConnector, CaseStatuses } from '../../../../cases/common/api'; +export { CaseConnector, ActionConnector, CaseStatuses } from '../../../../cases/common'; export type Comment = CommentRequest & { associationType: AssociationType; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx index d39da93a06a48..ffb964982d302 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx @@ -6,7 +6,7 @@ */ import { useCallback, useReducer, useRef, useEffect } from 'react'; -import { CaseStatuses } from '../../../../cases/common/api'; +import { CaseStatuses } from '../../../../cases/common'; import { displaySuccessToast, errorToToaster, diff --git a/x-pack/plugins/security_solution/public/cases/containers/utils.ts b/x-pack/plugins/security_solution/public/cases/containers/utils.ts index 7c33e4481b2aa..e447476d02282 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/utils.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/utils.ts @@ -13,22 +13,22 @@ import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import { - CasesFindResponse, - CasesFindResponseRt, + CaseConfigureResponseRt, + CasePatchRequest, CaseResponse, CaseResponseRt, + CasesConfigureResponse, + CasesFindResponse, + CasesFindResponseRt, CasesResponse, CasesResponseRt, - CasesStatusResponseRt, CasesStatusResponse, - throwErrors, - CasesConfigureResponse, - CaseConfigureResponseRt, + CasesStatusResponseRt, CaseUserActionsResponse, CaseUserActionsResponseRt, CommentType, - CasePatchRequest, -} from '../../../../cases/common/api'; + throwErrors, +} from '../../../../cases/common'; import { AppToast, ToasterError } from '../../common/components/toasters'; import { AllCases, Case, UpdateByKey } from './types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx index 875bc5e647077..c19e5c26bdc94 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx @@ -6,7 +6,7 @@ */ import { useEffect, useRef, useState } from 'react'; -import { ACTION_URL } from '../../../../../../cases/common/constants'; +import { ACTION_URL } from '../../../../../../cases/common'; import { KibanaServices } from '../../../../common/lib/kibana'; interface CaseAction { diff --git a/x-pack/plugins/security_solution/public/index.ts b/x-pack/plugins/security_solution/public/index.ts index f1d1bc3e6280b..55262fe039b4e 100644 --- a/x-pack/plugins/security_solution/public/index.ts +++ b/x-pack/plugins/security_solution/public/index.ts @@ -7,8 +7,8 @@ import { PluginInitializerContext } from '../../../../src/core/public'; import { Plugin } from './plugin'; -import { PluginSetup, PluginStart } from './types'; +import { PluginSetup } from './types'; export const plugin = (context: PluginInitializerContext): Plugin => new Plugin(context); -export { Plugin, PluginSetup, PluginStart }; +export { Plugin, PluginSetup }; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts index 01a85f6309c3f..4443688fd249d 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -12,7 +12,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; // eslint-disable-next-line no-restricted-imports import isEmpty from 'lodash/isEmpty'; -import { throwErrors } from '../../../../cases/common/api'; +import { throwErrors } from '../../../../cases/common'; import { TimelineResponse, TimelineResponseType, diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index e88077679e1b6..e3d2c345a2a66 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -22,6 +22,7 @@ import { TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, } from '../../triggers_actions_ui/public'; +import { CasesUiStart } from '../../cases/public'; import { SecurityPluginSetup } from '../../security/public'; import { ResolverPluginSetup } from './resolver/types'; import { Inspect } from '../common/search_strategy'; @@ -47,6 +48,7 @@ export interface SetupPlugins { } export interface StartPlugins { + cases: CasesUiStart; data: DataPublicPluginStart; embeddable: EmbeddableStart; inspector: InspectorStart; diff --git a/x-pack/plugins/security_solution/scripts/convert_saved_search_to_rules.js b/x-pack/plugins/security_solution/scripts/convert_saved_search_to_rules.js deleted file mode 100644 index 33968849fdb91..0000000000000 --- a/x-pack/plugins/security_solution/scripts/convert_saved_search_to_rules.js +++ /dev/null @@ -1,177 +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. - */ - -require('../../../../src/setup_node_env'); - -const fs = require('fs'); -const path = require('path'); -// eslint-disable-next-line import/no-extraneous-dependencies -const uuid = require('uuid'); - -/* - * This script is used to parse a set of saved searches on a file system - * and output rule data compatible json files. - * Example: - * node saved_query_to_rules.js ${HOME}/saved_searches ${HOME}/saved_rules - * - * After editing any changes in the files of ${HOME}/saved_rules/*.json - * you can then post the rules with a CURL post script such as: - * - * ./post_rule.sh ${HOME}/saved_rules/*.json - * - * Note: This script is recursive and but does not preserve folder structure - * when it outputs the saved rules. - */ - -// Defaults of the outputted rules since the saved KQL searches do not have -// this type of information. You usually will want to make any hand edits after -// doing a search to KQL conversion before posting it as a rule or checking it -// into another repository. -const INTERVAL = '5m'; -const SEVERITY = 'low'; -const TYPE = 'query'; -const FROM = 'now-6m'; -const TO = 'now'; -const IMMUTABLE = true; -const RISK_SCORE = 50; -const ENABLED = false; - -// For converting, if you want to use these instead of rely on the defaults then -// comment these in and use them for the script. Otherwise this is commented out -// so we can utilize the defaults of input and output which are based on saved objects -// of securitySolution:defaultIndex and your kibana.dev.yml setting of xpack.securitySolution.signalsIndex. If -// the setting of xpack.securitySolution.signalsIndex is not set it defaults to .siem-signals -// const INDEX = ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*']; -// const OUTPUT_INDEX = '.siem-signals-some-other-index'; - -const walk = (dir) => { - const list = fs.readdirSync(dir); - return list.reduce((accum, file) => { - const fileWithDir = `${dir}/${file}`; - const stat = fs.statSync(fileWithDir); - if (stat && stat.isDirectory()) { - return [...accum, ...walk(fileWithDir)]; - } else { - return [...accum, fileWithDir]; - } - }, []); -}; - -//clean up the file system characters -const cleanupFileName = (file) => { - const fileWithoutSpecialChars = file - .trim() - .replace(/\./g, '') - .replace(/\//g, '') - .replace(/\s+/g, '_') - .replace(/,/g, '') - .replace(/\[/g, '') - .replace(/\]/g, '') - .replace(/\(/g, '') - .replace(/\)/g, '') - .replace(/\@/g, '') - .replace(/\:/g, '') - .replace(/\+s/g, '') - .replace(/-/g, '') - .replace(/__/g, '_') - .toLowerCase(); - return path.basename( - fileWithoutSpecialChars.trim(), - path.extname(fileWithoutSpecialChars.trim()) - ); -}; - -async function main() { - if (process.argv.length !== 4) { - throw new Error( - 'usage: saved_query_to_rules [input directory with saved searches] [output directory]' - ); - } - - const files = process.argv[2]; - const outputDir = process.argv[3]; - - const savedSearchesJson = walk(files).filter((file) => { - return !path.basename(file).startsWith('.') && file.endsWith('.ndjson'); - }); - - const savedSearchesParsed = savedSearchesJson.reduce((accum, json) => { - const jsonFile = fs.readFileSync(json, 'utf8'); - const jsonLines = jsonFile.split(/\r{0,1}\n/); - const parsedLines = jsonLines.reduce((accum, line) => { - try { - const parsedLine = JSON.parse(line); - // don't try to parse out any exported count records - if (parsedLine.exportedCount != null) { - return accum; - } - parsedLine._file = parsedLine.attributes.title; - parsedLine.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.parse( - parsedLine.attributes.kibanaSavedObjectMeta.searchSourceJSON - ); - return [...accum, parsedLine]; - } catch (err) { - return accum; - } - }, []); - return [...accum, ...parsedLines]; - }, []); - - savedSearchesParsed.forEach( - ({ - _file, - attributes: { - description, - title, - kibanaSavedObjectMeta: { - searchSourceJSON: { - query: { query, language }, - filter, - }, - }, - }, - }) => { - const fileToWrite = cleanupFileName(_file); - - // remove meta value from the filter - const filterWithoutMeta = filter.map((filterValue) => { - filterValue.$state; - return filterValue; - }); - const outputMessage = { - description: description || title, - enabled: ENABLED, - filters: filterWithoutMeta, - from: FROM, - immutable: IMMUTABLE, - interval: INTERVAL, - language, - name: title, - query, - risk_score: RISK_SCORE, - rule_id: uuid.v4(), - severity: SEVERITY, - to: TO, - type: TYPE, - version: 1, - // comment these in if you want to use these for input output, otherwise - // with these two commented out, we will use the default saved objects from spaces. - // index: INDEX, - // output_index: OUTPUT_INDEX, - }; - - fs.writeFileSync( - `${outputDir}/${fileToWrite}.json`, - `${JSON.stringify(outputMessage, null, 2)}\n` - ); - } - ); -} - -if (require.main === module) { - main(); -} diff --git a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/README.md b/x-pack/plugins/security_solution/scripts/optimize_tsconfig/README.md deleted file mode 100644 index b711b8bf1dbc2..0000000000000 --- a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/README.md +++ /dev/null @@ -1,16 +0,0 @@ -Hard forked from here: -x-pack/plugins/apm/scripts/optimize-tsconfig.js - - -#### Optimizing TypeScript - -Kibana and X-Pack are very large TypeScript projects, and it comes at a cost. Editor responsiveness is not great, and the CLI type check for X-Pack takes about a minute. To get faster feedback, we create a smaller SIEM TypeScript project that only type checks the SIEM project and the files it uses. This optimization consists of creating a `tsconfig.json` in SIEM that includes the Kibana/X-Pack typings, and editing the Kibana/X-Pack configurations to not include any files, or removing the configurations altogether. The script configures git to ignore any changes in these files, and has an undo script as well. - -To run the optimization: - -`$ node x-pack/plugins/security_solution/scripts/optimize_tsconfig` - -To undo the optimization: - -`$ node x-pack/plugins/security_solution/scripts/unoptimize_tsconfig` - diff --git a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/optimize.js b/x-pack/plugins/security_solution/scripts/optimize_tsconfig/optimize.js deleted file mode 100644 index 9bea8c93ed52c..0000000000000 --- a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/optimize.js +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable import/no-extraneous-dependencies */ - -const fs = require('fs'); -const { promisify } = require('util'); -const path = require('path'); -const json5 = require('json5'); -const execa = require('execa'); - -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); - -const { xpackRoot, kibanaRoot, tsconfigTpl, filesToIgnore } = require('./paths'); -const { unoptimizeTsConfig } = require('./unoptimize'); - -function prepareParentTsConfigs() { - return Promise.all( - [path.resolve(xpackRoot, 'tsconfig.json'), path.resolve(kibanaRoot, 'tsconfig.json')].map( - async (filename) => { - const config = json5.parse(await readFile(filename, 'utf-8')); - - await writeFile( - filename, - JSON.stringify( - { - ...config, - include: [], - }, - null, - 2 - ), - { encoding: 'utf-8' } - ); - } - ) - ); -} - -async function addFilesToXpackTsConfig() { - const template = json5.parse(await readFile(tsconfigTpl, 'utf-8')); - const xpackTsConfig = path.join(xpackRoot, 'tsconfig.json'); - const config = json5.parse(await readFile(xpackTsConfig, 'utf-8')); - - await writeFile(xpackTsConfig, JSON.stringify({ ...config, ...template }, null, 2), { - encoding: 'utf-8', - }); -} - -async function setIgnoreChanges() { - for (const filename of filesToIgnore) { - await execa('git', ['update-index', '--skip-worktree', filename]); - } -} - -async function optimizeTsConfig() { - await unoptimizeTsConfig(); - - await prepareParentTsConfigs(); - - await addFilesToXpackTsConfig(); - - await setIgnoreChanges(); - // eslint-disable-next-line no-console - console.log( - 'Created an optimized tsconfig.json for SIEM. To undo these changes, run `./scripts/unoptimize_tsconfig.js`' - ); -} - -module.exports = { - optimizeTsConfig, -}; diff --git a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/paths.js b/x-pack/plugins/security_solution/scripts/optimize_tsconfig/paths.js deleted file mode 100644 index ac32739627935..0000000000000 --- a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/paths.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -const path = require('path'); - -const xpackRoot = path.resolve(__dirname, '../../../..'); -const kibanaRoot = path.resolve(xpackRoot, '..'); - -const tsconfigTpl = path.resolve(__dirname, './tsconfig.json'); - -const filesToIgnore = [ - path.resolve(xpackRoot, 'tsconfig.json'), - path.resolve(kibanaRoot, 'tsconfig.json'), -]; - -module.exports = { - xpackRoot, - kibanaRoot, - tsconfigTpl, - filesToIgnore, -}; diff --git a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json b/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json deleted file mode 100644 index ac56a6af31c72..0000000000000 --- a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "include": [ - "typings/**/*", - "plugins/lists/**/*", - "plugins/security_solution/**/*", - "plugins/apm/typings/numeral.d.ts", - "plugins/canvas/types/webpack.d.ts", - "plugins/triggers_actions_ui/**/*" - ], - "exclude": [ - "test/**/*", - "**/__fixtures__/**/*", - "plugins/security_solution/cypress/**/*" - ] -} diff --git a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/unoptimize.js b/x-pack/plugins/security_solution/scripts/optimize_tsconfig/unoptimize.js deleted file mode 100644 index 58bd5d526a638..0000000000000 --- a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/unoptimize.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable import/no-extraneous-dependencies */ - -const execa = require('execa'); - -const { filesToIgnore } = require('./paths'); - -async function unoptimizeTsConfig() { - for (const filename of filesToIgnore) { - await execa('git', ['update-index', '--no-skip-worktree', filename]); - await execa('git', ['checkout', filename]); - } -} - -module.exports = { - unoptimizeTsConfig: async () => { - await unoptimizeTsConfig(); - // eslint-disable-next-line no-console - console.log('Removed SIEM TypeScript optimizations'); - }, -}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/create_bootstrap_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/create_bootstrap_index.ts index f66cf2e0e8ebb..fd9b63152ddd3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/create_bootstrap_index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/create_bootstrap_index.ts @@ -5,24 +5,26 @@ * 2.0. */ -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; // See the reference(s) below on explanations about why -000001 was chosen and // why the is_write_index is true as well as the bootstrapping step which is needed. // Ref: https://www.elastic.co/guide/en/elasticsearch/reference/current/applying-policy-to-template.html export const createBootstrapIndex = async ( - callWithRequest: CallWithRequest<{ path: string; method: 'PUT'; body: unknown }, boolean>, + esClient: ElasticsearchClient, index: string ): Promise<unknown> => { - return callWithRequest('transport.request', { - path: `/${index}-000001`, - method: 'PUT', - body: { - aliases: { - [index]: { - is_write_index: true, + return ( + await esClient.transport.request({ + path: `/${index}-000001`, + method: 'PUT', + body: { + aliases: { + [index]: { + is_write_index: true, + }, }, }, - }, - }); + }) + ).body; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_all_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_all_index.ts index b70ead2b05aff..98a8f8c28d30d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_all_index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_all_index.ts @@ -5,11 +5,10 @@ * 2.0. */ -import { IndicesDeleteParams, Client } from 'elasticsearch'; -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; export const deleteAllIndex = async ( - callWithRequest: CallWithRequest<IndicesDeleteParams, ReturnType<Client['indices']['getAlias']>>, + esClient: ElasticsearchClient, pattern: string, maxAttempts = 5 ): Promise<boolean> => { @@ -21,10 +20,12 @@ export const deleteAllIndex = async ( } // resolve pattern to concrete index names - const resp = await callWithRequest('indices.getAlias', { - index: pattern, - ignore: 404, - }); + const { body: resp } = await esClient.indices.getAlias( + { + index: pattern, + }, + { ignore: [404] } + ); if (resp.status === 404) { return true; @@ -38,9 +39,9 @@ export const deleteAllIndex = async ( } // delete the concrete indexes we found and try again until this pattern resolves to no indexes - await callWithRequest('indices.delete', { + await esClient.indices.delete({ index: indices, - ignoreUnavailable: true, + ignore_unavailable: true, }); } }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_policy.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_policy.ts index 63f8648b8e516..d671d256f56aa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_policy.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_policy.ts @@ -5,14 +5,16 @@ * 2.0. */ -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; export const deletePolicy = async ( - callWithRequest: CallWithRequest<{ path: string; method: 'DELETE' }, unknown>, + esClient: ElasticsearchClient, policy: string ): Promise<unknown> => { - return callWithRequest('transport.request', { - path: `/_ilm/policy/${policy}`, - method: 'DELETE', - }); + return ( + await esClient.transport.request({ + path: `/_ilm/policy/${policy}`, + method: 'DELETE', + }) + ).body; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_template.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_template.ts index 3d9554a826172..e57bbd77120f2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_template.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/delete_template.ts @@ -4,15 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { IndicesDeleteTemplateParams } from 'elasticsearch'; -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; export const deleteTemplate = async ( - callWithRequest: CallWithRequest<IndicesDeleteTemplateParams, unknown>, + esClient: ElasticsearchClient, name: string ): Promise<unknown> => { - return callWithRequest('indices.deleteTemplate', { - name, - }); + return ( + await esClient.indices.deleteTemplate({ + name, + }) + ).body; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_index_exists.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_index_exists.test.ts index a162dece4f13d..488ba0dab0b97 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_index_exists.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_index_exists.test.ts @@ -5,6 +5,8 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; import { getIndexExists } from './get_index_exists'; class StatusCode extends Error { @@ -17,29 +19,41 @@ class StatusCode extends Error { describe('get_index_exists', () => { test('it should return a true if you have _shards', async () => { - const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 1 } }); - const indexExists = await getIndexExists(callWithRequest, 'some-index'); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) + ); + const indexExists = await getIndexExists(esClient, 'some-index'); expect(indexExists).toEqual(true); }); test('it should return a false if you do NOT have _shards', async () => { - const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 0 } }); - const indexExists = await getIndexExists(callWithRequest, 'some-index'); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) + ); + const indexExists = await getIndexExists(esClient, 'some-index'); expect(indexExists).toEqual(false); }); test('it should return a false if it encounters a 404', async () => { - const callWithRequest = jest.fn().mockImplementation(() => { - throw new StatusCode(404, 'I am a 404 error'); - }); - const indexExists = await getIndexExists(callWithRequest, 'some-index'); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createErrorTransportRequestPromise({ + body: new StatusCode(404, 'I am a 404 error'), + }) + ); + const indexExists = await getIndexExists(esClient, 'some-index'); expect(indexExists).toEqual(false); }); test('it should reject if it encounters a non 404', async () => { - const callWithRequest = jest.fn().mockImplementation(() => { - throw new StatusCode(500, 'I am a 500 error'); - }); - await expect(getIndexExists(callWithRequest, 'some-index')).rejects.toThrow('I am a 500 error'); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockReturnValue( + elasticsearchClientMock.createErrorTransportRequestPromise( + new StatusCode(500, 'I am a 500 error') + ) + ); + await expect(getIndexExists(esClient, 'some-index')).rejects.toThrow('I am a 500 error'); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_index_exists.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_index_exists.ts index 4e9eb1a80566f..b86b58897ee62 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_index_exists.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_index_exists.ts @@ -5,17 +5,14 @@ * 2.0. */ -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; export const getIndexExists = async ( - callWithRequest: CallWithRequest< - { index: string; size: number; terminate_after: number; allow_no_indices: boolean }, - { _shards: { total: number } } - >, + esClient: ElasticsearchClient, index: string ): Promise<boolean> => { try { - const response = await callWithRequest('search', { + const { body: response } = await esClient.search({ index, size: 0, terminate_after: 1, @@ -23,10 +20,10 @@ export const getIndexExists = async ( }); return response._shards.total > 0; } catch (err) { - if (err.status === 404) { + if (err.body?.status === 404) { return false; } else { - throw err; + throw err.body ? err.body : err; } } }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_policy_exists.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_policy_exists.ts index 75118eb5062f3..c0d7c38a4bb02 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_policy_exists.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_policy_exists.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; export const getPolicyExists = async ( - callWithRequest: CallWithRequest<{ path: string; method: 'GET' }, unknown>, + esClient: ElasticsearchClient, policy: string ): Promise<boolean> => { try { - await callWithRequest('transport.request', { + await esClient.transport.request({ path: `/_ilm/policy/${policy}`, method: 'GET', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_template_exists.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_template_exists.ts index 7237a5ce58e01..50ec3bfc670d5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_template_exists.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/get_template_exists.ts @@ -5,14 +5,15 @@ * 2.0. */ -import { IndicesExistsTemplateParams } from 'elasticsearch'; -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; export const getTemplateExists = async ( - callWithRequest: CallWithRequest<IndicesExistsTemplateParams, boolean>, + esClient: ElasticsearchClient, template: string ): Promise<boolean> => { - return callWithRequest('indices.existsTemplate', { - name: template, - }); + return ( + await esClient.indices.existsTemplate({ + name: template, + }) + ).body; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/read_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/read_index.ts index 653c9a2379cc2..7674ca3b48304 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/read_index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/read_index.ts @@ -5,14 +5,10 @@ * 2.0. */ -import { IndicesGetSettingsParams } from 'elasticsearch'; -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; -export const readIndex = async ( - callWithRequest: CallWithRequest<IndicesGetSettingsParams, unknown>, - index: string -): Promise<unknown> => { - return callWithRequest('indices.get', { +export const readIndex = async (esClient: ElasticsearchClient, index: string): Promise<unknown> => { + return esClient.indices.get({ index, }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/set_policy.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/set_policy.ts index 1071551170c68..9dbcdd795ac71 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/set_policy.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/set_policy.ts @@ -5,16 +5,18 @@ * 2.0. */ -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; export const setPolicy = async ( - callWithRequest: CallWithRequest<{ path: string; method: 'PUT'; body: unknown }, unknown>, + esClient: ElasticsearchClient, policy: string, - body: unknown + body: Record<string, unknown> ): Promise<unknown> => { - return callWithRequest('transport.request', { - path: `/_ilm/policy/${policy}`, - method: 'PUT', - body, - }); + return ( + await esClient.transport.request({ + path: `/_ilm/policy/${policy}`, + method: 'PUT', + body, + }) + ).body; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/index/set_template.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/index/set_template.ts index 11c240fce2356..e63dbbd6c3e8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/index/set_template.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/index/set_template.ts @@ -5,16 +5,17 @@ * 2.0. */ -import { IndicesPutTemplateParams } from 'elasticsearch'; -import { CallWithRequest } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; export const setTemplate = async ( - callWithRequest: CallWithRequest<IndicesPutTemplateParams, unknown>, + esClient: ElasticsearchClient, name: string, - body: unknown + body: Record<string, unknown> ): Promise<unknown> => { - return callWithRequest('indices.putTemplate', { - name, - body, - }); + return ( + await esClient.indices.putTemplate({ + name, + body, + }) + ).body; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals.ts index b716771d20ac3..b411ac2c69ef2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { AlertServices } from '../../../../../alerting/server'; +import { ElasticsearchClient } from 'kibana/server'; import { SignalSearchResponse } from '../signals/types'; import { buildSignalsSearchQuery } from './build_signals_query'; @@ -15,7 +15,7 @@ interface GetSignalsParams { size?: number; ruleId: string; index: string; - callCluster: AlertServices['callCluster']; + esClient: ElasticsearchClient; } export const getSignals = async ({ @@ -24,7 +24,7 @@ export const getSignals = async ({ size, ruleId, index, - callCluster, + esClient, }: GetSignalsParams): Promise<SignalSearchResponse> => { if (from == null || to == null) { throw Error('"from" or "to" was not provided to signals query'); @@ -38,7 +38,7 @@ export const getSignals = async ({ size, }); - const result: SignalSearchResponse = await callCluster('search', query); + const { body: result } = await esClient.search<SignalSearchResponse>(query); return result; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals_count.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals_count.ts index 9811e5ce21086..b864919fd7295 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals_count.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals_count.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { AlertServices } from '../../../../../alerting/server'; +import { ElasticsearchClient } from 'kibana/server'; import { buildSignalsSearchQuery } from './build_signals_query'; interface GetSignalsCount { @@ -13,11 +13,7 @@ interface GetSignalsCount { to?: string; ruleId: string; index: string; - callCluster: AlertServices['callCluster']; -} - -interface CountResult { - count: number; + esClient: ElasticsearchClient; } export const getSignalsCount = async ({ @@ -25,7 +21,7 @@ export const getSignalsCount = async ({ to, ruleId, index, - callCluster, + esClient, }: GetSignalsCount): Promise<number> => { if (from == null || to == null) { throw Error('"from" or "to" was not provided to signals count query'); @@ -38,7 +34,7 @@ export const getSignalsCount = async ({ from, }); - const result: CountResult = await callCluster('count', query); + const { body: result } = await esClient.count(query); return result.count; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts index 4923aa3d1223e..762d7e724f80a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -17,6 +17,8 @@ import { sampleEmptyDocSearchResults, } from '../signals/__mocks__/es_results'; import { DEFAULT_RULE_NOTIFICATION_QUERY_SIZE } from '../../../../common/constants'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; jest.mock('./build_signals_query'); describe('rules_notification_alert_type', () => { @@ -70,7 +72,11 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId()); + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsWithSortId() + ) + ); await alert.executor(payload); @@ -94,7 +100,11 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId()); + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsWithSortId() + ) + ); await alert.executor(payload); expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); @@ -118,7 +128,11 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId()); + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsWithSortId() + ) + ); await alert.executor(payload); expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); @@ -143,7 +157,11 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId()); + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsWithSortId() + ) + ); await alert.executor(payload); expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); @@ -165,7 +183,9 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue(sampleEmptyDocSearchResults()); + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(sampleEmptyDocSearchResults()) + ); await alert.executor(payload); @@ -180,7 +200,11 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsNoSortIdNoVersion()); + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsNoSortIdNoVersion() + ) + ); await alert.executor(payload); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts index 6e03eb45da480..a40cb998eb408 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -64,7 +64,7 @@ export const rulesNotificationAlertType = ({ size: DEFAULT_RULE_NOTIFICATION_QUERY_SIZE, index: ruleParams.outputIndex, ruleId: ruleParams.ruleId, - callCluster: services.callCluster, + esClient: services.scopedClusterClient.asCurrentUser, }); const signals = results.hits.hits.map((hit) => hit._source); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts index 93b5667b9f629..cd1b77862af04 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts @@ -66,9 +66,7 @@ export const createDetectionIndex = async ( context: SecuritySolutionRequestHandlerContext, siemClient: AppClient ): Promise<void> => { - const clusterClient = context.core.elasticsearch.legacy.client; const esClient = context.core.elasticsearch.client.asCurrentUser; - const callCluster = clusterClient.callAsCurrentUser; if (!siemClient) { throw new CreateIndexError('', 404); @@ -76,20 +74,20 @@ export const createDetectionIndex = async ( const index = siemClient.getSignalsIndex(); await ensureMigrationCleanupPolicy({ alias: index, esClient }); - const policyExists = await getPolicyExists(callCluster, index); + const policyExists = await getPolicyExists(esClient, index); if (!policyExists) { - await setPolicy(callCluster, index, signalsPolicy); + await setPolicy(esClient, index, signalsPolicy); } if (await templateNeedsUpdate({ alias: index, esClient })) { - await setTemplate(callCluster, index, getSignalsTemplate(index)); + await setTemplate(esClient, index, getSignalsTemplate(index)); } - const indexExists = await getIndexExists(callCluster, index); + const indexExists = await getIndexExists(esClient, index); if (indexExists) { - const indexVersion = await getIndexVersion(callCluster, index); + const indexVersion = await getIndexVersion(esClient, index); if (isOutdated({ current: indexVersion, target: SIGNALS_TEMPLATE_VERSION })) { - await callCluster('indices.rollover', { alias: index }); + await esClient.indices.rollover({ alias: index }); } } else { - await createBootstrapIndex(callCluster, index); + await createBootstrapIndex(esClient, index); } }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/delete_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/delete_index_route.ts index d652bd39c49ce..1a4f00a570424 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/delete_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/delete_index_route.ts @@ -38,16 +38,16 @@ export const deleteIndexRoute = (router: SecuritySolutionPluginRouter) => { const siemResponse = buildSiemResponse(response); try { - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client.asCurrentUser; + const siemClient = context.securitySolution?.getAppClient(); if (!siemClient) { return siemResponse.error({ statusCode: 404 }); } - const callCluster = clusterClient.callAsCurrentUser; const index = siemClient.getSignalsIndex(); - const indexExists = await getIndexExists(callCluster, index); + const indexExists = await getIndexExists(esClient, index); if (!indexExists) { return siemResponse.error({ @@ -55,14 +55,14 @@ export const deleteIndexRoute = (router: SecuritySolutionPluginRouter) => { body: `index: "${index}" does not exist`, }); } else { - await deleteAllIndex(callCluster, `${index}-*`); - const policyExists = await getPolicyExists(callCluster, index); + await deleteAllIndex(esClient, `${index}-*`); + const policyExists = await getPolicyExists(esClient, index); if (policyExists) { - await deletePolicy(callCluster, index); + await deletePolicy(esClient, index); } - const templateExists = await getTemplateExists(callCluster, index); + const templateExists = await getTemplateExists(esClient, index); if (templateExists) { - await deleteTemplate(callCluster, index); + await deleteTemplate(esClient, index); } return response.ok({ body: { acknowledged: true } }); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_index_version.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_index_version.ts index 1ef03b1d9e023..5c626cbe33ac1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_index_version.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_index_version.ts @@ -5,8 +5,9 @@ * 2.0. */ +import { ApiResponse } from '@elastic/elasticsearch'; import { get } from 'lodash'; -import { LegacyAPICaller } from '../../../../../../../../src/core/server'; +import { ElasticsearchClient } from '../../../../../../../../src/core/server'; import { readIndex } from '../../index/read_index'; interface IndicesAliasResponse { @@ -20,10 +21,10 @@ interface IndexAliasResponse { } export const getIndexVersion = async ( - callCluster: LegacyAPICaller, + esClient: ElasticsearchClient, index: string ): Promise<number> => { - const indexAlias: IndicesAliasResponse = await callCluster('indices.getAlias', { + const { body: indexAlias }: ApiResponse<IndicesAliasResponse> = await esClient.indices.getAlias({ index, }); const writeIndex = Object.keys(indexAlias).find( @@ -32,6 +33,6 @@ export const getIndexVersion = async ( if (writeIndex === undefined) { return 0; } - const writeIndexMapping = await readIndex(callCluster, writeIndex); - return get(writeIndexMapping, [writeIndex, 'mappings', '_meta', 'version']) ?? 0; + const writeIndexMapping = await readIndex(esClient, writeIndex); + return get(writeIndexMapping, ['body', writeIndex, 'mappings', '_meta', 'version']) ?? 0; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts index 6a2d6c64c211f..01d07f68aa489 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts @@ -26,7 +26,7 @@ export const readIndexRoute = (router: SecuritySolutionPluginRouter) => { const siemResponse = buildSiemResponse(response); try { - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client.asCurrentUser; const siemClient = context.securitySolution?.getAppClient(); if (!siemClient) { @@ -34,12 +34,12 @@ export const readIndexRoute = (router: SecuritySolutionPluginRouter) => { } const index = siemClient.getSignalsIndex(); - const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, index); + const indexExists = await getIndexExists(esClient, index); if (indexExists) { let mappingOutdated: boolean | null = null; try { - const indexVersion = await getIndexVersion(clusterClient.callAsCurrentUser, index); + const indexVersion = await getIndexVersion(esClient, index); mappingOutdated = isOutdated({ current: indexVersion, target: SIGNALS_TEMPLATE_VERSION, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index 50182a795ca93..cf4b0bcf6f2d9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -9,7 +9,6 @@ import { getEmptyFindResult, addPrepackagedRulesRequest, getFindResultWithSingleHit, - getEmptyIndex, getNonEmptyIndex, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, createMockConfig, mockGetCurrentUser } from '../__mocks__'; @@ -21,6 +20,8 @@ import { listMock } from '../../../../../../lists/server/mocks'; import { siemMock } from '../../../../mocks'; import { FrameworkRequest } from '../../../framework'; import { ExceptionListClient } from '../../../../../../lists/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; jest.mock('../../rules/get_prepackaged_rules', () => { return { @@ -101,6 +102,10 @@ describe('add_prepackaged_rules_route', () => { errors: [], }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) + ); addPrepackedRulesRoute(server.router, createMockConfig(), securitySetup); }); @@ -125,8 +130,11 @@ describe('add_prepackaged_rules_route', () => { }); test('it returns a 400 if the index does not exist', async () => { - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); const request = addPrepackagedRulesRequest(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) + ); const response = await server.inject(request, context); expect(response.status).toEqual(400); @@ -179,9 +187,10 @@ describe('add_prepackaged_rules_route', () => { }); test('catches errors if payloads cause errors to be thrown', async () => { - clients.clusterClient.callAsCurrentUser.mockImplementation(() => { - throw new Error('Test error'); - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + elasticsearchClientMock.createErrorTransportRequestPromise(new Error('Test error')) + ); const request = addPrepackagedRulesRequest(); const response = await server.inject(request, context); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index bccf7f4dfffa0..e7e571647cbe4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -106,7 +106,7 @@ export const createPrepackagedRules = async ( maxTimelineImportExportSize: number, exceptionsClient?: ExceptionListClient ): Promise<PrePackagedRulesAndTimelinesSchema | null> => { - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client; const savedObjectsClient = context.core.savedObjects.client; const exceptionsListClient = context.lists != null ? context.lists.getExceptionListClient() : exceptionsClient; @@ -126,7 +126,7 @@ export const createPrepackagedRules = async ( const rulesToUpdate = getRulesToUpdate(rulesFromFileSystem, prepackagedRules); const signalsIndex = siemClient.getSignalsIndex(); if (rulesToInstall.length !== 0 || rulesToUpdate.length !== 0) { - const signalsIndexExists = await getIndexExists(clusterClient.callAsCurrentUser, signalsIndex); + const signalsIndexExists = await getIndexExists(esClient.asCurrentUser, signalsIndex); if (!signalsIndexExists) { throw new PrepackagedRulesError( `Pre-packaged rules cannot be installed until the signals index is created: ${signalsIndex}`, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index bb9f3ca9c9319..c5cbbeb09ed6d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -10,7 +10,6 @@ import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../mach import { buildMlAuthz } from '../../../machine_learning/authz'; import { getReadBulkRequest, - getEmptyIndex, getNonEmptyIndex, getFindResultWithSingleHit, getEmptyFindResult, @@ -20,6 +19,8 @@ import { import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesBulkRoute } from './create_rules_bulk_route'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -37,6 +38,10 @@ describe('create_rules_bulk', () => { clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no existing rules clients.alertsClient.create.mockResolvedValue(getResult()); // successful creation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) + ); createRulesBulkRoute(server.router, ml); }); @@ -84,7 +89,10 @@ describe('create_rules_bulk', () => { }); it('returns an error object if the index does not exist', async () => { - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) + ); const response = await server.inject(getReadBulkRequest(), context); expect(response.status).toEqual(200); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 6b85c7a40743a..e54c9a4cbb03e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -43,7 +43,7 @@ export const createRulesBulkRoute = ( async (context, request, response) => { const siemResponse = buildSiemResponse(response); const alertsClient = context.alerting?.getAlertsClient(); - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client; const savedObjectsClient = context.core.savedObjects.client; const siemClient = context.securitySolution?.getAppClient(); @@ -92,7 +92,7 @@ export const createRulesBulkRoute = ( throwHttpError(await mlAuthz.validateRuleType(internalRule.params.type)); const finalIndex = internalRule.params.outputIndex; - const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, finalIndex); + const indexExists = await getIndexExists(esClient.asCurrentUser, finalIndex); if (!indexExists) { return createBulkErrorObject({ ruleId: internalRule.params.ruleId, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 7b998aa2d4252..dd636d5a180d9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -12,7 +12,6 @@ import { getCreateRequest, getFindResultStatus, getNonEmptyIndex, - getEmptyIndex, getFindResultWithSingleHit, createMlRuleRequest, } from '../__mocks__/request_responses'; @@ -22,6 +21,8 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesRoute } from './create_rules_route'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; jest.mock('../../rules/update_rules_notifications'); jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -40,6 +41,10 @@ describe('create_rules', () => { clients.alertsClient.create.mockResolvedValue(getResult()); // creation succeeds clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // needed to transform + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) + ); createRulesRoute(server.router, ml); }); @@ -102,7 +107,10 @@ describe('create_rules', () => { describe('unhappy paths', () => { test('it returns a 400 if the index does not exist', async () => { - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) + ); const response = await server.inject(getCreateRequest(), context); expect(response.status).toEqual(400); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts index 309d1bdbb1471..95539319b5a12 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -45,7 +45,7 @@ export const createRulesRoute = ( } try { const alertsClient = context.alerting?.getAlertsClient(); - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client; const savedObjectsClient = context.core.savedObjects.client; const siemClient = context.securitySolution?.getAppClient(); @@ -78,7 +78,7 @@ export const createRulesRoute = ( throwHttpError(await mlAuthz.validateRuleType(internalRule.params.type)); const indexExists = await getIndexExists( - clusterClient.callAsCurrentUser, + esClient.asCurrentUser, internalRule.params.outputIndex ); if (!indexExists) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index 4f29f2d0586ee..0a265adf620ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -11,7 +11,6 @@ import { getImportRulesRequestOverwriteTrue, getEmptyFindResult, getResult, - getEmptyIndex, getFindResultWithSingleHit, getNonEmptyIndex, } from '../__mocks__/request_responses'; @@ -25,6 +24,8 @@ import { ruleIdsToNdJsonString, rulesToNdJsonString, } from '../../../../../common/detection_engine/schemas/request/import_rules_schema.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -46,6 +47,10 @@ describe('import_rules_route', () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no extant rules + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) + ); importRulesRoute(server.router, config, ml); }); @@ -124,7 +129,10 @@ describe('import_rules_route', () => { test('returns an error if the index does not exist', async () => { clients.appClient.getSignalsIndex.mockReturnValue('mockSignalsIndex'); - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) + ); const response = await server.inject(request, context); expect(response.status).toEqual(400); expect(response.body).toEqual({ @@ -135,9 +143,12 @@ describe('import_rules_route', () => { }); test('returns an error when cluster throws error', async () => { - clients.clusterClient.callAsCurrentUser.mockImplementation(async () => { - throw new Error('Test error'); - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + elasticsearchClientMock.createErrorTransportRequestPromise({ + body: new Error('Test error'), + }) + ); const response = await server.inject(request, context); expect(response.status).toEqual(500); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 27231ab896b7e..b37cc41f1439e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -77,7 +77,7 @@ export const importRulesRoute = ( try { const alertsClient = context.alerting?.getAlertsClient(); - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client; const savedObjectsClient = context.core.savedObjects.client; const siemClient = context.securitySolution?.getAppClient(); @@ -101,7 +101,7 @@ export const importRulesRoute = ( }); } const signalsIndex = siemClient.getSignalsIndex(); - const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, signalsIndex); + const indexExists = await getIndexExists(esClient.asCurrentUser, signalsIndex); if (!indexExists) { return siemResponse.error({ statusCode: 400, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/convert_saved_search_to_rules.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/convert_saved_search_to_rules.sh deleted file mode 100755 index 65f27647fd43a..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/convert_saved_search_to_rules.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh - -# -# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -# or more contributor license agreements. Licensed under the Elastic License -# 2.0; you may not use this file except in compliance with the Elastic License -# 2.0. -# - -set -e -./check_env_variables.sh - -OUTPUT=${2:-../rules/prepackaged_rules} - -node ../../../../scripts/convert_saved_search_to_rules.js $1 $OUTPUT diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts index 8597667f64657..4b74f865c6a53 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts @@ -20,10 +20,10 @@ describe('create_signals', () => { excludeDocsWithTimestampOverride: false, }); expect(query).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['auditbeat-*'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, body: { docvalue_fields: [ { @@ -66,6 +66,7 @@ describe('create_signals', () => { { '@timestamp': { order: 'asc', + unmapped_type: 'date', }, }, ], @@ -84,10 +85,10 @@ describe('create_signals', () => { excludeDocsWithTimestampOverride: false, }); expect(query).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['auditbeat-*'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, body: { docvalue_fields: [ { @@ -130,6 +131,7 @@ describe('create_signals', () => { { '@timestamp': { order: 'asc', + unmapped_type: 'date', }, }, ], @@ -149,10 +151,10 @@ describe('create_signals', () => { excludeDocsWithTimestampOverride: false, }); expect(query).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['auditbeat-*'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, body: { docvalue_fields: [ { @@ -191,14 +193,15 @@ describe('create_signals', () => { include_unmapped: true, }, ], + search_after: [fakeSortId], sort: [ { '@timestamp': { order: 'asc', + unmapped_type: 'date', }, }, ], - search_after: [fakeSortId], }, }); }); @@ -215,10 +218,10 @@ describe('create_signals', () => { excludeDocsWithTimestampOverride: false, }); expect(query).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['auditbeat-*'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, body: { docvalue_fields: [ { @@ -257,14 +260,15 @@ describe('create_signals', () => { include_unmapped: true, }, ], + search_after: [fakeSortIdNumber], sort: [ { '@timestamp': { order: 'asc', + unmapped_type: 'date', }, }, ], - search_after: [fakeSortIdNumber], }, }); }); @@ -280,10 +284,10 @@ describe('create_signals', () => { excludeDocsWithTimestampOverride: false, }); expect(query).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['auditbeat-*'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, body: { docvalue_fields: [ { @@ -326,6 +330,7 @@ describe('create_signals', () => { { '@timestamp': { order: 'asc', + unmapped_type: 'date', }, }, ], @@ -352,10 +357,10 @@ describe('create_signals', () => { excludeDocsWithTimestampOverride: false, }); expect(query).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['auditbeat-*'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, body: { docvalue_fields: [{ field: '@timestamp', format: 'strict_date_optional_time' }], query: { @@ -400,6 +405,7 @@ describe('create_signals', () => { { '@timestamp': { order: 'asc', + unmapped_type: 'date', }, }, ], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts index f8fd4ed30d6ee..bce9adc9f0f88 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts @@ -73,10 +73,10 @@ export const buildEventsSearchQuery = ({ const filterWithTime = [filter, { bool: { filter: rangeFilter } }]; const searchQuery = { - allowNoIndices: true, + allow_no_indices: true, index, size, - ignoreUnavailable: true, + ignore_unavailable: true, body: { docvalue_fields: docFields, query: { @@ -100,6 +100,7 @@ export const buildEventsSearchQuery = ({ { [sortField]: { order: sortOrder ?? 'asc', + unmapped_type: 'date', }, }, ], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index ead9da533d775..ccefa24e2018c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -26,6 +26,8 @@ import { BulkResponse, RuleRangeTuple } from './types'; import { SearchListItemArraySchema } from '../../../../../lists/common/schemas'; import { getSearchListItemResponseMock } from '../../../../../lists/common/schemas/response/search_list_item_schema.mock'; import { getRuleRangeTuples } from './utils'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -59,9 +61,14 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success with number of searches less than max signals', async () => { - mockService.callCluster - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) - .mockResolvedValueOnce({ + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -72,8 +79,16 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6))) - .mockResolvedValueOnce({ + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -84,8 +99,16 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9))) - .mockResolvedValueOnce({ + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -96,8 +119,16 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(9, 12))) - .mockResolvedValueOnce({ + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(9, 12)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -108,7 +139,13 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsNoSortIdNoHits() + ) + ); const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ @@ -149,15 +186,19 @@ describe('searchAfterAndBulkCreate', () => { buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(9); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(5); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); test('should return success with number of searches less than max signals with gap', async () => { - mockService.callCluster - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) - .mockResolvedValueOnce({ + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) + ) + ); + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -168,8 +209,16 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6))) - .mockResolvedValueOnce({ + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -180,8 +229,16 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9))) - .mockResolvedValueOnce({ + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -192,7 +249,13 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsNoSortIdNoHits() + ) + ); const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ @@ -233,15 +296,20 @@ describe('searchAfterAndBulkCreate', () => { buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(7); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(4); expect(createdSignalsCount).toEqual(3); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); test('should return success when no search results are in the allowlist', async () => { - mockService.callCluster - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) - .mockResolvedValueOnce({ + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -267,7 +335,13 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsNoSortIdNoHits() + ) + ); const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ @@ -308,7 +382,7 @@ describe('searchAfterAndBulkCreate', () => { buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(3); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(2); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -320,16 +394,22 @@ describe('searchAfterAndBulkCreate', () => { { ...getSearchListItemResponseMock(), value: ['3.3.3.3'] }, ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - mockService.callCluster + mockService.scopedClusterClient.asCurrentUser.search .mockResolvedValueOnce( - repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ - '1.1.1.1', - '2.2.2.2', - '2.2.2.2', - '2.2.2.2', - ]) + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '2.2.2.2', + '2.2.2.2', + ]) + ) ) - .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + .mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsNoSortIdNoHits() + ) + ); const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ @@ -370,7 +450,7 @@ describe('searchAfterAndBulkCreate', () => { buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(2); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(2); expect(createdSignalsCount).toEqual(0); // should not create any signals because all events were in the allowlist expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -384,13 +464,15 @@ describe('searchAfterAndBulkCreate', () => { ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - mockService.callCluster.mockResolvedValueOnce( - repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3), [ - '1.1.1.1', - '2.2.2.2', - '2.2.2.2', - '2.2.2.2', - ]) + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '2.2.2.2', + '2.2.2.2', + ]) + ) ); const exceptionItem = getExceptionListItemSchemaMock(); @@ -432,7 +514,7 @@ describe('searchAfterAndBulkCreate', () => { buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(1); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(createdSignalsCount).toEqual(0); // should not create any signals because all events were in the allowlist expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best @@ -443,9 +525,14 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success when no sortId present but search results are in the allowlist', async () => { - mockService.callCluster - .mockResolvedValueOnce(repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3))) - .mockResolvedValueOnce({ + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -470,7 +557,8 @@ describe('searchAfterAndBulkCreate', () => { }, }, ], - }); + }) + ); const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ @@ -511,7 +599,7 @@ describe('searchAfterAndBulkCreate', () => { buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(2); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best @@ -522,9 +610,14 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success when no exceptions list provided', async () => { - mockService.callCluster - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) - .mockResolvedValueOnce({ + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -550,7 +643,13 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsNoSortIdNoHits() + ) + ); listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( @@ -587,7 +686,7 @@ describe('searchAfterAndBulkCreate', () => { buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(3); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(2); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -605,9 +704,14 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - mockService.callCluster - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) - .mockRejectedValue(new Error('bulk failed')); // Added this recently + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) + ) + ); + mockService.scopedClusterClient.asCurrentUser.bulk.mockRejectedValue( + elasticsearchClientMock.createErrorTransportRequestPromise(new Error('bulk failed')) + ); // Added this recently const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], @@ -653,7 +757,9 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - mockService.callCluster.mockResolvedValueOnce(sampleEmptyDocSearchResults()); + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(sampleEmptyDocSearchResults()) + ); listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( value.slice(0, 2).map((item) => ({ @@ -694,18 +800,20 @@ describe('searchAfterAndBulkCreate', () => { }); test('if returns false when singleSearchAfter throws an exception', async () => { - mockService.callCluster - .mockResolvedValueOnce({ - took: 100, - errors: false, - items: [ - { - create: { - status: 201, + mockService.scopedClusterClient.asCurrentUser.search + .mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + took: 100, + errors: false, + items: [ + { + create: { + status: 201, + }, }, - }, - ], - }) + ], + }) + ) .mockImplementation(() => { throw Error('Fake Error'); // throws the exception we are testing }); @@ -781,11 +889,24 @@ describe('searchAfterAndBulkCreate', () => { }, ], }; - mockService.callCluster - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) - .mockResolvedValueOnce(bulkItem) // adds the response with errors we are testing - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6))) - .mockResolvedValueOnce({ + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(bulkItem) + ); // adds the response with errors we are testing + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -796,8 +917,16 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9))) - .mockResolvedValueOnce({ + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -808,8 +937,16 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(9, 12))) - .mockResolvedValueOnce({ + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(9, 12)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -820,7 +957,13 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsNoSortIdNoHits() + ) + ); const { success, createdSignalsCount, @@ -854,15 +997,20 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(false); expect(errors).toEqual(['error on creation']); - expect(mockService.callCluster).toHaveBeenCalledTimes(9); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(5); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); it('invokes the enrichment callback with signal search results', async () => { - mockService.callCluster - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) - .mockResolvedValueOnce({ + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -873,8 +1021,16 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6))) - .mockResolvedValueOnce({ + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -885,8 +1041,16 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9))) - .mockResolvedValueOnce({ + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9)) + ) + ); + + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ took: 100, errors: false, items: [ @@ -897,7 +1061,13 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + ); + + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsNoSortIdNoHits() + ) + ); const mockEnrichment = jest.fn((a) => a); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ @@ -941,7 +1111,7 @@ describe('searchAfterAndBulkCreate', () => { }) ); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(7); + expect(mockService.scopedClusterClient.asCurrentUser.search).toHaveBeenCalledTimes(4); expect(createdSignalsCount).toEqual(3); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index 4079cbb852de4..bcd04ed5e15cd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -30,6 +30,8 @@ import { getExceptionListClientMock } from '../../../../../lists/server/services import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; import { getEntryListMock } from '../../../../../lists/common/schemas/types/entry_list.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -140,11 +142,13 @@ describe('rules_notification_alert_type', () => { ), }; }); - alertServices.callCluster.mockResolvedValue({ - hits: { - total: { value: 10 }, - }, - }); + alertServices.scopedClusterClient.asCurrentUser.transport.request.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + hits: { + total: { value: 10 }, + }, + }) + ); const value: Partial<ApiResponse> = { statusCode: 200, body: { @@ -160,7 +164,9 @@ describe('rules_notification_alert_type', () => { }, }, }; - alertServices.scopedClusterClient.fieldCaps.mockResolvedValue(value as ApiResponse); + alertServices.scopedClusterClient.asCurrentUser.fieldCaps.mockResolvedValue( + value as ApiResponse + ); const ruleAlert = getResult(); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', @@ -665,7 +671,9 @@ describe('rules_notification_alert_type', () => { }); it('and call ruleStatusService with the default message', async () => { - (searchAfterAndBulkCreate as jest.Mock).mockRejectedValue({}); + (searchAfterAndBulkCreate as jest.Mock).mockRejectedValue( + elasticsearchClientMock.createErrorTransportRequestPromise({}) + ); await alert.executor(payload); expect(logger.error).toHaveBeenCalled(); expect(logger.error.mock.calls[0][0]).toContain('An error occurred during rule execution'); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 65efd25c9fba2..cd77cab01bb01 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -12,6 +12,7 @@ import isEmpty from 'lodash/isEmpty'; import { chain, tryCatch } from 'fp-ts/lib/TaskEither'; import { flow } from 'fp-ts/lib/function'; +import { ApiResponse } from '@elastic/elasticsearch'; import { performance } from 'perf_hooks'; import { toError, toPromise } from '../../../../common/fp_utils'; @@ -198,7 +199,7 @@ export const signalRulesAlertType = ({ const inputIndices = await getInputIndex(services, version, index); const [privileges, timestampFieldCaps] = await Promise.all([ checkPrivileges(services, inputIndices), - services.scopedClusterClient.fieldCaps({ + services.scopedClusterClient.asCurrentUser.fieldCaps({ index, fields: hasTimestampOverride ? ['@timestamp', timestampOverride as string] @@ -583,7 +584,10 @@ export const signalRulesAlertType = ({ wroteWarningStatus = true; } try { - const signalIndexVersion = await getIndexVersion(services.callCluster, outputIndex); + const signalIndexVersion = await getIndexVersion( + services.scopedClusterClient.asCurrentUser, + outputIndex + ); if (isOutdated({ current: signalIndexVersion, target: MIN_EQL_RULE_INDEX_VERSION })) { throw new Error( `EQL based rules require an update to version ${MIN_EQL_RULE_INDEX_VERSION} of the detection alerts index mapping` @@ -610,10 +614,11 @@ export const signalRulesAlertType = ({ eventCategoryOverride ); const eqlSignalSearchStart = performance.now(); - const response: EqlSignalSearchResponse = await services.callCluster( - 'transport.request', + const { + body: response, + } = (await services.scopedClusterClient.asCurrentUser.transport.request( request - ); + )) as ApiResponse<EqlSignalSearchResponse>; const eqlSignalSearchEnd = performance.now(); const eqlSearchDuration = makeFloatString(eqlSignalSearchEnd - eqlSignalSearchStart); result.searchAfterTimes = [eqlSearchDuration]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts index 8ade6460cbffc..eecedb02b2687 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -21,6 +21,8 @@ import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; import { singleBulkCreate, filterDuplicateRules } from './single_bulk_create'; import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; import { buildRuleMessageFactory } from './rule_messages'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -139,15 +141,17 @@ describe('singleBulkCreate', () => { test('create successful bulk create', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockResolvedValueOnce({ - took: 100, - errors: false, - items: [ - { - fakeItemValue: 'fakeItemKey', - }, - ], - }); + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + ], + }) + ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleDocSearchResultsNoSortId(), ruleParams: sampleParams, @@ -174,15 +178,17 @@ describe('singleBulkCreate', () => { test('create successful bulk create with docs with no versioning', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockResolvedValueOnce({ - took: 100, - errors: false, - items: [ - { - fakeItemValue: 'fakeItemKey', - }, - ], - }); + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + ], + }) + ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleDocSearchResultsNoSortIdNoVersion(), ruleParams: sampleParams, @@ -209,7 +215,9 @@ describe('singleBulkCreate', () => { test('create unsuccessful bulk create due to empty search results', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockResolvedValue(false); + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(false) + ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleEmptyDocSearchResults(), ruleParams: sampleParams, @@ -237,7 +245,9 @@ describe('singleBulkCreate', () => { test('create successful bulk create when bulk create has duplicate errors', async () => { const sampleParams = sampleRuleAlertParams(); const sampleSearchResult = sampleDocSearchResultsNoSortId; - mockService.callCluster.mockResolvedValue(sampleBulkCreateDuplicateResult); + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(sampleBulkCreateDuplicateResult) + ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleSearchResult(), ruleParams: sampleParams, @@ -267,7 +277,9 @@ describe('singleBulkCreate', () => { test('create failed bulk create when bulk create has multiple error statuses', async () => { const sampleParams = sampleRuleAlertParams(); const sampleSearchResult = sampleDocSearchResultsNoSortId; - mockService.callCluster.mockResolvedValue(sampleBulkCreateErrorResult); + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(sampleBulkCreateErrorResult) + ); const { success, createdItemsCount, errors } = await singleBulkCreate({ filteredEvents: sampleSearchResult(), ruleParams: sampleParams, @@ -335,7 +347,9 @@ describe('singleBulkCreate', () => { test('create successful and returns proper createdItemsCount', async () => { const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockResolvedValue(sampleBulkCreateDuplicateResult); + mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(sampleBulkCreateDuplicateResult) + ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleDocSearchResultsNoSortId(), ruleParams: sampleParams, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index 4d66a1fe7de92..6c791bc4d0ee3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -158,7 +158,7 @@ export const singleBulkCreate = async ({ }), ]); const start = performance.now(); - const response: BulkResponse = await services.callCluster('bulk', { + const { body: response } = await services.scopedClusterClient.asCurrentUser.bulk<BulkResponse>({ index: signalsIndex, refresh, body: bulkBody, @@ -244,7 +244,7 @@ export const bulkInsertSignals = async ( doc._source, ]); const start = performance.now(); - const response: BulkResponse = await services.callCluster('bulk', { + const { body: response } = await services.scopedClusterClient.asCurrentUser.bulk<BulkResponse>({ refresh, body: bulkBody, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts index c3fb5a2b0a739..a325903c66ec0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.test.ts @@ -14,6 +14,8 @@ import { singleSearchAfter } from './single_search_after'; import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; import { ShardError } from '../../types'; import { buildRuleMessageFactory } from './rule_messages'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -29,7 +31,9 @@ describe('singleSearchAfter', () => { }); test('if singleSearchAfter works without a given sort id', async () => { - mockService.callCluster.mockResolvedValue(sampleDocSearchResultsNoSortId()); + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(sampleDocSearchResultsNoSortId()) + ); const { searchResult } = await singleSearchAfter({ searchAfterSortId: undefined, index: [], @@ -46,7 +50,9 @@ describe('singleSearchAfter', () => { expect(searchResult).toEqual(sampleDocSearchResultsNoSortId()); }); test('if singleSearchAfter returns an empty failure array', async () => { - mockService.callCluster.mockResolvedValue(sampleDocSearchResultsNoSortId()); + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(sampleDocSearchResultsNoSortId()) + ); const { searchErrors } = await singleSearchAfter({ searchAfterSortId: undefined, index: [], @@ -80,22 +86,24 @@ describe('singleSearchAfter', () => { }, }, ]; - mockService.callCluster.mockResolvedValue({ - took: 10, - timed_out: false, - _shards: { - total: 10, - successful: 10, - failed: 1, - skipped: 0, - failures: errors, - }, - hits: { - total: 100, - max_score: 100, - hits: [], - }, - }); + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + took: 10, + timed_out: false, + _shards: { + total: 10, + successful: 10, + failed: 1, + skipped: 0, + failures: errors, + }, + hits: { + total: 100, + max_score: 100, + hits: [], + }, + }) + ); const { searchErrors } = await singleSearchAfter({ searchAfterSortId: undefined, index: [], @@ -115,7 +123,11 @@ describe('singleSearchAfter', () => { }); test('if singleSearchAfter works with a given sort id', async () => { const searchAfterSortId = '1234567891111'; - mockService.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId()); + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + sampleDocSearchResultsWithSortId() + ) + ); const { searchResult } = await singleSearchAfter({ searchAfterSortId, index: [], @@ -133,9 +145,9 @@ describe('singleSearchAfter', () => { }); test('if singleSearchAfter throws error', async () => { const searchAfterSortId = '1234567891111'; - mockService.callCluster.mockImplementation(async () => { - throw Error('Fake Error'); - }); + mockService.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createErrorTransportRequestPromise(new Error('Fake Error')) + ); await expect( singleSearchAfter({ searchAfterSortId, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts index f7b30cd7f2e83..b35c68c8deacd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts @@ -72,8 +72,9 @@ export const singleSearchAfter = async ({ }); const start = performance.now(); - const nextSearchAfterResult: SignalSearchResponse = await services.callCluster( - 'search', + const { + body: nextSearchAfterResult, + } = await services.scopedClusterClient.asCurrentUser.search<SignalSearchResponse>( searchAfterQuery ); const end = performance.now(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts index 7d6cd655e336d..3a2a8fcbebf6e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts @@ -34,7 +34,7 @@ export const buildThreatEnrichment = ({ }, }; const threatResponse = await getThreatList({ - callCluster: services.callCluster, + esClient: services.scopedClusterClient.asCurrentUser, exceptionItems, threatFilters: [...threatFilters, matchedThreatsFilter], query: threatQuery, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index 854c2b8f3fdc1..e0be48458b049 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -66,7 +66,7 @@ export const createThreatSignals = async ({ }; let threatListCount = await getThreatListCount({ - callCluster: services.callCluster, + esClient: services.scopedClusterClient.asCurrentUser, exceptionItems, threatFilters, query: threatQuery, @@ -76,7 +76,7 @@ export const createThreatSignals = async ({ logger.debug(buildRuleMessage(`Total indicator items: ${threatListCount}`)); let threatList = await getThreatList({ - callCluster: services.callCluster, + esClient: services.scopedClusterClient.asCurrentUser, exceptionItems, threatFilters, query: threatQuery, @@ -166,7 +166,7 @@ export const createThreatSignals = async ({ logger.debug(buildRuleMessage(`Indicator items left to check are ${threatListCount}`)); threatList = await getThreatList({ - callCluster: services.callCluster, + esClient: services.scopedClusterClient.asCurrentUser, exceptionItems, query: threatQuery, language: threatLanguage, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts index 92d4e5cf8a93b..a2a51d3a060c1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ApiResponse } from '@elastic/elasticsearch'; import { SearchResponse } from 'elasticsearch'; import { getQueryFilter } from '../../../../../common/detection_engine/get_query_filter'; import { @@ -21,7 +22,7 @@ import { export const MAX_PER_PAGE = 9000; export const getThreatList = async ({ - callCluster, + esClient, query, language, index, @@ -52,7 +53,7 @@ export const getThreatList = async ({ `Querying the indicator items from the index: "${index}" with searchAfter: "${searchAfter}" for up to ${calculatedPerPage} indicator items` ) ); - const response: SearchResponse<ThreatListItem> = await callCluster('search', { + const { body: response } = await esClient.search<SearchResponse<ThreatListItem>>({ body: { query: queryFilter, fields: [ @@ -69,7 +70,7 @@ export const getThreatList = async ({ listItemIndex: listClient.getListItemIndex(), }), }, - ignoreUnavailable: true, + ignore_unavailable: true, index, size: calculatedPerPage, }); @@ -108,7 +109,7 @@ export const getSortWithTieBreaker = ({ }; export const getThreatListCount = async ({ - callCluster, + esClient, query, language, threatFilters, @@ -122,13 +123,15 @@ export const getThreatListCount = async ({ index, exceptionItems ); - const response: { + const { + body: response, + }: ApiResponse<{ count: number; - } = await callCluster('count', { + }> = await esClient.count({ body: { query: queryFilter, }, - ignoreUnavailable: true, + ignore_unavailable: true, index, }); return response.count; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index 7d0ab3a2b6d25..0c14f906742d4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -29,7 +29,7 @@ import { AlertServices, } from '../../../../../../alerting/server'; import { ExceptionListItemSchema } from '../../../../../../lists/common/schemas'; -import { ILegacyScopedClusterClient, Logger } from '../../../../../../../../src/core/server'; +import { ElasticsearchClient, Logger } from '../../../../../../../../src/core/server'; import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; @@ -148,7 +148,7 @@ export interface BooleanFilter { } export interface GetThreatListOptions { - callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; + esClient: ElasticsearchClient; query: string; language: ThreatLanguageOrUndefined; index: string[]; @@ -164,7 +164,7 @@ export interface GetThreatListOptions { } export interface ThreatListCountOptions { - callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; + esClient: ElasticsearchClient; query: string; language: ThreatLanguageOrUndefined; threatFilters: PartialFilter[]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 48e3da7404f51..fa2fa1f102bd1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -155,18 +155,20 @@ export const checkPrivileges = async ( services: AlertServices<AlertInstanceState, AlertInstanceContext, 'default'>, indices: string[] ): Promise<Privilege> => - services.callCluster('transport.request', { - path: '/_security/user/_has_privileges', - method: 'POST', - body: { - index: [ - { - names: indices ?? [], - privileges: ['read'], - }, - ], - }, - }); + ( + await services.scopedClusterClient.asCurrentUser.transport.request({ + path: '/_security/user/_has_privileges', + method: 'POST', + body: { + index: [ + { + names: indices ?? [], + privileges: ['read'], + }, + ], + }, + }) + ).body as Privilege; export const getNumCatchupIntervals = ({ gap, @@ -205,7 +207,11 @@ export const getListsClient = ({ throw new Error('lists plugin unavailable during rule execution'); } - const listClient = lists.getListClient(services.callCluster, spaceId, updatedByUser ?? 'elastic'); + const listClient = lists.getListClient( + services.scopedClusterClient.asCurrentUser, + spaceId, + updatedByUser ?? 'elastic' + ); const exceptionsClient = lists.getExceptionListClient( savedObjectClient, updatedByUser ?? 'elastic' diff --git a/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts index cc554b8468d1e..39530ea7b7741 100644 --- a/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts +++ b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts @@ -26,10 +26,10 @@ describe('buildSortedEventsQuery', () => { test('it builds a filter with given date range', () => { expect(buildSortedEventsQuery(query)).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['index-name'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, track_total_hits: false, body: { docvalue_fields: [ @@ -77,10 +77,10 @@ describe('buildSortedEventsQuery', () => { test('it does not include searchAfterSortId if it is an empty string', () => { query.searchAfterSortId = ''; expect(buildSortedEventsQuery(query)).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['index-name'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, track_total_hits: false, body: { docvalue_fields: [ @@ -129,10 +129,10 @@ describe('buildSortedEventsQuery', () => { const sortId = '123456789012'; query.searchAfterSortId = sortId; expect(buildSortedEventsQuery(query)).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['index-name'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, track_total_hits: false, body: { docvalue_fields: [ @@ -182,10 +182,10 @@ describe('buildSortedEventsQuery', () => { const sortId = 123456789012; query.searchAfterSortId = sortId; expect(buildSortedEventsQuery(query)).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['index-name'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, track_total_hits: false, body: { docvalue_fields: [ @@ -240,10 +240,10 @@ describe('buildSortedEventsQuery', () => { }, }; expect(buildSortedEventsQuery(query)).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['index-name'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, track_total_hits: false, body: { docvalue_fields: [ @@ -298,10 +298,10 @@ describe('buildSortedEventsQuery', () => { test('it uses sortOrder if specified', () => { query.sortOrder = 'desc'; expect(buildSortedEventsQuery(query)).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['index-name'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, track_total_hits: false, body: { docvalue_fields: [ @@ -349,10 +349,10 @@ describe('buildSortedEventsQuery', () => { test('it uses track_total_hits if specified', () => { query.track_total_hits = true; expect(buildSortedEventsQuery(query)).toEqual({ - allowNoIndices: true, + allow_no_indices: true, index: ['index-name'], size: 100, - ignoreUnavailable: true, + ignore_unavailable: true, track_total_hits: true, body: { docvalue_fields: [ diff --git a/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts index add3e1f59e20e..a4fb54a06ace8 100644 --- a/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts +++ b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts @@ -53,10 +53,10 @@ export const buildSortedEventsQuery = ({ const filterWithTime = [filter, { bool: { filter: rangeFilter } }]; const searchQuery = { - allowNoIndices: true, + allow_no_indices: true, index, size, - ignoreUnavailable: true, + ignore_unavailable: true, track_total_hits: track_total_hits ?? false, body: { docvalue_fields: docFields, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts index 4adc7c05821f9..66984e46de602 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts @@ -18,6 +18,8 @@ import { getAlertType, ConditionMetAlertInstanceId, ActionGroupId } from './aler import { EsQueryAlertParams, EsQueryAlertState } from './alert_type_params'; import { ActionContext } from './action_context'; import { ESSearchResponse, ESSearchRequest } from '../../../../../../typings/elasticsearch'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from '../../../../../../src/core/server/elasticsearch/client/mocks'; describe('alertType', () => { const logger = loggingSystemMock.create().get(); @@ -132,7 +134,9 @@ describe('alertType', () => { const alertServices: AlertServicesMock = alertsMock.createAlertServices(); const searchResult: ESSearchResponse<unknown, {}> = generateResults([]); - alertServices.callCluster.mockResolvedValueOnce(searchResult); + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(searchResult) + ); const result = await alertType.executor({ alertId: uuid.v4(), @@ -189,7 +193,9 @@ describe('alertType', () => { 'time-field': newestDocumentTimestamp - 2000, }, ]); - alertServices.callCluster.mockResolvedValueOnce(searchResult); + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(searchResult) + ); const result = await alertType.executor({ alertId: uuid.v4(), @@ -240,12 +246,14 @@ describe('alertType', () => { const previousTimestamp = Date.now(); const newestDocumentTimestamp = previousTimestamp + 1000; - alertServices.callCluster.mockResolvedValueOnce( - generateResults([ - { - 'time-field': newestDocumentTimestamp, - }, - ]) + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + generateResults([ + { + 'time-field': newestDocumentTimestamp, + }, + ]) + ) ); const executorOptions = { @@ -300,15 +308,17 @@ describe('alertType', () => { const oldestDocumentTimestamp = Date.now(); - alertServices.callCluster.mockResolvedValueOnce( - generateResults([ - { - 'time-field': oldestDocumentTimestamp, - }, - { - 'time-field': oldestDocumentTimestamp - 1000, - }, - ]) + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + generateResults([ + { + 'time-field': oldestDocumentTimestamp, + }, + { + 'time-field': oldestDocumentTimestamp - 1000, + }, + ]) + ) ); const result = await alertType.executor({ @@ -359,12 +369,14 @@ describe('alertType', () => { const oldestDocumentTimestamp = Date.now(); - alertServices.callCluster.mockResolvedValueOnce( - generateResults([ - { - 'time-field': oldestDocumentTimestamp, - }, - ]) + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + generateResults([ + { + 'time-field': oldestDocumentTimestamp, + }, + ]) + ) ); const executorOptions = { @@ -400,15 +412,17 @@ describe('alertType', () => { }); const newestDocumentTimestamp = oldestDocumentTimestamp + 5000; - alertServices.callCluster.mockResolvedValueOnce( - generateResults([ - { - 'time-field': newestDocumentTimestamp, - }, - { - 'time-field': newestDocumentTimestamp - 1000, - }, - ]) + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + generateResults([ + { + 'time-field': newestDocumentTimestamp, + }, + { + 'time-field': newestDocumentTimestamp - 1000, + }, + ]) + ) ); const secondResult = await alertType.executor({ @@ -443,17 +457,19 @@ describe('alertType', () => { const oldestDocumentTimestamp = Date.now(); - alertServices.callCluster.mockResolvedValueOnce( - generateResults( - [ - { - 'time-field': oldestDocumentTimestamp, - }, - { - 'time-field': oldestDocumentTimestamp - 1000, - }, - ], - true + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + generateResults( + [ + { + 'time-field': oldestDocumentTimestamp, + }, + { + 'time-field': oldestDocumentTimestamp - 1000, + }, + ], + true + ) ) ); @@ -504,18 +520,20 @@ describe('alertType', () => { const oldestDocumentTimestamp = Date.now(); - alertServices.callCluster.mockResolvedValueOnce( - generateResults( - [ - { - 'time-field': oldestDocumentTimestamp, - }, - { - 'time-field': oldestDocumentTimestamp - 1000, - }, - ], - true, - true + alertServices.scopedClusterClient.asCurrentUser.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + generateResults( + [ + { + 'time-field': oldestDocumentTimestamp, + }, + { + 'time-field': oldestDocumentTimestamp - 1000, + }, + ], + true, + true + ) ) ); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts index 7734c59425a16..d1cbeeb46fac0 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -7,7 +7,6 @@ import { i18n } from '@kbn/i18n'; import { Logger } from 'src/core/server'; -import { ESSearchResponse } from '../../../../../../typings/elasticsearch'; import { AlertType, AlertExecutorOptions } from '../../types'; import { ActionContext, EsQueryAlertActionContext, addMessages } from './action_context'; import { @@ -157,7 +156,7 @@ export function getAlertType( const { alertId, name, services, params, state } = options; const previousTimestamp = state.latestTimestamp; - const callCluster = services.callCluster; + const esClient = services.scopedClusterClient.asCurrentUser; const { parsedQuery, dateStart, dateEnd } = getSearchParams(params); const compareFn = ComparatorFns.get(params.thresholdComparator); @@ -215,7 +214,7 @@ export function getAlertType( logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query - ${JSON.stringify(query)}`); - const searchResult: ESSearchResponse<unknown, {}> = await callCluster('search', query); + const { body: searchResult } = await esClient.search(query); if (searchResult.hits.hits.length > 0) { const numMatches = searchResult.hits.total.value; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/es_query_builder.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/es_query_builder.ts index b86a8f9284c97..a416056217442 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/es_query_builder.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/es_query_builder.ts @@ -5,9 +5,10 @@ * 2.0. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; +import { ElasticsearchClient } from 'kibana/server'; import { Logger } from 'src/core/server'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { SearchResponse } from 'elasticsearch'; import { Query, IIndexPattern, @@ -39,7 +40,7 @@ export async function getShapesFilters( boundaryIndexTitle: string, boundaryGeoField: string, geoField: string, - callCluster: ILegacyScopedClusterClient['callAsCurrentUser'], + esClient: ElasticsearchClient, log: Logger, alertId: string, boundaryNameField?: string, @@ -48,7 +49,8 @@ export async function getShapesFilters( const filters: Record<string, unknown> = {}; const shapesIdsNamesMap: Record<string, unknown> = {}; // Get all shapes in index - const boundaryData: SearchResponse<Record<string, unknown>> = await callCluster('search', { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { body: boundaryData }: ApiResponse<Record<string, any>> = await esClient.search({ index: boundaryIndexTitle, body: { size: MAX_SHAPES_QUERY_SIZE, @@ -56,7 +58,7 @@ export async function getShapesFilters( }, }); - boundaryData.hits.hits.forEach(({ _index, _id }) => { + boundaryData.hits.hits.forEach(({ _index, _id }: { _index: string; _id: string }) => { filters[_id] = { geo_shape: { [geoField]: { @@ -101,14 +103,14 @@ export async function executeEsQueryFactory( boundaryNameField?: string; indexQuery?: Query; }, - { callCluster }: { callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] }, + esClient: ElasticsearchClient, log: Logger, shapesFilters: Record<string, unknown> ) { return async ( gteDateTime: Date | null, ltDateTime: Date | null - ): Promise<SearchResponse<unknown> | undefined> => { + ): Promise<ApiResponse<SearchResponse<unknown>> | undefined> => { let esFormattedQuery; if (indexQuery) { const gteEpochDateTime = gteDateTime ? new Date(gteDateTime).getTime() : null; @@ -192,9 +194,9 @@ export async function executeEsQueryFactory( }, }; - let esResult: SearchResponse<unknown> | undefined; + let esResult: ApiResponse<SearchResponse<unknown>> | undefined; try { - esResult = await callCluster('search', esQuery); + ({ body: esResult } = await esClient.search(esQuery)); } catch (err) { log.warn(`${err.message}`); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts index 3f2421529c346..866b25d239db7 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts @@ -6,8 +6,9 @@ */ import _ from 'lodash'; -import { SearchResponse } from 'elasticsearch'; import { Logger } from 'src/core/server'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { SearchResponse } from 'elasticsearch'; import { executeEsQueryFactory, getShapesFilters, OTHER_CATEGORY } from './es_query_builder'; import { AlertServices } from '../../../../alerting/server'; import { @@ -148,17 +149,22 @@ export const getGeoContainmentExecutor = (log: Logger): GeoContainmentAlertType[ params.boundaryIndexTitle, params.boundaryGeoField, params.geoField, - services.callCluster, + services.scopedClusterClient.asCurrentUser, log, alertId, params.boundaryNameField, params.boundaryIndexQuery ); - const executeEsQuery = await executeEsQueryFactory(params, services, log, shapesFilters); + const executeEsQuery = await executeEsQueryFactory( + params, + services.scopedClusterClient.asCurrentUser, + log, + shapesFilters + ); // Start collecting data only on the first cycle - let currentIntervalResults: SearchResponse<unknown> | undefined; + let currentIntervalResults: ApiResponse<SearchResponse<unknown>> | undefined; if (!currIntervalStartTime) { log.debug(`alert ${GEO_CONTAINMENT_ID}:${alertId} alert initialized. Collecting data`); // Consider making first time window configurable? @@ -171,7 +177,7 @@ export const getGeoContainmentExecutor = (log: Logger): GeoContainmentAlertType[ } const currLocationMap: Map<string, LatestEntityLocation[]> = transformResults( - currentIntervalResults, + currentIntervalResults?.body, params.dateField, params.geoField ); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts index 62d95e4ed88d8..429331916ea7d 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts @@ -9,10 +9,10 @@ import _ from 'lodash'; import sampleJsonResponse from './es_sample_response.json'; import sampleJsonResponseWithNesting from './es_sample_response_with_nesting.json'; import { getActiveEntriesAndGenerateAlerts, transformResults } from '../geo_containment'; -import { SearchResponse } from 'elasticsearch'; import { OTHER_CATEGORY } from '../es_query_builder'; import { alertsMock } from '../../../../../alerting/server/mocks'; import { GeoContainmentInstanceContext, GeoContainmentInstanceState } from '../alert_type'; +import { SearchResponse } from 'elasticsearch'; describe('geo_containment', () => { describe('transformResults', () => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index d2c910790ea40..4c0fafc95a579 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -146,7 +146,7 @@ export function getAlertType( ); } - const callCluster = services.callCluster; + const esClient = services.scopedClusterClient.asCurrentUser; const date = new Date().toISOString(); // the undefined values below are for config-schema optional types const queryParams: TimeSeriesQuery = { @@ -166,7 +166,7 @@ export function getAlertType( // console.log(`index_threshold: query: ${JSON.stringify(queryParams, null, 4)}`); const result = await (await data).timeSeriesQuery({ logger, - callCluster, + esClient, query: queryParams, }); logger.debug(`alert ${ID}:${alertId} "${name}" query result: ${JSON.stringify(result)}`); diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/README.md b/x-pack/plugins/telemetry_collection_xpack/schema/README.md index e6145b751e7d8..097cc6c57d88a 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/README.md +++ b/x-pack/plugins/telemetry_collection_xpack/schema/README.md @@ -9,6 +9,7 @@ There are currently 2 files: - `xpack_plugins.json`: The X-Pack related schema for the content that will be nested in `stack_stats.kibana.plugins`. It is automatically generated by `@kbn/telemetry-tools` based on the `schema` property provided by all the registered Usage Collectors via the `usageCollection.makeUsageCollector` API. More details in the [Schema field](../../usage_collection/README.md#schema-field) chapter in the UsageCollection's docs. +- `xpack_monitoring.json`: It declares the payload sent by the monitoring-sourced telemetry. The actual schema for the payload is declared under `properties.monitoringTelemetry.properties.stats.items`, but due to the general behaviour in the `@kbn/telemetry-tools`, it gets nested down in that path. NOTE: Despite its similarities to ES mappings, the intention of these files is not to define any index mappings. They should be considered as a tool to understand the format of the payload that will be sent when reporting telemetry to the Remote Service. diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_monitoring.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_monitoring.json new file mode 100644 index 0000000000000..0e3571f3c4b1a --- /dev/null +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_monitoring.json @@ -0,0 +1,250 @@ +{ + "properties": { + "monitoringTelemetry": { + "properties": { + "stats": { + "type": "array", + "items": { + "properties": { + "timestamp": { + "type": "date" + }, + "cluster_uuid": { + "type": "keyword" + }, + "cluster_name": { + "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "cluster_stats": { + "properties": {} + }, + "stack_stats": { + "properties": { + "logstash": { + "properties": { + "versions": { + "type": "array", + "items": { + "properties": { + "version": { + "type": "keyword" + }, + "count": { + "type": "long" + } + } + } + }, + "count": { + "type": "long" + }, + "cluster_stats": { + "properties": { + "collection_types": { + "properties": { + "DYNAMIC_KEY": { + "type": "long" + } + } + }, + "queues": { + "properties": { + "DYNAMIC_KEY": { + "type": "long" + } + } + }, + "plugins": { + "type": "array", + "items": { + "properties": { + "name": { + "type": "keyword" + }, + "count": { + "type": "long" + } + } + } + }, + "pipelines": { + "properties": { + "count": { + "type": "long" + }, + "batch_size_max": { + "type": "long" + }, + "batch_size_avg": { + "type": "long" + }, + "batch_size_min": { + "type": "long" + }, + "batch_size_total": { + "type": "long" + }, + "workers_max": { + "type": "long" + }, + "workers_avg": { + "type": "long" + }, + "workers_min": { + "type": "long" + }, + "workers_total": { + "type": "long" + }, + "sources": { + "properties": { + "DYNAMIC_KEY": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "beats": { + "properties": { + "versions": { + "properties": { + "DYNAMIC_KEY": { + "type": "long" + } + } + }, + "types": { + "properties": { + "DYNAMIC_KEY": { + "type": "long" + } + } + }, + "outputs": { + "properties": { + "DYNAMIC_KEY": { + "type": "long" + } + } + }, + "queue": { + "properties": { + "DYNAMIC_KEY": { + "type": "long" + } + } + }, + "count": { + "type": "long" + }, + "eventsPublished": { + "type": "long" + }, + "hosts": { + "type": "long" + }, + "input": { + "properties": { + "count": { + "type": "long" + }, + "names": { + "type": "array", + "items": { + "type": "keyword" + } + } + } + }, + "module": { + "properties": { + "count": { + "type": "long" + }, + "names": { + "type": "array", + "items": { + "type": "keyword" + } + } + } + }, + "architecture": { + "properties": { + "count": { + "type": "long" + }, + "architectures": { + "type": "array", + "items": { + "properties": { + "name": { + "type": "keyword" + }, + "architecture": { + "type": "keyword" + }, + "count": { + "type": "long" + } + } + } + } + } + }, + "heartbeat": { + "properties": { + "monitors": { + "type": "long" + }, + "endpoints": { + "type": "long" + }, + "DYNAMIC_KEY": { + "properties": { + "monitors": { + "type": "long" + }, + "endpoints": { + "type": "long" + } + } + } + } + }, + "functionbeat": { + "properties": { + "functions": { + "properties": { + "count": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "collection": { + "type": "keyword" + }, + "collectionSource": { + "type": "keyword" + } + } + } + } + } + } + } +} diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 4a0ab555f8f40..bb9356014e7a3 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -2336,6 +2336,17 @@ } } }, + "kibana_settings": { + "properties": { + "xpack": { + "properties": { + "default_admin_email": { + "type": "text" + } + } + } + } + }, "monitoring": { "properties": { "hasMonitoringData": { @@ -2436,6 +2447,16 @@ } } }, + "csv_searchsource": { + "properties": { + "available": { + "type": "boolean" + }, + "total": { + "type": "long" + } + } + }, "PNG": { "properties": { "available": { @@ -2521,6 +2542,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -2564,6 +2598,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -2607,6 +2654,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -2650,6 +2710,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -2693,6 +2766,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -2736,6 +2822,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -2787,6 +2886,16 @@ } } }, + "csv_searchsource": { + "properties": { + "available": { + "type": "boolean" + }, + "total": { + "type": "long" + } + } + }, "PNG": { "properties": { "available": { @@ -2872,6 +2981,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -2915,6 +3037,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -2958,6 +3093,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -3001,6 +3149,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -3044,6 +3205,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { @@ -3087,6 +3261,19 @@ } } }, + "csv_searchsource": { + "properties": { + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "PNG": { "properties": { "canvas workpad": { diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_root.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_root.json index afadfc1ec9e92..12e5f400e3b37 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_root.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_root.json @@ -5,6 +5,9 @@ "uid": { "type": "keyword" }, + "hkey": { + "type": "long" + }, "issue_date": { "type": "date" }, diff --git a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_license.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_license.ts index 90fdbfe6a894f..64d9aee7b0ac7 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_license.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_license.ts @@ -11,15 +11,17 @@ import { ElasticsearchClient } from 'src/core/server'; export interface ESLicense { status: string; uid: string; + hkey: string; type: string; issue_date: string; issue_date_in_millis: number; expiry_date: string; - expirty_date_in_millis: number; + expiry_date_in_millis: number; max_nodes: number; issued_to: string; issuer: string; start_date_in_millis: number; + max_resource_units: number; } let cachedLicense: ESLicense | undefined; diff --git a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts index fed18bcb461e3..12f2f24502ce0 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts @@ -139,9 +139,9 @@ describe('Telemetry Collection: Get Aggregated Stats', () => { const esClient = mockEsClient(); const usageCollection = mockUsageCollection({ ...kibana, - monitoringTelemetry: [ - { collectionSource: 'monitoring', timestamp: new Date().toISOString() }, - ], + monitoringTelemetry: { + stats: [{ collectionSource: 'monitoring', timestamp: new Date().toISOString() }], + }, }); const context = getContext(); diff --git a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.ts index 2b81c31ff90e4..32e59e01b123d 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.ts @@ -44,7 +44,7 @@ export const getStatsWithXpack: StatsGetter<TelemetryAggregatedStats> = async fu }) .reduce((acc, stats) => { // Concatenate the telemetry reported via monitoring as additional payloads instead of reporting it inside of stack_stats.kibana.plugins.monitoringTelemetry - const monitoringTelemetry = stats.stack_stats.kibana?.plugins?.monitoringTelemetry; + const monitoringTelemetry = stats.stack_stats.kibana?.plugins?.monitoringTelemetry?.stats; if (monitoringTelemetry) { delete stats.stack_stats.kibana!.plugins.monitoringTelemetry; } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 32b749d2d7fa7..43bbc7d935720 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -262,7 +262,6 @@ "charts.colormaps.greysText": "グレー", "charts.colormaps.redsText": "赤", "charts.colormaps.yellowToRedText": "黄色から赤", - "charts.colorPicker.clearColor": "色のクリア", "charts.colorPicker.setColor.screenReaderDescription": "値 {legendDataLabel} の色を設定", "charts.countText": "カウント", "charts.functions.palette.args.colorHelpText": "パレットの色です。{html} カラー名、{hex}、{hsl}、{hsla}、{rgb}、または {rgba} を使用できます。", @@ -1449,8 +1448,6 @@ "devTools.devToolsTitle": "開発ツール", "devTools.k7BreadcrumbsDevToolsLabel": "開発ツール", "devTools.pageTitle": "開発ツール", - "discover.advancedSettings.aggsTermsSizeText": "「可視化」ボタンをクリックした際に、フィールドドロップダウンや Discover サイドバーに可視化される用語の数を設定します。", - "discover.advancedSettings.aggsTermsSizeTitle": "用語数", "discover.advancedSettings.context.defaultSizeText": "コンテキストビューに表示される周りのエントリーの数", "discover.advancedSettings.context.defaultSizeTitle": "コンテキストサイズ", "discover.advancedSettings.context.sizeStepText": "コンテキストサイズを増減させる際の最低単位です", @@ -5001,7 +4998,6 @@ "xpack.apm.agentConfig.configTable.appliedTooltipMessage": "1 つ以上のエージェントにより適用されました", "xpack.apm.agentConfig.configTable.configTable.failurePromptText": "エージェントの構成一覧を取得できませんでした。ユーザーに十分なパーミッションがない可能性があります。", "xpack.apm.agentConfig.configTable.createConfigButtonLabel": "構成の作成", - "xpack.apm.agentConfig.configTable.emptyPromptText": "変更しましょう。Kibana からエージェント構成を直接的に微調整できます。再展開する必要はありません。まず、最初の構成を作成します。", "xpack.apm.agentConfig.configTable.emptyPromptTitle": "構成が見つかりません。", "xpack.apm.agentConfig.configTable.environmentColumnLabel": "サービス環境", "xpack.apm.agentConfig.configTable.lastUpdatedColumnLabel": "最終更新", @@ -17317,7 +17313,6 @@ "xpack.rollupJobs.listBreadcrumbTitle": "ロールアップジョブ", "xpack.rollupJobs.loadAction.errorTitle": "ロールアップジョブを読み込み中にエラーが発生", "xpack.rollupJobs.refreshAction.errorTitle": "ロールアップジョブの更新中にエラーが発生", - "xpack.rollupJobs.rollupIndexPatternsDescription": "ロールアップインデックスを捕捉するインデックスパターンの作成を有効にします。\n それによりロールアップデータに基づくビジュアライゼーションが可能になります。更新\n 変更を適用するにはページ。", "xpack.rollupJobs.rollupIndexPatternsTitle": "ロールアップインデックスパターンを有効にする", "xpack.rollupJobs.startJobsAction.errorTitle": "ロールアップジョブの開始中にエラーが発生", "xpack.rollupJobs.stopJobsAction.errorTitle": "ロールアップジョブの停止中にエラーが発生", @@ -23257,12 +23252,8 @@ "xpack.uptime.synthetics.emptyJourney.message.footer": "表示する詳細情報はありません。", "xpack.uptime.synthetics.emptyJourney.message.heading": "ステップが含まれていませんでした。", "xpack.uptime.synthetics.emptyJourney.title": "ステップがありません。", - "xpack.uptime.synthetics.executedJourney.heading": "概要情報", "xpack.uptime.synthetics.executedStep.errorHeading": "エラー", - "xpack.uptime.synthetics.executedStep.scriptHeading": "スクリプトのステップ", "xpack.uptime.synthetics.executedStep.stackTrace": "スタックトレース", - "xpack.uptime.synthetics.executedStep.stepName": "{stepNumber}. {stepName}", - "xpack.uptime.synthetics.experimentalCallout.title": "実験的機能", "xpack.uptime.synthetics.imageLoadingSpinner.ariaLabel": "画像を示すアニメーションスピナーを読み込んでいます", "xpack.uptime.synthetics.journey.allFailedMessage": "{total}ステップ - すべて失敗またはスキップされました", "xpack.uptime.synthetics.journey.allSucceededMessage": "{total}ステップ - すべて成功しました", @@ -23273,8 +23264,6 @@ "xpack.uptime.synthetics.screenshot.noImageMessage": "画像がありません", "xpack.uptime.synthetics.screenshotDisplay.altText": "名前「{stepName}」のステップのスクリーンショット", "xpack.uptime.synthetics.screenshotDisplay.altTextWithoutName": "スクリーンショット", - "xpack.uptime.synthetics.screenshotDisplay.thumbnailAltText": "名前「{stepName}」のステップのサムネイルスクリーンショット", - "xpack.uptime.synthetics.screenshotDisplay.thumbnailAltTextWithoutName": "サムネイルスクリーンショット", "xpack.uptime.synthetics.statusBadge.failedMessage": "失敗", "xpack.uptime.synthetics.statusBadge.skippedMessage": "スキップ", "xpack.uptime.synthetics.statusBadge.succeededMessage": "成功", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index db3ca3d56ec5a..5d290f4443c8c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -265,7 +265,6 @@ "charts.colormaps.greysText": "灰色", "charts.colormaps.redsText": "红色", "charts.colormaps.yellowToRedText": "黄到红", - "charts.colorPicker.clearColor": "清除颜色", "charts.colorPicker.setColor.screenReaderDescription": "为值 {legendDataLabel} 设置颜色", "charts.countText": "计数", "charts.functions.palette.args.colorHelpText": "调色板颜色。接受 {html} 颜色名称 {hex}、{hsl}、{hsla}、{rgb} 或 {rgba}。", @@ -1457,8 +1456,6 @@ "devTools.devToolsTitle": "开发工具", "devTools.k7BreadcrumbsDevToolsLabel": "开发工具", "devTools.pageTitle": "开发工具", - "discover.advancedSettings.aggsTermsSizeText": "确定在单击“可视化”按钮时将在发现侧边栏的字段下拉列表中可视化多少个词。", - "discover.advancedSettings.aggsTermsSizeTitle": "词数目", "discover.advancedSettings.context.defaultSizeText": "要在上下文视图中显示的周围条目数目", "discover.advancedSettings.context.defaultSizeTitle": "上下文大小", "discover.advancedSettings.context.sizeStepText": "递增或递减上下文大小的步进大小", @@ -5029,7 +5026,6 @@ "xpack.apm.agentConfig.configTable.appliedTooltipMessage": "已至少由一个代理应用", "xpack.apm.agentConfig.configTable.configTable.failurePromptText": "无法获取代理配置列表。您的用户可能没有足够的权限。", "xpack.apm.agentConfig.configTable.createConfigButtonLabel": "创建配置", - "xpack.apm.agentConfig.configTable.emptyPromptText": "让我们改动一下!可以直接从 Kibana 微调代理配置,无需重新部署。首先创建您的第一个配置。", "xpack.apm.agentConfig.configTable.emptyPromptTitle": "未找到任何配置。", "xpack.apm.agentConfig.configTable.environmentColumnLabel": "服务环境", "xpack.apm.agentConfig.configTable.lastUpdatedColumnLabel": "上次更新时间", @@ -17547,7 +17543,6 @@ "xpack.rollupJobs.listBreadcrumbTitle": "汇总/打包作业", "xpack.rollupJobs.loadAction.errorTitle": "加载汇总/打包作业时出错", "xpack.rollupJobs.refreshAction.errorTitle": "刷新汇总/打包作业时出错", - "xpack.rollupJobs.rollupIndexPatternsDescription": "启用用于捕获汇总/打包索引的索引模式的创建,\n 汇总/打包索引反过来基于汇总/打包数据启用可视化。刷新\n 页面以应用更改。", "xpack.rollupJobs.rollupIndexPatternsTitle": "启用汇总索引模式", "xpack.rollupJobs.startJobsAction.errorTitle": "启动汇总/打包作业时出错", "xpack.rollupJobs.stopJobsAction.errorTitle": "停止汇总/打包作业时出错", @@ -23614,12 +23609,8 @@ "xpack.uptime.synthetics.emptyJourney.message.footer": "没有更多可显示的信息。", "xpack.uptime.synthetics.emptyJourney.message.heading": "此过程不包含任何步骤。", "xpack.uptime.synthetics.emptyJourney.title": "没有此过程的任何步骤", - "xpack.uptime.synthetics.executedJourney.heading": "摘要信息", "xpack.uptime.synthetics.executedStep.errorHeading": "错误", - "xpack.uptime.synthetics.executedStep.scriptHeading": "步骤脚本", "xpack.uptime.synthetics.executedStep.stackTrace": "堆栈跟踪", - "xpack.uptime.synthetics.executedStep.stepName": "{stepNumber}:{stepName}", - "xpack.uptime.synthetics.experimentalCallout.title": "实验功能", "xpack.uptime.synthetics.imageLoadingSpinner.ariaLabel": "表示图像正在加载的动画旋转图标", "xpack.uptime.synthetics.journey.allFailedMessage": "{total} 个步骤 - 全部失败或跳过", "xpack.uptime.synthetics.journey.allSucceededMessage": "{total} 个步骤 - 全部成功", @@ -23630,8 +23621,6 @@ "xpack.uptime.synthetics.screenshot.noImageMessage": "没有可用图像", "xpack.uptime.synthetics.screenshotDisplay.altText": "名称为“{stepName}”的步骤的屏幕截图", "xpack.uptime.synthetics.screenshotDisplay.altTextWithoutName": "屏幕截图", - "xpack.uptime.synthetics.screenshotDisplay.thumbnailAltText": "名称为“{stepName}”的步骤的缩略屏幕截图", - "xpack.uptime.synthetics.screenshotDisplay.thumbnailAltTextWithoutName": "缩略屏幕截图", "xpack.uptime.synthetics.statusBadge.failedMessage": "失败", "xpack.uptime.synthetics.statusBadge.skippedMessage": "已跳过", "xpack.uptime.synthetics.statusBadge.succeededMessage": "成功", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts index 3ad3c6ce02372..679bc3d53c40d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts @@ -102,7 +102,7 @@ describe('Jira API', () => { const res = await getIssueTypes({ http, signal: abortCtrl.signal, connectorId: 'test' }); expect(res).toEqual(issueTypesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { body: '{"params":{"subAction":"issueTypes","subActionParams":{}}}', signal: abortCtrl.signal, }); @@ -121,7 +121,7 @@ describe('Jira API', () => { }); expect(res).toEqual(fieldsResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { body: '{"params":{"subAction":"fieldsByIssueType","subActionParams":{"id":"10006"}}}', signal: abortCtrl.signal, }); @@ -140,7 +140,7 @@ describe('Jira API', () => { }); expect(res).toEqual(issuesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { body: '{"params":{"subAction":"issues","subActionParams":{"title":"test issue"}}}', signal: abortCtrl.signal, }); @@ -159,7 +159,7 @@ describe('Jira API', () => { }); expect(res).toEqual(issuesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { body: '{"params":{"subAction":"issue","subActionParams":{"id":"RJ-107"}}}', signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts index 0184a1f0ca2e5..46ea9dea3aa56 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts @@ -17,7 +17,7 @@ export async function getIssueTypes({ signal: AbortSignal; connectorId: string; }): Promise<Record<string, any>> { - return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { body: JSON.stringify({ params: { subAction: 'issueTypes', subActionParams: {} }, }), @@ -36,7 +36,7 @@ export async function getFieldsByIssueType({ connectorId: string; id: string; }): Promise<Record<string, any>> { - return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { body: JSON.stringify({ params: { subAction: 'fieldsByIssueType', subActionParams: { id } }, }), @@ -55,7 +55,7 @@ export async function getIssues({ connectorId: string; title: string; }): Promise<Record<string, any>> { - return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { body: JSON.stringify({ params: { subAction: 'issues', subActionParams: { title } }, }), @@ -74,7 +74,7 @@ export async function getIssue({ connectorId: string; id: string; }): Promise<Record<string, any>> { - return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { body: JSON.stringify({ params: { subAction: 'issue', subActionParams: { id } }, }), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts index 68edff4dc3950..01208f93405d2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts @@ -61,7 +61,7 @@ describe('Resilient API', () => { }); expect(res).toEqual(incidentTypesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { body: '{"params":{"subAction":"incidentTypes","subActionParams":{}}}', signal: abortCtrl.signal, }); @@ -79,7 +79,7 @@ describe('Resilient API', () => { }); expect(res).toEqual(severityResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { body: '{"params":{"subAction":"severity","subActionParams":{}}}', signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.ts index 46077fcaf6890..8ea3c3c63e50f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.ts @@ -17,7 +17,7 @@ export async function getIncidentTypes({ signal: AbortSignal; connectorId: string; }): Promise<Record<string, any>> { - return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { body: JSON.stringify({ params: { subAction: 'incidentTypes', subActionParams: {} }, }), @@ -34,7 +34,7 @@ export async function getSeverity({ signal: AbortSignal; connectorId: string; }): Promise<Record<string, any>> { - return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { body: JSON.stringify({ params: { subAction: 'severity', subActionParams: {} }, }), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts index 9d9b3c5e64909..5c814bbfd6450 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts @@ -61,7 +61,7 @@ describe('ServiceNow API', () => { }); expect(res).toEqual(choicesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { body: '{"params":{"subAction":"getChoices","subActionParams":{"fields":["priority"]}}}', signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index 3e92515e49b49..bb90915591285 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -19,7 +19,7 @@ export async function getChoices({ connectorId: string; fields: string[]; }): Promise<Record<string, any>> { - return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { body: JSON.stringify({ params: { subAction: 'getChoices', subActionParams: { fields } }, }), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts deleted file mode 100644 index bf70e4c5f2408..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ActionConnector, ActionConnectorWithoutId, ActionType } from '../../types'; -import { httpServiceMock } from '../../../../../../src/core/public/mocks'; -import { - createActionConnector, - deleteActions, - loadActionTypes, - loadAllActions, - updateActionConnector, - executeAction, -} from './action_connector_api'; - -const http = httpServiceMock.createStartContract(); - -beforeEach(() => jest.resetAllMocks()); - -describe('loadActionTypes', () => { - test('should call get types API', async () => { - const resolvedValue: ActionType[] = [ - { - id: 'test', - name: 'Test', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadActionTypes({ http }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/actions/list_action_types", - ] - `); - }); -}); - -describe('loadAllActions', () => { - test('should call getAll actions API', async () => { - http.get.mockResolvedValueOnce([]); - - const result = await loadAllActions({ http }); - expect(result).toEqual([]); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/actions", - ] - `); - }); -}); - -describe('createActionConnector', () => { - test('should call create action API', async () => { - const connector: ActionConnectorWithoutId<{}, {}> = { - actionTypeId: 'test', - isPreconfigured: false, - name: 'My test', - config: {}, - secrets: {}, - }; - const resolvedValue: ActionConnector = { ...connector, id: '123' }; - http.post.mockResolvedValueOnce(resolvedValue); - - const result = await createActionConnector({ http, connector }); - expect(result).toEqual(resolvedValue); - expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/actions/action", - Object { - "body": "{\\"actionTypeId\\":\\"test\\",\\"isPreconfigured\\":false,\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}", - }, - ] - `); - }); -}); - -describe('updateActionConnector', () => { - test('should call the update API', async () => { - const id = '123'; - const connector: ActionConnectorWithoutId<{}, {}> = { - actionTypeId: 'test', - isPreconfigured: false, - name: 'My test', - config: {}, - secrets: {}, - }; - const resolvedValue = { ...connector, id }; - http.put.mockResolvedValueOnce(resolvedValue); - - const result = await updateActionConnector({ http, connector, id }); - expect(result).toEqual(resolvedValue); - expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/actions/action/123", - Object { - "body": "{\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}", - }, - ] - `); - }); -}); - -describe('deleteActions', () => { - test('should call delete API per action', async () => { - const ids = ['1', '2', '3']; - - const result = await deleteActions({ ids, http }); - expect(result).toEqual({ errors: [], successes: [undefined, undefined, undefined] }); - expect(http.delete.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/actions/action/1", - ], - Array [ - "/api/actions/action/2", - ], - Array [ - "/api/actions/action/3", - ], - ] - `); - }); -}); - -describe('executeAction', () => { - test('should call execute API', async () => { - const id = '123'; - const params = { - stringParams: 'someString', - numericParams: 123, - }; - - http.post.mockResolvedValueOnce({ - actionId: id, - status: 'ok', - }); - - const result = await executeAction({ id, http, params }); - expect(result).toEqual({ - actionId: id, - status: 'ok', - }); - expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/actions/action/123/_execute", - Object { - "body": "{\\"params\\":{\\"stringParams\\":\\"someString\\",\\"numericParams\\":123}}", - }, - ] - `); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.ts deleted file mode 100644 index 57fb079d97299..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { HttpSetup } from 'kibana/public'; -import { BASE_ACTION_API_PATH } from '../constants'; -import type { ActionConnector, ActionConnectorWithoutId, ActionType } from '../../types'; -import { ActionTypeExecutorResult } from '../../../../../plugins/actions/common'; - -export async function loadActionTypes({ http }: { http: HttpSetup }): Promise<ActionType[]> { - return await http.get(`${BASE_ACTION_API_PATH}/list_action_types`); -} - -export async function loadAllActions({ http }: { http: HttpSetup }): Promise<ActionConnector[]> { - return await http.get(`${BASE_ACTION_API_PATH}`); -} - -export async function createActionConnector({ - http, - connector, -}: { - http: HttpSetup; - connector: Omit<ActionConnectorWithoutId, 'referencedByCount'>; -}): Promise<ActionConnector> { - return await http.post(`${BASE_ACTION_API_PATH}/action`, { - body: JSON.stringify(connector), - }); -} - -export async function updateActionConnector({ - http, - connector, - id, -}: { - http: HttpSetup; - connector: Pick<ActionConnectorWithoutId, 'name' | 'config' | 'secrets'>; - id: string; -}): Promise<ActionConnector> { - return await http.put(`${BASE_ACTION_API_PATH}/action/${id}`, { - body: JSON.stringify({ - name: connector.name, - config: connector.config, - secrets: connector.secrets, - }), - }); -} - -export async function deleteActions({ - ids, - http, -}: { - ids: string[]; - http: HttpSetup; -}): Promise<{ successes: string[]; errors: string[] }> { - const successes: string[] = []; - const errors: string[] = []; - await Promise.all(ids.map((id) => http.delete(`${BASE_ACTION_API_PATH}/action/${id}`))).then( - function (fulfilled) { - successes.push(...fulfilled); - }, - function (rejected) { - errors.push(...rejected); - } - ); - return { successes, errors }; -} - -export async function executeAction({ - id, - params, - http, -}: { - id: string; - http: HttpSetup; - params: Record<string, unknown>; -}): Promise<ActionTypeExecutorResult<unknown>> { - return http.post(`${BASE_ACTION_API_PATH}/action/${id}/_execute`, { - body: JSON.stringify({ params }), - }); -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.test.ts new file mode 100644 index 0000000000000..8815757df6af5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionType } from '../../../types'; +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { loadActionTypes } from './index'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('loadActionTypes', () => { + test('should call get types API', async () => { + const apiResponseValue = [ + { + id: 'test', + name: 'Test', + enabled: true, + enabled_in_config: true, + enabled_in_license: true, + minimum_license_required: 'basic', + }, + ]; + http.get.mockResolvedValueOnce(apiResponseValue); + + const resolvedValue: ActionType[] = [ + { + id: 'test', + name: 'Test', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]; + + const result = await loadActionTypes({ http }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/actions/connector_types", + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.ts new file mode 100644 index 0000000000000..6f7e8b03658e0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.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 { HttpSetup } from 'kibana/public'; + +import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common'; +import { BASE_ACTION_API_PATH } from '../../constants'; +import type { ActionType } from '../../../types'; + +const rewriteResponseRes = (results: Array<AsApiContract<ActionType>>): ActionType[] => { + return results.map((item) => rewriteBodyReq(item)); +}; + +const rewriteBodyReq: RewriteRequestCase<ActionType> = ({ + enabled_in_config: enabledInConfig, + enabled_in_license: enabledInLicense, + minimum_license_required: minimumLicenseRequired, + ...res +}: AsApiContract<ActionType>) => ({ + enabledInConfig, + enabledInLicense, + minimumLicenseRequired, + ...res, +}); + +export async function loadActionTypes({ http }: { http: HttpSetup }): Promise<ActionType[]> { + const res = await http.get(`${BASE_ACTION_API_PATH}/connector_types`); + return rewriteResponseRes(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.test.ts new file mode 100644 index 0000000000000..565cc0afebfea --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { loadAllActions } from './index'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('loadAllActions', () => { + test('should call getAll actions API', async () => { + http.get.mockResolvedValueOnce([]); + + const result = await loadAllActions({ http }); + expect(result).toEqual([]); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/actions/connectors", + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.ts new file mode 100644 index 0000000000000..cf424ea1e7317 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { HttpSetup } from 'kibana/public'; +import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common'; +import { BASE_ACTION_API_PATH } from '../../constants'; +import type { ActionConnector, ActionConnectorProps } from '../../../types'; + +const rewriteResponseRes = ( + results: Array< + AsApiContract<ActionConnectorProps<Record<string, unknown>, Record<string, unknown>>> + > +): Array<ActionConnectorProps<Record<string, unknown>, Record<string, unknown>>> => { + return results.map((item) => transformConnector(item)); +}; + +const transformConnector: RewriteRequestCase< + ActionConnectorProps<Record<string, unknown>, Record<string, unknown>> +> = ({ + connector_type_id: actionTypeId, + is_preconfigured: isPreconfigured, + referenced_by_count: referencedByCount, + ...res +}) => ({ + actionTypeId, + isPreconfigured, + referencedByCount, + ...res, +}); + +export async function loadAllActions({ http }: { http: HttpSetup }): Promise<ActionConnector[]> { + const res = await http.get(`${BASE_ACTION_API_PATH}/connectors`); + return rewriteResponseRes(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/create.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/create.test.ts new file mode 100644 index 0000000000000..208970fbfc061 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/create.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 { ActionConnectorWithoutId } from '../../../types'; +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { createActionConnector } from './index'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('createActionConnector', () => { + test('should call create action API', async () => { + const apiResponse = { + connector_type_id: 'test', + is_preconfigured: false, + name: 'My test', + config: {}, + secrets: {}, + id: '123', + }; + http.post.mockResolvedValueOnce(apiResponse); + + const connector: ActionConnectorWithoutId<{}, {}> = { + actionTypeId: 'test', + isPreconfigured: false, + name: 'My test', + config: {}, + secrets: {}, + }; + const resolvedValue = { ...connector, id: '123' }; + + const result = await createActionConnector({ http, connector }); + expect(result).toEqual(resolvedValue); + expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/actions/connector", + Object { + "body": "{\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{},\\"connector_type_id\\":\\"test\\",\\"is_preconfigured\\":false}", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/create.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/create.ts new file mode 100644 index 0000000000000..e6e74f3f3c059 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/create.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { HttpSetup } from 'kibana/public'; +import { RewriteRequestCase, RewriteResponseCase } from '../../../../../actions/common'; +import { BASE_ACTION_API_PATH } from '../../constants'; +import type { + ActionConnector, + ActionConnectorProps, + ActionConnectorWithoutId, +} from '../../../types'; + +const rewriteBodyRequest: RewriteResponseCase< + Omit<ActionConnectorWithoutId, 'referencedByCount'> +> = ({ actionTypeId, isPreconfigured, ...res }) => ({ + ...res, + connector_type_id: actionTypeId, + is_preconfigured: isPreconfigured, +}); + +const rewriteBodyRes: RewriteRequestCase< + ActionConnectorProps<Record<string, unknown>, Record<string, unknown>> +> = ({ connector_type_id: actionTypeId, is_preconfigured: isPreconfigured, ...res }) => ({ + ...res, + actionTypeId, + isPreconfigured, +}); + +export async function createActionConnector({ + http, + connector, +}: { + http: HttpSetup; + connector: Omit<ActionConnectorWithoutId, 'referencedByCount'>; +}): Promise<ActionConnector> { + const res = await http.post(`${BASE_ACTION_API_PATH}/connector`, { + body: JSON.stringify(rewriteBodyRequest(connector)), + }); + return rewriteBodyRes(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.test.ts new file mode 100644 index 0000000000000..bb00c8c30e4ed --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { deleteActions } from './index'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('deleteActions', () => { + test('should call delete API per action', async () => { + const ids = ['1', '2', '3']; + + const result = await deleteActions({ ids, http }); + expect(result).toEqual({ errors: [], successes: [undefined, undefined, undefined] }); + expect(http.delete.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/actions/connector/1", + ], + Array [ + "/api/actions/connector/2", + ], + Array [ + "/api/actions/connector/3", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.ts new file mode 100644 index 0000000000000..c9c25db676a06 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.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 { HttpSetup } from 'kibana/public'; +import { BASE_ACTION_API_PATH } from '../../constants'; + +export async function deleteActions({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise<{ successes: string[]; errors: string[] }> { + const successes: string[] = []; + const errors: string[] = []; + await Promise.all(ids.map((id) => http.delete(`${BASE_ACTION_API_PATH}/connector/${id}`))).then( + function (fulfilled) { + successes.push(...fulfilled); + }, + function (rejected) { + errors.push(...rejected); + } + ); + return { successes, errors }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.test.ts new file mode 100644 index 0000000000000..60cd3132aa756 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { executeAction } from './index'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('executeAction', () => { + test('should call execute API', async () => { + const id = '123'; + const params = { + stringParams: 'someString', + numericParams: 123, + }; + + http.post.mockResolvedValueOnce({ + connector_id: id, + status: 'ok', + }); + + const result = await executeAction({ id, http, params }); + expect(result).toEqual({ + actionId: id, + status: 'ok', + }); + expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/actions/connector/123/_execute", + Object { + "body": "{\\"params\\":{\\"stringParams\\":\\"someString\\",\\"numericParams\\":123}}", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.ts new file mode 100644 index 0000000000000..638ceddb5652f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from 'kibana/public'; +import { + ActionTypeExecutorResult, + RewriteRequestCase, +} from '../../../../../../plugins/actions/common'; +import { BASE_ACTION_API_PATH } from '../../constants'; + +const rewriteBodyRes: RewriteRequestCase<ActionTypeExecutorResult<unknown>> = ({ + connector_id: actionId, + service_message: serviceMessage, + ...res +}) => ({ + ...res, + actionId, + serviceMessage, +}); + +export async function executeAction({ + id, + params, + http, +}: { + id: string; + http: HttpSetup; + params: Record<string, unknown>; +}): Promise<ActionTypeExecutorResult<unknown>> { + const res = await http.post(`${BASE_ACTION_API_PATH}/connector/${id}/_execute`, { + body: JSON.stringify({ params }), + }); + return rewriteBodyRes(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/index.ts new file mode 100644 index 0000000000000..7cc4f1df6a735 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { loadActionTypes } from './connector_types'; +export { loadAllActions } from './connectors'; +export { createActionConnector } from './create'; +export { deleteActions } from './delete'; +export { executeAction } from './execute'; +export { updateActionConnector } from './update'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.test.ts new file mode 100644 index 0000000000000..29e7a1e4bed3d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionConnectorWithoutId } from '../../../types'; +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { updateActionConnector } from './index'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('updateActionConnector', () => { + test('should call the update API', async () => { + const id = '123'; + const apiResponse = { + connector_type_id: 'test', + is_preconfigured: false, + name: 'My test', + config: {}, + secrets: {}, + id, + }; + http.put.mockResolvedValueOnce(apiResponse); + + const connector: ActionConnectorWithoutId<{}, {}> = { + actionTypeId: 'test', + isPreconfigured: false, + name: 'My test', + config: {}, + secrets: {}, + }; + const resolvedValue = { ...connector, id }; + + const result = await updateActionConnector({ http, connector, id }); + expect(result).toEqual(resolvedValue); + expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/actions/connector/123", + Object { + "body": "{\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.ts new file mode 100644 index 0000000000000..18b8871ce25d1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { HttpSetup } from 'kibana/public'; +import { RewriteRequestCase } from '../../../../../actions/common'; +import { BASE_ACTION_API_PATH } from '../../constants'; +import type { + ActionConnector, + ActionConnectorProps, + ActionConnectorWithoutId, +} from '../../../types'; + +const rewriteBodyRes: RewriteRequestCase< + ActionConnectorProps<Record<string, unknown>, Record<string, unknown>> +> = ({ connector_type_id: actionTypeId, is_preconfigured: isPreconfigured, ...res }) => ({ + ...res, + actionTypeId, + isPreconfigured, +}); + +export async function updateActionConnector({ + http, + connector, + id, +}: { + http: HttpSetup; + connector: Pick<ActionConnectorWithoutId, 'name' | 'config' | 'secrets'>; + id: string; +}): Promise<ActionConnector> { + const res = await http.put(`${BASE_ACTION_API_PATH}/connector/${id}`, { + body: JSON.stringify({ + name: connector.name, + config: connector.config, + secrets: connector.secrets, + }), + }); + + return rewriteBodyRes(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 5c5dcf344b10b..cf2dda203bb2d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -121,7 +121,7 @@ export interface ConnectorValidationResult<Config, Secrets> { secrets?: GenericValidationResult<Secrets>; } -interface ActionConnectorProps<Config, Secrets> { +export interface ActionConnectorProps<Config, Secrets> { secrets: Secrets; id: string; actionTypeId: string; diff --git a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.test.ts b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.test.ts index fd0ea1bc9e8f5..86d18d98fa0e1 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.test.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.test.ts @@ -9,6 +9,8 @@ import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { TimeSeriesQueryParameters, TimeSeriesQuery, timeSeriesQuery } from './time_series_query'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from '../../../../../../src/core/server/elasticsearch/client/mocks'; const DefaultQueryParams: TimeSeriesQuery = { index: 'index-name', @@ -27,19 +29,18 @@ const DefaultQueryParams: TimeSeriesQuery = { describe('timeSeriesQuery', () => { let params: TimeSeriesQueryParameters; - const mockCallCluster = jest.fn(); + const esClient = elasticsearchClientMock.createClusterClient().asScoped().asCurrentUser; beforeEach(async () => { - mockCallCluster.mockReset(); params = { logger: loggingSystemMock.create().get(), - callCluster: mockCallCluster, + esClient, query: DefaultQueryParams, }; }); it('fails as expected when the callCluster call fails', async () => { - mockCallCluster.mockRejectedValue(new Error('woopsie')); + esClient.search = jest.fn().mockRejectedValue(new Error('woopsie')); expect(timeSeriesQuery(params)).rejects.toThrowErrorMatchingInlineSnapshot( `"error running search"` ); diff --git a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts index 106f665640e41..78462d9969929 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts @@ -6,8 +6,7 @@ */ import { SearchResponse } from 'elasticsearch'; -import { Logger } from 'kibana/server'; -import { LegacyScopedClusterClient } from '../../../../../../src/core/server'; +import { Logger, ElasticsearchClient } from 'kibana/server'; import { DEFAULT_GROUPS } from '../index'; import { getDateRangeInfo } from './date_range_info'; @@ -16,14 +15,14 @@ export { TimeSeriesQuery, TimeSeriesResult } from './time_series_types'; export interface TimeSeriesQueryParameters { logger: Logger; - callCluster: LegacyScopedClusterClient['callAsCurrentUser']; + esClient: ElasticsearchClient; query: TimeSeriesQuery; } export async function timeSeriesQuery( params: TimeSeriesQueryParameters ): Promise<TimeSeriesResult> { - const { logger, callCluster, query: queryParams } = params; + const { logger, esClient, query: queryParams } = params; const { index, timeWindowSize, @@ -59,9 +58,8 @@ export async function timeSeriesQuery( }, // aggs: {...}, filled in below }, - ignoreUnavailable: true, - allowNoIndices: true, - ignore: [404], + ignore_unavailable: true, + allow_no_indices: true, }; // add the aggregations @@ -127,17 +125,16 @@ export async function timeSeriesQuery( }; } - let esResult: SearchResponse<unknown>; const logPrefix = 'indexThreshold timeSeriesQuery: callCluster'; logger.debug(`${logPrefix} call: ${JSON.stringify(esQuery)}`); - + let esResult: SearchResponse<unknown>; // note there are some commented out console.log()'s below, which are left // in, as they are VERY useful when debugging these queries; debug logging // isn't as nice since it's a single long JSON line. // console.log('time_series_query.ts request\n', JSON.stringify(esQuery, null, 4)); try { - esResult = await callCluster('search', esQuery); + esResult = (await esClient.search<SearchResponse<unknown>>(esQuery, { ignore: [404] })).body; } catch (err) { // console.log('time_series_query.ts error\n', JSON.stringify(err, null, 4)); logger.warn(`${logPrefix} error: ${err.message}`); diff --git a/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts b/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts index e5cafdd8a0ad7..6a3b5a0c44aba 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts @@ -12,7 +12,7 @@ import { KibanaRequest, IKibanaResponse, KibanaResponseFactory, - ILegacyScopedClusterClient, + ElasticsearchClient, } from 'kibana/server'; import { Logger } from '../../../../../../src/core/server'; @@ -49,7 +49,10 @@ export function createFieldsRoute(logger: Logger, router: IRouter, baseRoute: st } try { - rawFields = await getRawFields(ctx.core.elasticsearch.legacy.client, req.body.indexPatterns); + rawFields = await getRawFields( + ctx.core.elasticsearch.client.asCurrentUser, + req.body.indexPatterns + ); } catch (err) { const indexPatterns = req.body.indexPatterns.join(','); logger.warn( @@ -90,19 +93,15 @@ interface Field { aggregatable: boolean; } -async function getRawFields( - dataClient: ILegacyScopedClusterClient, - indexes: string[] -): Promise<RawFields> { +async function getRawFields(esClient: ElasticsearchClient, indexes: string[]): Promise<RawFields> { const params = { index: indexes, fields: ['*'], - ignoreUnavailable: true, - allowNoIndices: true, - ignore: 404, + ignore_unavailable: true, + allow_no_indices: true, }; - const result = await dataClient.callAsCurrentUser('fieldCaps', params); - return result as RawFields; + const result = await esClient.fieldCaps(params); + return result.body as RawFields; } function getFieldsFromRawFields(rawFields: RawFields): Field[] { diff --git a/x-pack/plugins/triggers_actions_ui/server/data/routes/indices.ts b/x-pack/plugins/triggers_actions_ui/server/data/routes/indices.ts index 13d6892f37c1b..c029f5b8bdaed 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/routes/indices.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/routes/indices.ts @@ -17,9 +17,8 @@ import { KibanaRequest, IKibanaResponse, KibanaResponseFactory, - ILegacyScopedClusterClient, + ElasticsearchClient, } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; import { Logger } from '../../../../../../src/core/server'; const bodySchema = schema.object({ @@ -54,14 +53,14 @@ export function createIndicesRoute(logger: Logger, router: IRouter, baseRoute: s let aliases: string[] = []; try { - aliases = await getAliasesFromPattern(ctx.core.elasticsearch.legacy.client, pattern); + aliases = await getAliasesFromPattern(ctx.core.elasticsearch.client.asCurrentUser, pattern); } catch (err) { logger.warn(`route ${path} error getting aliases from pattern "${pattern}": ${err.message}`); } let indices: string[] = []; try { - indices = await getIndicesFromPattern(ctx.core.elasticsearch.legacy.client, pattern); + indices = await getIndicesFromPattern(ctx.core.elasticsearch.client.asCurrentUser, pattern); } catch (err) { logger.warn(`route ${path} error getting indices from pattern "${pattern}": ${err.message}`); } @@ -81,13 +80,12 @@ function uniqueCombined(list1: string[], list2: string[], limit: number) { } async function getIndicesFromPattern( - dataClient: ILegacyScopedClusterClient, + esClient: ElasticsearchClient, pattern: string ): Promise<string[]> { const params = { index: pattern, - ignore: [404], - ignoreUnavailable: true, + ignore_unavailable: true, body: { size: 0, // no hits aggs: { @@ -100,7 +98,7 @@ async function getIndicesFromPattern( }, }, }; - const response: SearchResponse<unknown> = await dataClient.callAsCurrentUser('search', params); + const { body: response } = await esClient.search(params); // TODO: Investigate when the status field might appear here, type suggests it shouldn't ever happen // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((response as any).status === 404 || !response.aggregations) { @@ -111,17 +109,16 @@ async function getIndicesFromPattern( } async function getAliasesFromPattern( - dataClient: ILegacyScopedClusterClient, + esClient: ElasticsearchClient, pattern: string ): Promise<string[]> { const params = { index: pattern, - ignoreUnavailable: true, - ignore: [404], + ignore_unavailable: true, }; const result: string[] = []; - const response = await dataClient.callAsCurrentUser('indices.getAlias', params); + const { body: response } = await esClient.indices.getAlias(params); if (response.status === 404) { return result; diff --git a/x-pack/plugins/triggers_actions_ui/server/data/routes/time_series_query.ts b/x-pack/plugins/triggers_actions_ui/server/data/routes/time_series_query.ts index dc94d56623bba..da6638db2e457 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/routes/time_series_query.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/routes/time_series_query.ts @@ -44,7 +44,7 @@ export function createTimeSeriesQueryRoute( const result = await timeSeriesQuery({ logger, - callCluster: ctx.core.elasticsearch.legacy.client.callAsCurrentUser, + esClient: ctx.core.elasticsearch.client.asCurrentUser, query: req.body, }); diff --git a/x-pack/plugins/uptime/common/constants/ui.ts b/x-pack/plugins/uptime/common/constants/ui.ts index 880bc0f92ddf6..dcaf4bb310ad7 100644 --- a/x-pack/plugins/uptime/common/constants/ui.ts +++ b/x-pack/plugins/uptime/common/constants/ui.ts @@ -15,6 +15,8 @@ export const CERTIFICATES_ROUTE = '/certificates'; export const STEP_DETAIL_ROUTE = '/journey/:checkGroupId/step/:stepIndex'; +export const SYNTHETIC_CHECK_STEPS_ROUTE = '/journey/:checkGroupId/steps'; + export enum STATUS { UP = 'up', DOWN = 'down', diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index 8991d52f6a920..77b9473f2912e 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -216,6 +216,7 @@ export const PingType = t.intersection([ type: t.string, url: t.string, end: t.number, + text: t.string, }), }), tags: t.array(t.string), @@ -251,6 +252,7 @@ export const SyntheticsJourneyApiResponseType = t.intersection([ t.intersection([ t.type({ timestamp: t.string, + journey: PingType, }), t.partial({ next: t.type({ diff --git a/x-pack/plugins/uptime/public/components/common/step_detail_link.tsx b/x-pack/plugins/uptime/public/components/common/step_detail_link.tsx index 313dd18e67c11..fa6d0b4c3f8bb 100644 --- a/x-pack/plugins/uptime/public/components/common/step_detail_link.tsx +++ b/x-pack/plugins/uptime/public/components/common/step_detail_link.tsx @@ -6,7 +6,7 @@ */ import React, { FC } from 'react'; -import { ReactRouterEuiButton } from './react_router_helpers'; +import { ReactRouterEuiButtonEmpty } from './react_router_helpers'; interface StepDetailLinkProps { /** @@ -23,14 +23,8 @@ export const StepDetailLink: FC<StepDetailLinkProps> = ({ children, checkGroupId const to = `/journey/${checkGroupId}/step/${stepIndex}`; return ( - <ReactRouterEuiButton - data-test-subj={`step-detail-link`} - to={to} - size="s" - fill - fullWidth={false} - > + <ReactRouterEuiButtonEmpty data-test-subj={`step-detail-link`} to={to}> {children} - </ReactRouterEuiButton> + </ReactRouterEuiButtonEmpty> ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.tsx index 390a133b1819b..3b0aad721be8a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.tsx @@ -6,7 +6,7 @@ */ import { EuiButtonIcon, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; -import React from 'react'; +import React, { MouseEvent } from 'react'; import { nextAriaLabel, prevAriaLabel } from './translations'; export interface NavButtonsProps { @@ -34,8 +34,9 @@ export const NavButtons: React.FC<NavButtonsProps> = ({ disabled={stepNumber === 1} color="subdued" size="s" - onClick={() => { + onClick={(evt: MouseEvent<HTMLButtonElement>) => { setStepNumber(stepNumber - 1); + evt.stopPropagation(); }} iconType="arrowLeft" aria-label={prevAriaLabel} @@ -46,8 +47,9 @@ export const NavButtons: React.FC<NavButtonsProps> = ({ disabled={stepNumber === maxSteps} color="subdued" size="s" - onClick={() => { + onClick={(evt: MouseEvent<HTMLButtonElement>) => { setStepNumber(stepNumber + 1); + evt.stopPropagation(); }} iconType="arrowRight" aria-label={nextAriaLabel} diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx index 2a1989cafa434..d628b2d8388f9 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx @@ -12,6 +12,8 @@ import { mockReduxHooks } from '../../../../../lib/helper/test_helpers'; import { render } from '../../../../../lib/helper/rtl_helpers'; import { Ping } from '../../../../../../common/runtime_types/ping'; import * as observabilityPublic from '../../../../../../../observability/public'; +import { getShortTimeStamp } from '../../../../overview/monitor_list/columns/monitor_status_column'; +import moment from 'moment'; mockReduxHooks(); @@ -68,7 +70,7 @@ describe('Ping Timestamp component', () => { .spyOn(observabilityPublic, 'useFetcher') .mockReturnValue({ status: fetchStatus, data: null, refetch: () => null }); const { getByTestId } = render( - <PingTimestamp ping={response} timestamp={response.timestamp} /> + <PingTimestamp ping={response} label={getShortTimeStamp(moment(response.timestamp))} /> ); expect(getByTestId('pingTimestampSpinner')).toBeInTheDocument(); } @@ -79,7 +81,7 @@ describe('Ping Timestamp component', () => { .spyOn(observabilityPublic, 'useFetcher') .mockReturnValue({ status: FETCH_STATUS.SUCCESS, data: null, refetch: () => null }); const { getByTestId } = render( - <PingTimestamp ping={response} timestamp={response.timestamp} /> + <PingTimestamp ping={response} label={getShortTimeStamp(moment(response.timestamp))} /> ); expect(getByTestId('pingTimestampNoImageAvailable')).toBeInTheDocument(); }); @@ -91,7 +93,9 @@ describe('Ping Timestamp component', () => { data: { src }, refetch: () => null, }); - const { container } = render(<PingTimestamp ping={response} timestamp={response.timestamp} />); + const { container } = render( + <PingTimestamp ping={response} label={getShortTimeStamp(moment(response.timestamp))} /> + ); expect(container.querySelector('img')?.src).toBe(src); }); @@ -103,7 +107,7 @@ describe('Ping Timestamp component', () => { refetch: () => null, }); const { getByAltText, getAllByText, queryByAltText } = render( - <PingTimestamp ping={response} timestamp={response.timestamp} /> + <PingTimestamp ping={response} label={getShortTimeStamp(moment(response.timestamp))} /> ); const caption = getAllByText('Nov 26, 2020 10:28:56 AM'); fireEvent.mouseEnter(caption[0]); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx index cfb92dd31190e..16553e9de8604 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx @@ -8,18 +8,15 @@ import React, { useContext, useEffect, useState } from 'react'; import useIntersection from 'react-use/lib/useIntersection'; import styled from 'styled-components'; -import moment from 'moment'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Ping } from '../../../../../../common/runtime_types/ping'; import { useFetcher, FETCH_STATUS } from '../../../../../../../observability/public'; import { getJourneyScreenshot } from '../../../../../state/api/journey'; import { UptimeSettingsContext } from '../../../../../contexts'; -import { NavButtons } from './nav_buttons'; import { NoImageDisplay } from './no_image_display'; import { StepImageCaption } from './step_image_caption'; import { StepImagePopover } from './step_image_popover'; import { formatCaptionContent } from './translations'; -import { getShortTimeStamp } from '../../../../overview/monitor_list/columns/monitor_status_column'; const StepDiv = styled.div` figure.euiImage { @@ -27,25 +24,16 @@ const StepDiv = styled.div` display: none; } } - - position: relative; - div.stepArrows { - display: none; - } - :hover { - div.stepArrows { - display: flex; - } - } `; interface Props { - timestamp: string; + label?: string; ping: Ping; + initialStepNo?: number; } -export const PingTimestamp = ({ timestamp, ping }: Props) => { - const [stepNumber, setStepNumber] = useState(1); +export const PingTimestamp = ({ label, ping, initialStepNo = 1 }: Props) => { + const [stepNumber, setStepNumber] = useState(initialStepNo); const [isImagePopoverOpen, setIsImagePopoverOpen] = useState(false); const [stepImages, setStepImages] = useState<string[]>([]); @@ -77,6 +65,8 @@ export const PingTimestamp = ({ timestamp, ping }: Props) => { const captionContent = formatCaptionContent(stepNumber, data?.maxSteps); + const [numberOfCaptions, setNumberOfCaptions] = useState(0); + const ImageCaption = ( <StepImageCaption captionContent={captionContent} @@ -84,11 +74,24 @@ export const PingTimestamp = ({ timestamp, ping }: Props) => { maxSteps={data?.maxSteps} setStepNumber={setStepNumber} stepNumber={stepNumber} - timestamp={timestamp} isLoading={status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING} + label={label} + onVisible={(val) => setNumberOfCaptions((prevVal) => (val ? prevVal + 1 : prevVal - 1))} /> ); + useEffect(() => { + // This is a hack to get state if image is in full screen, we should refactor + // it once eui image exposes it's full screen state + // we are checking if number of captions are 2, that means + // image is in full screen mode since caption is also rendered on + // full screen image + // we dont want to change image displayed in thumbnail + if (numberOfCaptions === 1 && stepNumber !== initialStepNo) { + setStepNumber(initialStepNo); + } + }, [numberOfCaptions, initialStepNo, stepNumber]); + return ( <EuiFlexGroup alignItems="center"> <EuiFlexItem grow={false}> @@ -111,16 +114,10 @@ export const PingTimestamp = ({ timestamp, ping }: Props) => { isPending={status === FETCH_STATUS.PENDING} /> )} - <NavButtons - maxSteps={data?.maxSteps} - setIsImagePopoverOpen={setIsImagePopoverOpen} - setStepNumber={setStepNumber} - stepNumber={stepNumber} - /> </StepDiv> </EuiFlexItem> <EuiFlexItem grow={false}> - <span className="eui-textNoWrap">{getShortTimeStamp(moment(timestamp))}</span> + <span className="eui-textNoWrap">{label}</span> </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx index a33e587093279..5c2c4d3669e79 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.test.tsx @@ -9,6 +9,8 @@ import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import { render } from '../../../../../lib/helper/rtl_helpers'; import { StepImageCaption, StepImageCaptionProps } from './step_image_caption'; +import { getShortTimeStamp } from '../../../../overview/monitor_list/columns/monitor_status_column'; +import moment from 'moment'; describe('StepImageCaption', () => { let defaultProps: StepImageCaptionProps; @@ -20,7 +22,8 @@ describe('StepImageCaption', () => { maxSteps: 3, setStepNumber: jest.fn(), stepNumber: 2, - timestamp: '2020-11-26T15:28:56.896Z', + label: getShortTimeStamp(moment('2020-11-26T15:28:56.896Z')), + onVisible: jest.fn(), isLoading: false, }; }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx index fe9709a02b684..80d41ccc23dc8 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx @@ -5,11 +5,9 @@ * 2.0. */ +import React, { MouseEvent, useEffect } from 'react'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import React from 'react'; -import moment from 'moment'; import { nextAriaLabel, prevAriaLabel } from './translations'; -import { getShortTimeStamp } from '../../../../overview/monitor_list/columns/monitor_status_column'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; export interface StepImageCaptionProps { @@ -18,7 +16,8 @@ export interface StepImageCaptionProps { maxSteps?: number; setStepNumber: React.Dispatch<React.SetStateAction<number>>; stepNumber: number; - timestamp: string; + label?: string; + onVisible: (val: boolean) => void; isLoading: boolean; } @@ -35,19 +34,34 @@ export const StepImageCaption: React.FC<StepImageCaptionProps> = ({ maxSteps, setStepNumber, stepNumber, - timestamp, isLoading, + label, + onVisible, }) => { + useEffect(() => { + onVisible(true); + return () => { + onVisible(false); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - <ImageCaption> + <ImageCaption + onClick={(evt) => { + // we don't want this to be captured by row click which leads to step list page + evt.stopPropagation(); + }} + > <div className="stepArrowsFullScreen"> {imgSrc && ( <EuiFlexGroup alignItems="center" justifyContent="center"> <EuiFlexItem grow={false}> <EuiButtonEmpty disabled={stepNumber === 1} - onClick={() => { + onClick={(evt: MouseEvent<HTMLButtonElement>) => { setStepNumber(stepNumber - 1); + evt.preventDefault(); }} iconType="arrowLeft" aria-label={prevAriaLabel} @@ -62,8 +76,9 @@ export const StepImageCaption: React.FC<StepImageCaptionProps> = ({ <EuiFlexItem grow={false}> <EuiButtonEmpty disabled={stepNumber === maxSteps} - onClick={() => { + onClick={(evt: MouseEvent<HTMLButtonElement>) => { setStepNumber(stepNumber + 1); + evt.stopPropagation(); }} iconType="arrowRight" iconSide="right" @@ -75,7 +90,7 @@ export const StepImageCaption: React.FC<StepImageCaptionProps> = ({ </EuiFlexItem> </EuiFlexGroup> )} - <span className="eui-textNoWrap">{getShortTimeStamp(moment(timestamp))}</span> + <span className="eui-textNoWrap">{label}</span> </div> </ImageCaption> ); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx index 4fc8db515a5d6..d3dce3a2505b2 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx @@ -38,7 +38,7 @@ export const StepImagePopover: React.FC<StepImagePopoverProps> = ({ isImagePopoverOpen, }) => ( <EuiPopover - anchorPosition="upCenter" + anchorPosition="leftDown" button={ <StepImage allowFullScreen={true} @@ -52,6 +52,7 @@ export const StepImagePopover: React.FC<StepImagePopoverProps> = ({ /> } isOpen={isImagePopoverOpen} + closePopover={() => {}} > <EuiImage alt={fullSizeImageAlt} diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.test.tsx index 5dee3e5b1e14a..dd42a14890793 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.test.tsx @@ -98,14 +98,35 @@ describe('PingListExpandedRow', () => { > <EuiFlexItem> <EuiCallOut - iconType="beaker" - title="Experimental feature" - /> - </EuiFlexItem> - <EuiFlexItem> - <BrowserExpandedRow - checkGroup="check_group_id" - /> + color="primary" + > + <EuiDescriptionList + listItems={ + Array [ + Object { + "description": <React.Fragment> + <BodyDescription + body={ + Object { + "bytes": 1200000, + "content": "<http><head><title>The Title", + "hash": "testhash", + } + } + /> + + + , + "title": "Response Body", + }, + ] + } + /> +
`); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx index 2599b8ed9fdca..df0d273d3bc3a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx @@ -21,7 +21,6 @@ import { i18n } from '@kbn/i18n'; import { Ping, HttpResponseBody } from '../../../../common/runtime_types'; import { DocLinkForBody } from './doc_link_body'; import { PingRedirects } from './ping_redirects'; -import { BrowserExpandedRow } from '../synthetics/browser_expanded_row'; import { PingHeaders } from './headers'; interface Props { @@ -57,24 +56,6 @@ const BodyExcerpt = ({ content }: { content: string }) => export const PingListExpandedRowComponent = ({ ping }: Props) => { const listItems = []; - if (ping.monitor.type === 'browser') { - return ( - - - - - - - - - ); - } - // Show the error block if (ping.error) { listItems.push({ diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx index 18bc5f5ec3ecb..65644ce493906 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx @@ -7,8 +7,10 @@ import { EuiBasicTable, EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect, MouseEvent } from 'react'; import styled from 'styled-components'; +import { useHistory } from 'react-router-dom'; +import moment from 'moment'; import { useDispatch } from 'react-redux'; import { Ping } from '../../../../common/runtime_types'; import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; @@ -27,6 +29,7 @@ import { FailedStep } from './columns/failed_step'; import { usePingsList } from './use_pings'; import { PingListHeader } from './ping_list_header'; import { clearPings } from '../../../state/actions'; +import { getShortTimeStamp } from '../../overview/monitor_list/columns/monitor_status_column'; export const SpanWithMargin = styled.span` margin-right: 16px; @@ -69,6 +72,8 @@ export const PingList = () => { const dispatch = useDispatch(); + const history = useHistory(); + const pruneJourneysCallback = useCallback( (checkGroups: string[]) => dispatch(pruneJourneyState(checkGroups)), [dispatch] @@ -140,7 +145,7 @@ export const PingList = () => { field: 'timestamp', name: TIMESTAMP_LABEL, render: (timestamp: string, item: Ping) => ( - + ), }, ] @@ -197,20 +202,43 @@ export const PingList = () => { }, ] : []), - { - align: 'right', - width: '24px', - isExpander: true, - render: (item: Ping) => ( - - ), - }, + ...(monitorType !== MONITOR_TYPES.BROWSER + ? [ + { + align: 'right', + width: '24px', + isExpander: true, + render: (item: Ping) => ( + + ), + }, + ] + : []), ]; + const getRowProps = (item: Ping) => { + if (monitorType !== MONITOR_TYPES.BROWSER) { + return {}; + } + const { monitor } = item; + return { + height: '85px', + 'data-test-subj': `row-${monitor.check_group}`, + onClick: (evt: MouseEvent) => { + const targetElem = evt.target as HTMLElement; + + // we dont want to capture image click event + if (targetElem.tagName !== 'IMG' && targetElem.tagName !== 'path') { + history.push(`/journey/${monitor.check_group}/steps`); + } + }, + }; + }; + const pagination: Pagination = { initialPageSize: DEFAULT_PAGE_SIZE, pageIndex, @@ -247,6 +275,7 @@ export const PingList = () => { setPageIndex(criteria.page!.index); }} tableLayout={'auto'} + rowProps={getRowProps} />
); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.test.tsx deleted file mode 100644 index 396d51e3002b2..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.test.tsx +++ /dev/null @@ -1,203 +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 { shallowWithIntl } from '@kbn/test/jest'; -import React from 'react'; -import { BrowserExpandedRowComponent } from './browser_expanded_row'; -import { Ping } from '../../../../common/runtime_types'; - -describe('BrowserExpandedRowComponent', () => { - let defStep: Ping; - beforeEach(() => { - defStep = { - docId: 'doc-id', - timestamp: '123', - monitor: { - duration: { - us: 100, - }, - id: 'mon-id', - status: 'up', - type: 'browser', - }, - }; - }); - - it('returns empty step state when no journey', () => { - expect(shallowWithIntl()).toMatchInlineSnapshot( - `` - ); - }); - - it('returns empty step state when journey has no steps', () => { - expect( - shallowWithIntl( - - ) - ).toMatchInlineSnapshot(``); - }); - - it('displays loading spinner when loading', () => { - expect( - shallowWithIntl( - - ) - ).toMatchInlineSnapshot(` -
- -
- `); - }); - - it('renders executed journey when step/end is present', () => { - expect( - shallowWithIntl( - - ) - ).toMatchInlineSnapshot(` - - `); - }); - - it('handles case where synth type is somehow missing', () => { - expect( - shallowWithIntl( - - ) - ).toMatchInlineSnapshot(`""`); - }); - - it('renders console output step list when only console steps are present', () => { - expect( - shallowWithIntl( - - ) - ).toMatchInlineSnapshot(` - - `); - }); - - it('renders null when only unsupported steps are present', () => { - expect( - shallowWithIntl( - - ) - ).toMatchInlineSnapshot(`""`); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx deleted file mode 100644 index 2ceaa2d1b68ef..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiLoadingSpinner } from '@elastic/eui'; -import React, { useEffect, FC } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { Ping } from '../../../../common/runtime_types'; -import { getJourneySteps } from '../../../state/actions/journey'; -import { JourneyState } from '../../../state/reducers/journey'; -import { journeySelector } from '../../../state/selectors'; -import { EmptyJourney } from './empty_journey'; -import { ExecutedJourney } from './executed_journey'; -import { ConsoleOutputEventList } from './console_output_event_list'; - -interface BrowserExpandedRowProps { - checkGroup?: string; -} - -export const BrowserExpandedRow: React.FC = ({ checkGroup }) => { - const dispatch = useDispatch(); - useEffect(() => { - if (checkGroup) { - dispatch(getJourneySteps({ checkGroup })); - } - }, [dispatch, checkGroup]); - - const journeys = useSelector(journeySelector); - const journey = journeys[checkGroup ?? '']; - - return ; -}; - -type ComponentProps = BrowserExpandedRowProps & { - journey?: JourneyState; -}; - -const stepEnd = (step: Ping) => step.synthetics?.type === 'step/end'; -const stepConsole = (step: Ping) => - ['stderr', 'cmd/status'].indexOf(step.synthetics?.type ?? '') !== -1; - -export const BrowserExpandedRowComponent: FC = ({ checkGroup, journey }) => { - if (!!journey && journey.loading) { - return ( -
- -
- ); - } - - if (!journey || journey.steps.length === 0) { - return ; - } - - if (journey.steps.some(stepEnd)) return ; - - if (journey.steps.some(stepConsole)) return ; - - // TODO: should not happen, this means that the journey has no step/end and no console logs, but some other steps; filmstrip, screenshot, etc. - // we should probably create an error prompt letting the user know this step is not supported yet - return null; -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.test.tsx deleted file mode 100644 index 2fbc19d245826..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.test.tsx +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { shallowWithIntl } from '@kbn/test/jest'; -import React from 'react'; -import { ExecutedJourney } from './executed_journey'; -import { Ping } from '../../../../common/runtime_types'; - -const MONITOR_BOILERPLATE = { - id: 'MON_ID', - duration: { - us: 10, - }, - status: 'down', - type: 'browser', -}; - -describe('ExecutedJourney component', () => { - let steps: Ping[]; - - beforeEach(() => { - steps = [ - { - docId: '1', - timestamp: '123', - monitor: MONITOR_BOILERPLATE, - synthetics: { - payload: { - status: 'failed', - }, - type: 'step/end', - }, - }, - { - docId: '2', - timestamp: '124', - monitor: MONITOR_BOILERPLATE, - synthetics: { - payload: { - status: 'failed', - }, - type: 'step/end', - }, - }, - ]; - }); - - it('creates expected message for all failed', () => { - const wrapper = shallowWithIntl( - - ); - expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` - -

- -

-

- 2 Steps - all failed or skipped -

-
- `); - }); - - it('creates expected message for all succeeded', () => { - steps[0].synthetics!.payload!.status = 'succeeded'; - steps[1].synthetics!.payload!.status = 'succeeded'; - const wrapper = shallowWithIntl( - - ); - expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` - -

- -

-

- 2 Steps - all succeeded -

-
- `); - }); - - it('creates appropriate message for mixed results', () => { - steps[0].synthetics!.payload!.status = 'succeeded'; - const wrapper = shallowWithIntl( - - ); - expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` - -

- -

-

- 2 Steps - 1 succeeded -

-
- `); - }); - - it('tallies skipped steps', () => { - steps[0].synthetics!.payload!.status = 'succeeded'; - steps[1].synthetics!.payload!.status = 'skipped'; - const wrapper = shallowWithIntl( - - ); - expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` - -

- -

-

- 2 Steps - 1 succeeded -

-
- `); - }); - - it('uses appropriate count when non-step/end steps are included', () => { - steps[0].synthetics!.payload!.status = 'succeeded'; - steps.push({ - docId: '3', - timestamp: '125', - monitor: MONITOR_BOILERPLATE, - synthetics: { - type: 'stderr', - error: { - message: `there was an error, that's all we know`, - stack: 'your.error.happened.here', - }, - }, - }); - const wrapper = shallowWithIntl( - - ); - expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` - -

- -

-

- 2 Steps - 1 succeeded -

-
- `); - }); - - it('renders a component per step', () => { - expect( - shallowWithIntl( - - ).find('EuiFlexGroup') - ).toMatchInlineSnapshot(` - - - - - - `); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx deleted file mode 100644 index 1ded7f065d8ab..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx +++ /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 { EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import React, { FC } from 'react'; -import { Ping } from '../../../../common/runtime_types'; -import { JourneyState } from '../../../state/reducers/journey'; -import { ExecutedStep } from './executed_step'; - -interface StepStatusCount { - failed: number; - skipped: number; - succeeded: number; -} - -function statusMessage(count: StepStatusCount) { - const total = count.succeeded + count.failed + count.skipped; - if (count.failed + count.skipped === total) { - return i18n.translate('xpack.uptime.synthetics.journey.allFailedMessage', { - defaultMessage: '{total} Steps - all failed or skipped', - values: { total }, - }); - } else if (count.succeeded === total) { - return i18n.translate('xpack.uptime.synthetics.journey.allSucceededMessage', { - defaultMessage: '{total} Steps - all succeeded', - values: { total }, - }); - } - return i18n.translate('xpack.uptime.synthetics.journey.partialSuccessMessage', { - defaultMessage: '{total} Steps - {succeeded} succeeded', - values: { succeeded: count.succeeded, total }, - }); -} - -function reduceStepStatus(prev: StepStatusCount, cur: Ping): StepStatusCount { - if (cur.synthetics?.payload?.status === 'succeeded') { - prev.succeeded += 1; - return prev; - } else if (cur.synthetics?.payload?.status === 'skipped') { - prev.skipped += 1; - return prev; - } - prev.failed += 1; - return prev; -} - -function isStepEnd(step: Ping) { - return step.synthetics?.type === 'step/end'; -} - -interface ExecutedJourneyProps { - journey: JourneyState; -} - -export const ExecutedJourney: FC = ({ journey }) => { - return ( -
- -

- -

-

- {statusMessage( - journey.steps - .filter(isStepEnd) - .reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 }) - )} -

-
- - - {journey.steps.filter(isStepEnd).map((step, index) => ( - - ))} - - -
- ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx deleted file mode 100644 index 991aa8fefba0a..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx +++ /dev/null @@ -1,110 +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 { EuiFlexItem, EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; -import { CodeBlockAccordion } from './code_block_accordion'; -import { StepScreenshotDisplay } from './step_screenshot_display'; -import { StatusBadge } from './status_badge'; -import { Ping } from '../../../../common/runtime_types'; -import { StepDetailLink } from '../../common/step_detail_link'; -import { VIEW_PERFORMANCE } from './translations'; - -const CODE_BLOCK_OVERFLOW_HEIGHT = 360; - -interface ExecutedStepProps { - step: Ping; - index: number; - checkGroup: string; -} - -export const ExecutedStep: FC = ({ step, index, checkGroup }) => { - return ( - <> -
- - - - - - - - -
- -
-
-
- -
- - - - - - {step.synthetics?.step?.index && ( - - - {VIEW_PERFORMANCE} - - - - )} - - {step.synthetics?.payload?.source} - - - {step.synthetics?.error?.message} - - - {step.synthetics?.error?.stack} - - - -
-
- - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.test.tsx deleted file mode 100644 index 304787e96818f..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.test.tsx +++ /dev/null @@ -1,42 +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 { shallowWithIntl } from '@kbn/test/jest'; -import React from 'react'; -import { StatusBadge } from './status_badge'; - -describe('StatusBadge', () => { - it('displays success message', () => { - expect(shallowWithIntl()).toMatchInlineSnapshot(` - - Succeeded - - `); - }); - - it('displays failed message', () => { - expect(shallowWithIntl()).toMatchInlineSnapshot(` - - Failed - - `); - }); - - it('displays skipped message', () => { - expect(shallowWithIntl()).toMatchInlineSnapshot(` - - Skipped - - `); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx index 346af9d31a28b..ef0d001ac905e 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx @@ -48,7 +48,7 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) }; }, [stepIndex, journey]); - useMonitorBreadcrumb({ journey, activeStep }); + useMonitorBreadcrumb({ details: journey?.details, activeStep, performanceBreakDownView: true }); const handleNextStep = useCallback(() => { history.push(`/journey/${checkGroup}/step/${stepIndex + 1}`); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumb.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumb.tsx index c51b85f76d605..8b85f05130d0b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumb.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumb.tsx @@ -6,20 +6,25 @@ */ import moment from 'moment'; +import { i18n } from '@kbn/i18n'; import { useBreadcrumbs } from '../../../../hooks/use_breadcrumbs'; -import { useKibana, useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { JourneyState } from '../../../../state/reducers/journey'; import { Ping } from '../../../../../common/runtime_types/ping'; import { PLUGIN } from '../../../../../common/constants/plugin'; +import { getShortTimeStamp } from '../../../overview/monitor_list/columns/monitor_status_column'; interface Props { - journey: JourneyState; + details: JourneyState['details']; activeStep?: Ping; + performanceBreakDownView?: boolean; } -export const useMonitorBreadcrumb = ({ journey, activeStep }: Props) => { - const [dateFormat] = useUiSetting$('dateFormat'); - +export const useMonitorBreadcrumb = ({ + details, + activeStep, + performanceBreakDownView = false, +}: Props) => { const kibana = useKibana(); const appPath = kibana.services.application?.getUrlForApp(PLUGIN.ID) ?? ''; @@ -32,8 +37,22 @@ export const useMonitorBreadcrumb = ({ journey, activeStep }: Props) => { }, ] : []), - ...(journey?.details?.timestamp - ? [{ text: moment(journey?.details?.timestamp).format(dateFormat) }] + ...(details?.journey?.monitor?.check_group + ? [ + { + text: getShortTimeStamp(moment(details?.timestamp)), + href: `${appPath}/journey/${details.journey.monitor.check_group}/steps`, + }, + ] + : []), + ...(performanceBreakDownView + ? [ + { + text: i18n.translate('xpack.uptime.synthetics.performanceBreakDown.label', { + defaultMessage: 'Performance breakdown', + }), + }, + ] : []), ]); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx index ac79d7f4c2a8a..4aed073424788 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx @@ -17,7 +17,7 @@ import { JourneyState } from '../../../../state/reducers/journey'; import { chromeServiceMock, uiSettingsServiceMock } from 'src/core/public/mocks'; describe('useMonitorBreadcrumbs', () => { - it('sets the given breadcrumbs', () => { + it('sets the given breadcrumbs for steps list view', () => { let breadcrumbObj: ChromeBreadcrumb[] = []; const getBreadcrumbs = () => { return breadcrumbObj; @@ -43,8 +43,13 @@ describe('useMonitorBreadcrumbs', () => { const Component = () => { useMonitorBreadcrumb({ - activeStep: { monitor: { id: 'test-monitor' } } as Ping, - journey: { details: { timestamp: '2021-01-04T11:25:19.104Z' } } as JourneyState, + activeStep: { monitor: { id: 'test-monitor', check_group: 'fake-test-group' } } as Ping, + details: { + timestamp: '2021-01-04T11:25:19.104Z', + journey: { + monitor: { id: 'test-monitor', check_group: 'fake-test-group' }, + }, + } as JourneyState['details'], }); return <>Step Water Fall; }; @@ -69,7 +74,78 @@ describe('useMonitorBreadcrumbs', () => { "text": "test-monitor", }, Object { - "text": "Jan 4, 2021 @ 06:25:19.104", + "href": "/app/uptime/journey/fake-test-group/steps", + "onClick": [Function], + "text": "Jan 4, 2021 6:25:19 AM", + }, + ] + `); + }); + + it('sets the given breadcrumbs for performance breakdown page', () => { + let breadcrumbObj: ChromeBreadcrumb[] = []; + const getBreadcrumbs = () => { + return breadcrumbObj; + }; + + const core = { + chrome: { + ...chromeServiceMock.createStartContract(), + setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => { + breadcrumbObj = newBreadcrumbs; + }, + }, + uiSettings: { + ...uiSettingsServiceMock.createSetupContract(), + get(key: string, defaultOverride?: any): any { + return `MMM D, YYYY @ HH:mm:ss.SSS` || defaultOverride; + }, + get$(key: string, defaultOverride?: any): any { + return of(`MMM D, YYYY @ HH:mm:ss.SSS`) || of(defaultOverride); + }, + }, + }; + + const Component = () => { + useMonitorBreadcrumb({ + activeStep: { monitor: { id: 'test-monitor', check_group: 'fake-test-group' } } as Ping, + details: { + timestamp: '2021-01-04T11:25:19.104Z', + journey: { + monitor: { id: 'test-monitor', check_group: 'fake-test-group' }, + }, + } as JourneyState['details'], + performanceBreakDownView: true, + }); + return <>Step Water Fall; + }; + + render( + + + , + { core } + ); + + expect(getBreadcrumbs()).toMatchInlineSnapshot(` + Array [ + Object { + "href": "/app/uptime", + "onClick": [Function], + "text": "Uptime", + }, + Object { + "href": "/app/uptime/monitor/dGVzdC1tb25pdG9y", + "onClick": [Function], + "text": "test-monitor", + }, + Object { + "href": "/app/uptime/journey/fake-test-group/steps", + "onClick": [Function], + "text": "Jan 4, 2021 6:25:19 AM", + }, + Object { + "text": "Performance breakdown", }, ] `); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx index c6476a5bf2e53..f5581f75b3759 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx @@ -67,7 +67,7 @@ export const getShortTimeStamp = (timeStamp: moment.Moment, relative = false) => moment.locale(prevLocale); return shortTimestamp; } else { - if (moment().diff(timeStamp, 'd') > 1) { + if (moment().diff(timeStamp, 'd') >= 1) { return timeStamp.format('ll LTS'); } return timeStamp.format('LTS'); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/screenshot_link.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/screenshot_link.tsx new file mode 100644 index 0000000000000..16068e0d72b46 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/screenshot_link.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 { FormattedMessage } from '@kbn/i18n/react'; +import { ReactRouterEuiLink } from '../../../common/react_router_helpers'; +import { Ping } from '../../../../../common/runtime_types/ping'; +import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; + +const LabelLink = euiStyled.div` + margin-bottom: ${(props) => props.theme.eui.paddingSizes.xs}; + font-size: ${({ theme }) => theme.eui.euiFontSizeS}; +`; + +interface Props { + lastSuccessfulStep: Ping; +} + +export const ScreenshotLink = ({ lastSuccessfulStep }: Props) => { + return ( + + + + + + + ), + }} + /> + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx new file mode 100644 index 0000000000000..eb7bc95751557 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { StepScreenshotDisplay } from '../../step_screenshot_display'; +import { Ping } from '../../../../../common/runtime_types/ping'; +import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; +import { useFetcher } from '../../../../../../observability/public'; +import { fetchLastSuccessfulStep } from '../../../../state/api/journey'; +import { ScreenshotLink } from './screenshot_link'; +import { getShortTimeStamp } from '../../../overview/monitor_list/columns/monitor_status_column'; + +const Label = euiStyled.div` + margin-bottom: ${(props) => props.theme.eui.paddingSizes.xs}; + font-size: ${({ theme }) => theme.eui.euiFontSizeS}; + color: ${({ theme }) => theme.eui.euiColorDarkShade}; +`; + +interface Props { + step: Ping; +} + +export const StepScreenshots = ({ step }: Props) => { + const isSucceeded = step.synthetics?.payload?.status === 'succeeded'; + + const { data: lastSuccessfulStep } = useFetcher(() => { + if (!isSucceeded) { + return fetchLastSuccessfulStep({ + timestamp: step.timestamp, + monitorId: step.monitor.id, + stepIndex: step.synthetics?.step?.index!, + }); + } + }, [step.docId, step.timestamp]); + + return ( + + + + + + + + {!isSucceeded && lastSuccessfulStep?.monitor && ( + + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx new file mode 100644 index 0000000000000..69a5ef91a5925 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx @@ -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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { Ping } from '../../../../common/runtime_types/ping'; +import { PingTimestamp } from '../../monitor/ping_list/columns/ping_timestamp'; + +interface Props { + step: Ping; +} + +export const StepImage = ({ step }: Props) => { + return ( + + + + + + {step.synthetics?.step?.name} + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_list.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_list.test.tsx new file mode 100644 index 0000000000000..959bf0f644580 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_list.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Ping } from '../../../../common/runtime_types/ping'; +import { StepsList } from './steps_list'; +import { render } from '../../../lib/helper/rtl_helpers'; + +describe('StepList component', () => { + let steps: Ping[]; + + beforeEach(() => { + steps = [ + { + docId: '1', + timestamp: '123', + monitor: { + id: 'MON_ID', + duration: { + us: 10, + }, + status: 'down', + type: 'browser', + check_group: 'fake-group', + }, + synthetics: { + payload: { + status: 'failed', + }, + type: 'step/end', + step: { + name: 'load page', + index: 1, + }, + }, + }, + { + docId: '2', + timestamp: '124', + monitor: { + id: 'MON_ID', + duration: { + us: 10, + }, + status: 'down', + type: 'browser', + check_group: 'fake-group-1', + }, + synthetics: { + payload: { + status: 'failed', + }, + type: 'step/end', + step: { + name: 'go to login', + index: 2, + }, + }, + }, + ]; + }); + + it('creates expected message for all failed', () => { + const { getByText } = render(); + expect(getByText('2 Steps - all failed or skipped')); + }); + + it('renders a link to the step detail view', () => { + const { getByTitle, getByTestId } = render(); + expect(getByTestId('step-detail-link')).toHaveAttribute('href', '/journey/fake-group/step/1'); + expect(getByTitle(`Failed`)); + }); + + it.each([ + ['succeeded', 'Succeeded'], + ['failed', 'Failed'], + ['skipped', 'Skipped'], + ])('supplies status badge correct status', (status, expectedStatus) => { + const step = steps[0]; + step.synthetics!.payload!.status = status; + const { getByText } = render(); + expect(getByText(expectedStatus)); + }); + + it('creates expected message for all succeeded', () => { + steps[0].synthetics!.payload!.status = 'succeeded'; + steps[1].synthetics!.payload!.status = 'succeeded'; + + const { getByText } = render(); + expect(getByText('2 Steps - all succeeded')); + }); + + it('creates appropriate message for mixed results', () => { + steps[0].synthetics!.payload!.status = 'succeeded'; + + const { getByText } = render(); + expect(getByText('2 Steps - 1 succeeded')); + }); + + it('tallies skipped steps', () => { + steps[0].synthetics!.payload!.status = 'succeeded'; + steps[1].synthetics!.payload!.status = 'skipped'; + + const { getByText } = render(); + expect(getByText('2 Steps - 1 succeeded')); + }); + + it('uses appropriate count when non-step/end steps are included', () => { + steps[0].synthetics!.payload!.status = 'succeeded'; + steps.push({ + docId: '3', + timestamp: '125', + monitor: { + id: 'MON_ID', + duration: { + us: 10, + }, + status: 'down', + type: 'browser', + check_group: 'fake-group-2', + }, + synthetics: { + type: 'stderr', + error: { + message: `there was an error, that's all we know`, + stack: 'your.error.happened.here', + }, + }, + }); + + const { getByText } = render(); + expect(getByText('2 Steps - 1 succeeded')); + }); + + it('renders a row per step', () => { + const { getByTestId } = render(); + expect(getByTestId('row-fake-group')); + expect(getByTestId('row-fake-group-1')); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx new file mode 100644 index 0000000000000..47bf3ae0a1784 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx @@ -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 { EuiBasicTable, EuiButtonIcon, EuiPanel, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { MouseEvent } from 'react'; +import styled from 'styled-components'; +import { Ping } from '../../../../common/runtime_types'; +import { STATUS_LABEL } from '../../monitor/ping_list/translations'; +import { COLLAPSE_LABEL, EXPAND_LABEL, STEP_NAME_LABEL } from '../translations'; +import { StatusBadge } from '../status_badge'; +import { StepDetailLink } from '../../common/step_detail_link'; +import { VIEW_PERFORMANCE } from '../../monitor/synthetics/translations'; +import { StepImage } from './step_image'; +import { useExpandedRow } from './use_expanded_row'; + +export const SpanWithMargin = styled.span` + margin-right: 16px; +`; + +interface Props { + data: Ping[]; + error?: Error; + loading: boolean; +} + +interface StepStatusCount { + failed: number; + skipped: number; + succeeded: number; +} + +function isStepEnd(step: Ping) { + return step.synthetics?.type === 'step/end'; +} + +function statusMessage(count: StepStatusCount, loading?: boolean) { + if (loading) { + return i18n.translate('xpack.uptime.synthetics.journey.loadingSteps', { + defaultMessage: 'Loading steps ...', + }); + } + const total = count.succeeded + count.failed + count.skipped; + if (count.failed + count.skipped === total) { + return i18n.translate('xpack.uptime.synthetics.journey.allFailedMessage', { + defaultMessage: '{total} Steps - all failed or skipped', + values: { total }, + }); + } else if (count.succeeded === total) { + return i18n.translate('xpack.uptime.synthetics.journey.allSucceededMessage', { + defaultMessage: '{total} Steps - all succeeded', + values: { total }, + }); + } + return i18n.translate('xpack.uptime.synthetics.journey.partialSuccessMessage', { + defaultMessage: '{total} Steps - {succeeded} succeeded', + values: { succeeded: count.succeeded, total }, + }); +} + +function reduceStepStatus(prev: StepStatusCount, cur: Ping): StepStatusCount { + if (cur.synthetics?.payload?.status === 'succeeded') { + prev.succeeded += 1; + return prev; + } else if (cur.synthetics?.payload?.status === 'skipped') { + prev.skipped += 1; + return prev; + } + prev.failed += 1; + return prev; +} + +export const StepsList = ({ data, error, loading }: Props) => { + const steps = data.filter(isStepEnd); + + const { expandedRows, toggleExpand } = useExpandedRow({ steps, allPings: data, loading }); + + const columns: any[] = [ + { + field: 'synthetics.payload.status', + name: STATUS_LABEL, + render: (pingStatus: string, item: Ping) => ( + + ), + }, + { + align: 'left', + field: 'timestamp', + name: STEP_NAME_LABEL, + render: (timestamp: string, item: Ping) => , + }, + { + align: 'left', + field: 'timestamp', + name: '', + render: (val: string, item: Ping) => ( + + {VIEW_PERFORMANCE} + + ), + }, + { + align: 'right', + width: '24px', + isExpander: true, + render: (ping: Ping) => { + return ( + toggleExpand({ ping })} + aria-label={expandedRows[ping.docId] ? COLLAPSE_LABEL : EXPAND_LABEL} + iconType={expandedRows[ping.docId] ? 'arrowUp' : 'arrowDown'} + /> + ); + }, + }, + ]; + + const getRowProps = (item: Ping) => { + const { monitor } = item; + + return { + height: '85px', + 'data-test-subj': `row-${monitor.check_group}`, + onClick: (evt: MouseEvent) => { + const targetElem = evt.target as HTMLElement; + + // we dont want to capture image click event + if (targetElem.tagName !== 'IMG' && targetElem.tagName !== 'BUTTON') { + toggleExpand({ ping: item }); + } + }, + }; + }; + + return ( + + +

+ {statusMessage( + steps.reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 }), + loading + )} +

+
+ +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_check_steps.ts b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_check_steps.ts new file mode 100644 index 0000000000000..da40b900fdcc2 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_check_steps.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useParams } from 'react-router-dom'; +import { FETCH_STATUS, useFetcher } from '../../../../../observability/public'; +import { fetchJourneySteps } from '../../../state/api/journey'; +import { JourneyState } from '../../../state/reducers/journey'; + +export const useCheckSteps = (): JourneyState => { + const { checkGroupId } = useParams<{ checkGroupId: string }>(); + + const { data, status, error } = useFetcher(() => { + return fetchJourneySteps({ + checkGroup: checkGroupId, + }); + }, [checkGroupId]); + + return { + error, + checkGroup: checkGroupId, + steps: data?.steps ?? [], + details: data?.details, + loading: status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING, + }; +}; diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.test.tsx new file mode 100644 index 0000000000000..d94122a7311ca --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.test.tsx @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Route } from 'react-router-dom'; +import { fireEvent, screen } from '@testing-library/dom'; +import { EuiButtonIcon } from '@elastic/eui'; +import { createMemoryHistory } from 'history'; + +import { useExpandedRow } from './use_expanded_row'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { Ping } from '../../../../common/runtime_types/ping'; +import { SYNTHETIC_CHECK_STEPS_ROUTE } from '../../../../common/constants'; +import { COLLAPSE_LABEL, EXPAND_LABEL } from '../translations'; +import { act } from 'react-dom/test-utils'; + +describe('useExpandedROw', () => { + let expandedRowsObj = {}; + const TEST_ID = 'uptimeStepListExpandBtn'; + + const history = createMemoryHistory({ + initialEntries: ['/journey/fake-group/steps'], + }); + const steps: Ping[] = [ + { + docId: '1', + timestamp: '123', + monitor: { + id: 'MON_ID', + duration: { + us: 10, + }, + status: 'down', + type: 'browser', + check_group: 'fake-group', + }, + synthetics: { + payload: { + status: 'failed', + }, + type: 'step/end', + step: { + name: 'load page', + index: 1, + }, + }, + }, + { + docId: '2', + timestamp: '124', + monitor: { + id: 'MON_ID', + duration: { + us: 10, + }, + status: 'down', + type: 'browser', + check_group: 'fake-group', + }, + synthetics: { + payload: { + status: 'failed', + }, + type: 'step/end', + step: { + name: 'go to login', + index: 2, + }, + }, + }, + ]; + + const Component = () => { + const { expandedRows, toggleExpand } = useExpandedRow({ + steps, + allPings: steps, + loading: false, + }); + + expandedRowsObj = expandedRows; + + return ( + + Step list + {steps.map((ping, index) => ( + toggleExpand({ ping })} + aria-label={expandedRows[ping.docId] ? COLLAPSE_LABEL : EXPAND_LABEL} + iconType={expandedRows[ping.docId] ? 'arrowUp' : 'arrowDown'} + /> + ))} + + ); + }; + + it('it toggles rows on expand click', async () => { + render(, { + history, + }); + + fireEvent.click(await screen.findByTestId(TEST_ID + '1')); + + expect(Object.keys(expandedRowsObj)).toStrictEqual(['1']); + + expect(JSON.stringify(expandedRowsObj)).toContain('fake-group'); + + await act(async () => { + fireEvent.click(await screen.findByTestId(TEST_ID + '1')); + }); + + expect(Object.keys(expandedRowsObj)).toStrictEqual([]); + }); + + it('it can expand both rows at same time', async () => { + render(, { + history, + }); + + // let's expand both rows + fireEvent.click(await screen.findByTestId(TEST_ID + '1')); + fireEvent.click(await screen.findByTestId(TEST_ID + '0')); + + expect(Object.keys(expandedRowsObj)).toStrictEqual(['0', '1']); + }); + + it('it updates already expanded rows on new check group monitor', async () => { + render(, { + history, + }); + + // let's expand both rows + fireEvent.click(await screen.findByTestId(TEST_ID + '1')); + fireEvent.click(await screen.findByTestId(TEST_ID + '0')); + + const newFakeGroup = 'new-fake-group-1'; + + steps[0].monitor.check_group = newFakeGroup; + steps[1].monitor.check_group = newFakeGroup; + + act(() => { + history.push(`/journey/${newFakeGroup}/steps`); + }); + + expect(JSON.stringify(expandedRowsObj)).toContain(newFakeGroup); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx new file mode 100644 index 0000000000000..bb56b237dfbd2 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { ExecutedStep } from '../executed_step'; +import { Ping } from '../../../../common/runtime_types/ping'; + +interface HookProps { + loading: boolean; + allPings: Ping[]; + steps: Ping[]; +} + +type ExpandRowType = Record; + +export const useExpandedRow = ({ loading, steps, allPings }: HookProps) => { + const [expandedRows, setExpandedRows] = useState({}); + // eui table uses index from 0, synthetics uses 1 + + const { checkGroupId } = useParams<{ checkGroupId: string }>(); + + const getBrowserConsole = useCallback( + (index: number) => { + return allPings.find( + (stepF) => + stepF.synthetics?.type === 'journey/browserconsole' && + stepF.synthetics?.step?.index! === index + )?.synthetics?.payload?.text; + }, + [allPings] + ); + + useEffect(() => { + const expandedRowsN: ExpandRowType = {}; + for (const expandedRowKeyStr in expandedRows) { + if (expandedRows.hasOwnProperty(expandedRowKeyStr)) { + const expandedRowKey = Number(expandedRowKeyStr); + + const step = steps.find((stepF) => stepF.synthetics?.step?.index !== expandedRowKey)!; + + expandedRowsN[expandedRowKey] = ( + + ); + } + } + + setExpandedRows(expandedRowsN); + + // we only want to update when checkGroupId changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [checkGroupId, loading]); + + const toggleExpand = ({ ping }: { ping: Ping }) => { + // eui table uses index from 0, synthetics uses 1 + const stepIndex = ping.synthetics?.step?.index! - 1; + + // If already expanded, collapse + if (expandedRows[stepIndex]) { + delete expandedRows[stepIndex]; + setExpandedRows({ ...expandedRows }); + } else { + // Otherwise expand this row + setExpandedRows({ + ...expandedRows, + [stepIndex]: ( + + ), + }); + } + }; + + return { expandedRows, toggleExpand }; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx b/x-pack/plugins/uptime/public/components/synthetics/code_block_accordion.tsx similarity index 87% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx rename to x-pack/plugins/uptime/public/components/synthetics/code_block_accordion.tsx index 18aeb7a236ca8..225ba1041c263 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/code_block_accordion.tsx @@ -13,6 +13,7 @@ interface Props { id?: string; language: 'html' | 'javascript'; overflowHeight: number; + initialIsOpen?: boolean; } /** @@ -25,9 +26,10 @@ export const CodeBlockAccordion: FC = ({ id, language, overflowHeight, + initialIsOpen = false, }) => { return children && id ? ( - + {children} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/console_event.test.tsx similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.test.tsx rename to x-pack/plugins/uptime/public/components/synthetics/console_event.test.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx b/x-pack/plugins/uptime/public/components/synthetics/console_event.tsx similarity index 89% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx rename to x-pack/plugins/uptime/public/components/synthetics/console_event.tsx index dc7b6ce9ea123..19672f953607b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/console_event.tsx @@ -7,8 +7,8 @@ import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import React, { useContext, FC } from 'react'; -import { Ping } from '../../../../common/runtime_types'; -import { UptimeThemeContext } from '../../../contexts'; +import { UptimeThemeContext } from '../../contexts'; +import { Ping } from '../../../common/runtime_types/ping'; interface Props { event: Ping; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.test.tsx similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.test.tsx rename to x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.test.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx b/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.tsx similarity index 92% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx rename to x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.tsx index df1f6aeb3623b..df4314e5ccf1c 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.tsx @@ -8,9 +8,9 @@ import { EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC } from 'react'; -import { Ping } from '../../../../common/runtime_types'; -import { JourneyState } from '../../../state/reducers/journey'; import { ConsoleEvent } from './console_event'; +import { Ping } from '../../../common/runtime_types/ping'; +import { JourneyState } from '../../state/reducers/journey'; interface Props { journey: JourneyState; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/empty_journey.test.tsx similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.test.tsx rename to x-pack/plugins/uptime/public/components/synthetics/empty_journey.test.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx b/x-pack/plugins/uptime/public/components/synthetics/empty_journey.tsx similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx rename to x-pack/plugins/uptime/public/components/synthetics/empty_journey.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/executed_step.test.tsx similarity index 54% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.test.tsx rename to x-pack/plugins/uptime/public/components/synthetics/executed_step.test.tsx index 225ccb884ad00..24b52e09adbf9 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/executed_step.test.tsx @@ -7,8 +7,8 @@ import React from 'react'; import { ExecutedStep } from './executed_step'; -import { Ping } from '../../../../common/runtime_types'; -import { render } from '../../../lib/helper/rtl_helpers'; +import { render } from '../../lib/helper/rtl_helpers'; +import { Ping } from '../../../common/runtime_types/ping'; describe('ExecutedStep', () => { let step: Ping; @@ -34,33 +34,6 @@ describe('ExecutedStep', () => { }; }); - it('renders correct step heading', () => { - const { getByText } = render(); - - expect(getByText(`${step?.synthetics?.step?.index}. ${step?.synthetics?.step?.name}`)); - }); - - it('renders a link to the step detail view', () => { - const { getByRole, getByText } = render( - - ); - expect(getByRole('link')).toHaveAttribute('href', '/journey/fake-group/step/4'); - expect(getByText('4. STEP_NAME')); - }); - - it.each([ - ['succeeded', 'Succeeded'], - ['failed', 'Failed'], - ['skipped', 'Skipped'], - ['somegarbage', '4.'], - ])('supplies status badge correct status', (status, expectedStatus) => { - step.synthetics = { - payload: { status }, - }; - const { getByText } = render(); - expect(getByText(expectedStatus)); - }); - it('renders accordion for step', () => { step.synthetics = { payload: { @@ -72,10 +45,9 @@ describe('ExecutedStep', () => { }, }; - const { getByText } = render(); + const { getByText } = render(); - expect(getByText('4. STEP_NAME')); - expect(getByText('Step script')); + expect(getByText('Script executed at this step')); expect(getByText(`const someVar = "the var"`)); }); @@ -87,11 +59,22 @@ describe('ExecutedStep', () => { }, }; - const { getByText } = render(); + const { getByText } = render(); - expect(getByText('4.')); - expect(getByText('Error')); + expect(getByText('Error message')); expect(getByText('There was an error executing the step.')); expect(getByText('some.stack.trace.string')); }); + + it('renders accordions for console output', () => { + const browserConsole = + "Refused to execute script from because its MIME type ('image/gif') is not executable"; + + const { getByText } = render( + + ); + + expect(getByText('Console output')); + expect(getByText(browserConsole)); + }); }); diff --git a/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx b/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx new file mode 100644 index 0000000000000..a77b3dfe3ba21 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { CodeBlockAccordion } from './code_block_accordion'; +import { Ping } from '../../../common/runtime_types/ping'; +import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; +import { StepScreenshots } from './check_steps/step_expanded_row/step_screenshots'; + +const CODE_BLOCK_OVERFLOW_HEIGHT = 360; + +interface ExecutedStepProps { + step: Ping; + index: number; + loading: boolean; + browserConsole?: string; +} + +const Label = euiStyled.div` + margin-bottom: ${(props) => props.theme.eui.paddingSizes.xs}; + font-size: ${({ theme }) => theme.eui.euiFontSizeS}; + color: ${({ theme }) => theme.eui.euiColorDarkShade}; +`; + +const Message = euiStyled.div` + font-weight: bold; + font-size:${({ theme }) => theme.eui.euiFontSizeM}; + margin-bottom: ${(props) => props.theme.eui.paddingSizes.m}; +`; + +const ExpandedRow = euiStyled.div` + padding: '8px'; + max-width: 1000px; + width: 100%; +`; + +export const ExecutedStep: FC = ({ + loading, + step, + index, + browserConsole = '', +}) => { + const isSucceeded = step.synthetics?.payload?.status === 'succeeded'; + + return ( + + {loading ? ( + + ) : ( + <> + + {step.synthetics?.error?.message && ( + + + {step.synthetics?.error?.message} + + )} + + + {step.synthetics?.payload?.source} + + + + <> + {browserConsole} + + + + + + + {step.synthetics?.error?.stack} + + + )} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/synthetics/status_badge.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/status_badge.test.tsx new file mode 100644 index 0000000000000..500c680b91bf6 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/status_badge.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { StatusBadge } from './status_badge'; +import { render } from '../../lib/helper/rtl_helpers'; + +describe('StatusBadge', () => { + it('displays success message', () => { + const { getByText } = render(); + + expect(getByText('1.')); + expect(getByText('Succeeded')); + }); + + it('displays failed message', () => { + const { getByText } = render(); + + expect(getByText('2.')); + expect(getByText('Failed')); + }); + + it('displays skipped message', () => { + const { getByText } = render(); + + expect(getByText('3.')); + expect(getByText('Skipped')); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx b/x-pack/plugins/uptime/public/components/synthetics/status_badge.tsx similarity index 66% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx rename to x-pack/plugins/uptime/public/components/synthetics/status_badge.tsx index 0cf9e5477d0db..b4c4e310abe6b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/status_badge.tsx @@ -5,14 +5,15 @@ * 2.0. */ -import { EuiBadge } from '@elastic/eui'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext, FC } from 'react'; -import { UptimeAppColors } from '../../../apps/uptime_app'; -import { UptimeThemeContext } from '../../../contexts'; +import { UptimeAppColors } from '../../apps/uptime_app'; +import { UptimeThemeContext } from '../../contexts'; interface StatusBadgeProps { status?: string; + stepNo: number; } export function colorFromStatus(color: UptimeAppColors, status?: string) { @@ -45,9 +46,18 @@ export function textFromStatus(status?: string) { } } -export const StatusBadge: FC = ({ status }) => { +export const StatusBadge: FC = ({ status, stepNo }) => { const theme = useContext(UptimeThemeContext); return ( - {textFromStatus(status)} + + + + {stepNo}. + + + + {textFromStatus(status)} + + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx similarity index 96% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.test.tsx rename to x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx index 29dca39c34bf2..52d2eacaf0e52 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { render } from '../../../lib/helper/rtl_helpers'; import React from 'react'; import { StepScreenshotDisplay } from './step_screenshot_display'; +import { render } from '../../lib/helper/rtl_helpers'; jest.mock('react-use/lib/useIntersection', () => () => ({ isIntersecting: true, diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx similarity index 52% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx rename to x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx index 654193de72a9c..78c65b7d40803 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx @@ -5,33 +5,32 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiImage, EuiPopover, EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiImage, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useContext, useEffect, useRef, useState, FC } from 'react'; import useIntersection from 'react-use/lib/useIntersection'; -import { UptimeSettingsContext, UptimeThemeContext } from '../../../contexts'; +import { UptimeSettingsContext, UptimeThemeContext } from '../../contexts'; interface StepScreenshotDisplayProps { screenshotExists?: boolean; checkGroup?: string; stepIndex?: number; stepName?: string; + lazyLoad?: boolean; } -const THUMBNAIL_WIDTH = 320; -const THUMBNAIL_HEIGHT = 180; -const POPOVER_IMG_WIDTH = 640; -const POPOVER_IMG_HEIGHT = 360; +const IMAGE_WIDTH = 640; +const IMAGE_HEIGHT = 360; const StepImage = styled(EuiImage)` &&& { figcaption { display: none; } - width: ${THUMBNAIL_WIDTH}, - height: ${THUMBNAIL_HEIGHT}, + width: ${IMAGE_WIDTH}, + height: ${IMAGE_HEIGHT}, objectFit: 'cover', objectPosition: 'center top', } @@ -42,6 +41,7 @@ export const StepScreenshotDisplay: FC = ({ screenshotExists, stepIndex, stepName, + lazyLoad = true, }) => { const containerRef = useRef(null); const { @@ -50,8 +50,6 @@ export const StepScreenshotDisplay: FC = ({ const { basePath } = useContext(UptimeSettingsContext); - const [isImagePopoverOpen, setIsImagePopoverOpen] = useState(false); - const intersection = useIntersection(containerRef, { root: null, rootMargin: '0px', @@ -69,57 +67,26 @@ export const StepScreenshotDisplay: FC = ({ let content: JSX.Element | null = null; const imgSrc = basePath + `/api/uptime/journey/screenshot/${checkGroup}/${stepIndex}`; - if (hasIntersected && screenshotExists) { + if ((hasIntersected || !lazyLoad) && screenshotExists) { content = ( - <> - setIsImagePopoverOpen(true)} - onMouseLeave={() => setIsImagePopoverOpen(false)} - /> - } - closePopover={() => setIsImagePopoverOpen(false)} - isOpen={isImagePopoverOpen} - > - - - + ); } else if (screenshotExists === false) { content = ( @@ -148,7 +115,7 @@ export const StepScreenshotDisplay: FC = ({ return (
{content}
diff --git a/x-pack/plugins/uptime/public/components/synthetics/translations.ts b/x-pack/plugins/uptime/public/components/synthetics/translations.ts new file mode 100644 index 0000000000000..743118574b325 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/translations.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const STEP_NAME_LABEL = i18n.translate('xpack.uptime.stepList.stepName', { + defaultMessage: 'Step name', +}); + +export const COLLAPSE_LABEL = i18n.translate('xpack.uptime.stepList.collapseRow', { + defaultMessage: 'Collapse', +}); + +export const EXPAND_LABEL = i18n.translate('xpack.uptime.stepList.expandRow', { + defaultMessage: 'Expand', +}); diff --git a/x-pack/plugins/uptime/public/hooks/use_telemetry.ts b/x-pack/plugins/uptime/public/hooks/use_telemetry.ts index da0f109747758..b9ec9cc5e5516 100644 --- a/x-pack/plugins/uptime/public/hooks/use_telemetry.ts +++ b/x-pack/plugins/uptime/public/hooks/use_telemetry.ts @@ -16,6 +16,7 @@ export enum UptimePage { Settings = 'Settings', Certificates = 'Certificates', StepDetail = 'StepDetail', + SyntheticCheckStepsPage = 'SyntheticCheckStepsPage', NotFound = '__not-found__', } diff --git a/x-pack/plugins/uptime/public/pages/index.ts b/x-pack/plugins/uptime/public/pages/index.ts index 828942bc1eb1e..5624f61c3abb5 100644 --- a/x-pack/plugins/uptime/public/pages/index.ts +++ b/x-pack/plugins/uptime/public/pages/index.ts @@ -6,6 +6,6 @@ */ export { MonitorPage } from './monitor'; -export { StepDetailPage } from './step_detail_page'; +export { StepDetailPage } from './synthetics/step_detail_page'; export { SettingsPage } from './settings'; export { NotFoundPage } from './not_found'; diff --git a/x-pack/plugins/uptime/public/pages/synthetics/checks_navigation.tsx b/x-pack/plugins/uptime/public/pages/synthetics/checks_navigation.tsx new file mode 100644 index 0000000000000..291019d93c398 --- /dev/null +++ b/x-pack/plugins/uptime/public/pages/synthetics/checks_navigation.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useHistory } from 'react-router-dom'; +import moment from 'moment'; +import { SyntheticsJourneyApiResponse } from '../../../common/runtime_types/ping'; +import { getShortTimeStamp } from '../../components/overview/monitor_list/columns/monitor_status_column'; + +interface Props { + timestamp: string; + details: SyntheticsJourneyApiResponse['details']; +} + +export const ChecksNavigation = ({ timestamp, details }: Props) => { + const history = useHistory(); + + return ( + + + { + history.push(`/journey/${details?.previous?.checkGroup}/steps`); + }} + > + + + + + {getShortTimeStamp(moment(timestamp))} + + + { + history.push(`/journey/${details?.next?.checkGroup}/steps`); + }} + > + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/pages/step_detail_page.tsx b/x-pack/plugins/uptime/public/pages/synthetics/step_detail_page.tsx similarity index 75% rename from x-pack/plugins/uptime/public/pages/step_detail_page.tsx rename to x-pack/plugins/uptime/public/pages/synthetics/step_detail_page.tsx index aa81ddd0eae3d..de38d2d663523 100644 --- a/x-pack/plugins/uptime/public/pages/step_detail_page.tsx +++ b/x-pack/plugins/uptime/public/pages/synthetics/step_detail_page.tsx @@ -7,9 +7,9 @@ import React from 'react'; import { useParams } from 'react-router-dom'; -import { useTrackPageview } from '../../../observability/public'; -import { useInitApp } from '../hooks/use_init_app'; -import { StepDetailContainer } from '../components/monitor/synthetics/step_detail/step_detail_container'; +import { useTrackPageview } from '../../../../observability/public'; +import { useInitApp } from '../../hooks/use_init_app'; +import { StepDetailContainer } from '../../components/monitor/synthetics/step_detail/step_detail_container'; export const StepDetailPage: React.FC = () => { useInitApp(); diff --git a/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx b/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx new file mode 100644 index 0000000000000..edfd7ae24f91b --- /dev/null +++ b/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useTrackPageview } from '../../../../observability/public'; +import { useInitApp } from '../../hooks/use_init_app'; +import { StepsList } from '../../components/synthetics/check_steps/steps_list'; +import { useCheckSteps } from '../../components/synthetics/check_steps/use_check_steps'; +import { ChecksNavigation } from './checks_navigation'; +import { useMonitorBreadcrumb } from '../../components/monitor/synthetics/step_detail/use_monitor_breadcrumb'; +import { EmptyJourney } from '../../components/synthetics/empty_journey'; + +export const SyntheticsCheckSteps: React.FC = () => { + useInitApp(); + useTrackPageview({ app: 'uptime', path: 'syntheticCheckSteps' }); + useTrackPageview({ app: 'uptime', path: 'syntheticCheckSteps', delay: 15000 }); + + const { error, loading, steps, details, checkGroup } = useCheckSteps(); + + useMonitorBreadcrumb({ details, activeStep: details?.journey }); + + return ( + <> + + + +

{details?.journey?.monitor.name || details?.journey?.monitor.id}

+
+
+ + {details && } + +
+ + + {(!steps || steps.length === 0) && !loading && } + + ); +}; diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 82aa09c3293e6..dcfb21955f219 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -15,10 +15,12 @@ import { OVERVIEW_ROUTE, SETTINGS_ROUTE, STEP_DETAIL_ROUTE, + SYNTHETIC_CHECK_STEPS_ROUTE, } from '../common/constants'; import { MonitorPage, StepDetailPage, NotFoundPage, SettingsPage } from './pages'; import { CertificatesPage } from './pages/certificates'; import { UptimePage, useUptimeTelemetry } from './hooks'; +import { SyntheticsCheckSteps } from './pages/synthetics/synthetics_checks'; interface RouteProps { path: string; @@ -71,6 +73,13 @@ const Routes: RouteProps[] = [ dataTestSubj: 'uptimeStepDetailPage', telemetryId: UptimePage.StepDetail, }, + { + title: baseTitle, + path: SYNTHETIC_CHECK_STEPS_ROUTE, + component: SyntheticsCheckSteps, + dataTestSubj: 'uptimeSyntheticCheckStepsPage', + telemetryId: UptimePage.SyntheticCheckStepsPage, + }, { title: baseTitle, path: OVERVIEW_ROUTE, diff --git a/x-pack/plugins/uptime/public/state/api/journey.ts b/x-pack/plugins/uptime/public/state/api/journey.ts index 5c4c7c7149792..63796a66d1c5c 100644 --- a/x-pack/plugins/uptime/public/state/api/journey.ts +++ b/x-pack/plugins/uptime/public/state/api/journey.ts @@ -8,6 +8,7 @@ import { apiService } from './utils'; import { FetchJourneyStepsParams } from '../actions/journey'; import { + Ping, SyntheticsJourneyApiResponse, SyntheticsJourneyApiResponseType, } from '../../../common/runtime_types'; @@ -34,6 +35,22 @@ export async function fetchJourneysFailedSteps({ )) as SyntheticsJourneyApiResponse; } +export async function fetchLastSuccessfulStep({ + monitorId, + timestamp, + stepIndex, +}: { + monitorId: string; + timestamp: string; + stepIndex: number; +}): Promise { + return (await apiService.get(`/api/uptime/synthetics/step/success/`, { + monitorId, + timestamp, + stepIndex, + })) as Ping; +} + export async function getJourneyScreenshot(imgSrc: string) { try { const imgRequest = new Request(imgSrc); diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts index 12db6eeff8fcf..29f2f0cca82bc 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts @@ -27,6 +27,7 @@ import { GetMonitorStatusResult } from '../requests/get_monitor_status'; import { makePing } from '../../../common/runtime_types/ping'; import { GetMonitorAvailabilityResult } from '../requests/get_monitor_availability'; import type { UptimeRouter } from '../../types'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; /** * The alert takes some dependencies as parameters; these are things like @@ -63,7 +64,8 @@ const mockOptions = ( services = alertsMock.createAlertServices(), state = {} ): any => { - services.scopedClusterClient = jest.fn() as any; + services.scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + services.scopedClusterClient.asCurrentUser = (jest.fn() as unknown) as any; services.savedObjectsClient.get.mockResolvedValue({ id: '', diff --git a/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts b/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts index 97e8b0614a354..654f99cb0265a 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts @@ -58,7 +58,10 @@ export const uptimeAlertWrapper = ( options.services.savedObjectsClient ); - const uptimeEsClient = createUptimeESClient({ esClient, savedObjectsClient }); + const uptimeEsClient = createUptimeESClient({ + esClient: esClient.asCurrentUser, + savedObjectsClient, + }); return uptimeAlert.executor({ options, dynamicSettings, uptimeEsClient, savedObjectsClient }); }, diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts index e0edcc4576378..de37688b155f5 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts @@ -27,13 +27,12 @@ export const getJourneyDetails: UMElasticsearchQueryFn< }, { term: { - 'synthetics.type': 'journey/end', + 'synthetics.type': 'journey/start', }, }, ], }, }, - _source: ['@timestamp', 'monitor.id'], size: 1, }; @@ -53,7 +52,7 @@ export const getJourneyDetails: UMElasticsearchQueryFn< }, { term: { - 'synthetics.type': 'journey/end', + 'synthetics.type': 'journey/start', }, }, ], @@ -109,6 +108,7 @@ export const getJourneyDetails: UMElasticsearchQueryFn< nextJourneyResult?.hits?.hits.length > 0 ? nextJourneyResult?.hits?.hits[0] : null; return { timestamp: thisJourneySource['@timestamp'], + journey: thisJourneySource, previous: previousJourney ? { checkGroup: previousJourney._source.monitor.check_group, diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts index 9cb5e1eedb6b0..faa260eb9abd4 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts @@ -60,10 +60,10 @@ export const getJourneyScreenshot: UMElasticsearchQueryFn< return null; } - const stepHit = result?.aggregations?.step.image.hits.hits[0]._source as Ping; + const stepHit = result?.aggregations?.step.image.hits.hits[0]?._source as Ping; return { - blob: stepHit.synthetics?.blob ?? null, + blob: stepHit?.synthetics?.blob ?? null, stepName: stepHit?.synthetics?.step?.name ?? '', totalSteps: result?.hits?.total.value, }; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.test.ts index 1034318257f66..af7752b05997e 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.test.ts @@ -14,9 +14,9 @@ describe('getJourneySteps request module', () => { expect(formatSyntheticEvents()).toMatchInlineSnapshot(` Array [ "step/end", - "stderr", "cmd/status", "step/screenshot", + "journey/browserconsole", ] `); }); @@ -121,9 +121,9 @@ describe('getJourneySteps request module', () => { "terms": Object { "synthetics.type": Array [ "step/end", - "stderr", "cmd/status", "step/screenshot", + "journey/browserconsole", ], }, } diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts index 3055f169fc495..43d17cb938159 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts @@ -13,7 +13,7 @@ export interface GetJourneyStepsParams { syntheticEventTypes?: string | string[]; } -const defaultEventTypes = ['step/end', 'stderr', 'cmd/status', 'step/screenshot']; +const defaultEventTypes = ['step/end', 'cmd/status', 'step/screenshot', 'journey/browserconsole']; export const formatSyntheticEvents = (eventTypes?: string | string[]) => { if (!eventTypes) { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_last_successful_step.ts b/x-pack/plugins/uptime/server/lib/requests/get_last_successful_step.ts new file mode 100644 index 0000000000000..82958167341c0 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_last_successful_step.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UMElasticsearchQueryFn } from '../adapters/framework'; +import { Ping } from '../../../common/runtime_types/ping'; + +export interface GetStepScreenshotParams { + monitorId: string; + timestamp: string; + stepIndex: number; +} + +export const getStepLastSuccessfulStep: UMElasticsearchQueryFn< + GetStepScreenshotParams, + any +> = async ({ uptimeEsClient, monitorId, stepIndex, timestamp }) => { + const lastSuccessCheckParams = { + size: 1, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + lte: timestamp, + }, + }, + }, + { + term: { + 'monitor.id': monitorId, + }, + }, + { + term: { + 'synthetics.type': 'step/end', + }, + }, + { + term: { + 'synthetics.step.status': 'succeeded', + }, + }, + { + term: { + 'synthetics.step.index': stepIndex, + }, + }, + ], + }, + }, + }; + + const { body: result } = await uptimeEsClient.search({ body: lastSuccessCheckParams }); + + if (result?.hits?.total.value < 1) { + return null; + } + + const step = result?.hits.hits[0]._source as Ping & { '@timestamp': string }; + + return { + ...step, + timestamp: step['@timestamp'], + }; +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index 9e665fb8bbdb0..24109245c2902 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -24,6 +24,7 @@ import { getJourneyScreenshot } from './get_journey_screenshot'; import { getJourneyDetails } from './get_journey_details'; import { getNetworkEvents } from './get_network_events'; import { getJourneyFailedSteps } from './get_journey_failed_steps'; +import { getStepLastSuccessfulStep } from './get_last_successful_step'; export const requests = { getCerts, @@ -42,6 +43,7 @@ export const requests = { getIndexStatus, getJourneySteps, getJourneyFailedSteps, + getStepLastSuccessfulStep, getJourneyScreenshot, getJourneyDetails, getNetworkEvents, diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index 41556d3c8d513..91b5597321ed0 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -27,6 +27,7 @@ import { createGetMonitorDurationRoute } from './monitors/monitors_durations'; import { createGetIndexPatternRoute, createGetIndexStatusRoute } from './index_state'; import { createNetworkEventsRoute } from './network_events'; import { createJourneyFailedStepsRoute } from './pings/journeys'; +import { createLastSuccessfulStepRoute } from './synthetics/last_successful_step'; export * from './types'; export { createRouteWithAuth } from './create_route_with_auth'; @@ -52,4 +53,5 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [ createJourneyScreenshotRoute, createNetworkEventsRoute, createJourneyFailedStepsRoute, + createLastSuccessfulStepRoute, ]; diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts index cda078da01539..2b056498d7f10 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts @@ -18,6 +18,9 @@ export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServ stepIndex: schema.number(), _debug: schema.maybe(schema.boolean()), }), + query: schema.object({ + _debug: schema.maybe(schema.boolean()), + }), }, handler: async ({ uptimeEsClient, request, response }) => { const { checkGroup, stepIndex } = request.params; @@ -28,7 +31,7 @@ export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServ stepIndex, }); - if (result === null) { + if (result === null || !result.blob) { return response.notFound(); } return response.ok({ diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts index def373e88ae16..9b5bffc380c27 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts @@ -15,7 +15,6 @@ export const createJourneyRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => validate: { params: schema.object({ checkGroup: schema.string(), - _debug: schema.maybe(schema.boolean()), }), query: schema.object({ // provides a filter for the types of synthetic events to include @@ -23,21 +22,24 @@ export const createJourneyRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => syntheticEventTypes: schema.maybe( schema.oneOf([schema.arrayOf(schema.string()), schema.string()]) ), + _debug: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request }): Promise => { const { checkGroup } = request.params; const { syntheticEventTypes } = request.query; - const result = await libs.requests.getJourneySteps({ - uptimeEsClient, - checkGroup, - syntheticEventTypes, - }); - const details = await libs.requests.getJourneyDetails({ - uptimeEsClient, - checkGroup, - }); + const [result, details] = await Promise.all([ + await libs.requests.getJourneySteps({ + uptimeEsClient, + checkGroup, + syntheticEventTypes, + }), + await libs.requests.getJourneyDetails({ + uptimeEsClient, + checkGroup, + }), + ]); return { checkGroup, @@ -53,6 +55,7 @@ export const createJourneyFailedStepsRoute: UMRestApiRouteFactory = (libs: UMSer validate: { query: schema.object({ checkGroups: schema.arrayOf(schema.string()), + _debug: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts b/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts new file mode 100644 index 0000000000000..a1523fae9d4a1 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.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 { schema } from '@kbn/config-schema'; +import { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; + +export const createLastSuccessfulStepRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/synthetics/step/success/', + validate: { + query: schema.object({ + monitorId: schema.string(), + stepIndex: schema.number(), + timestamp: schema.string(), + _debug: schema.maybe(schema.boolean()), + }), + }, + handler: async ({ uptimeEsClient, request, response }) => { + const { timestamp, monitorId, stepIndex } = request.query; + + return await libs.requests.getStepLastSuccessfulStep({ + uptimeEsClient, + monitorId, + stepIndex, + timestamp, + }); + }, +}); diff --git a/x-pack/test/accessibility/apps/ml.ts b/x-pack/test/accessibility/apps/ml.ts index 08762353132e7..164c7032d9dd3 100644 --- a/x-pack/test/accessibility/apps/ml.ts +++ b/x-pack/test/accessibility/apps/ml.ts @@ -278,6 +278,7 @@ export default function ({ getService }: FtrProviderContext) { it('data frame analytics create job validation step for outlier job', async () => { await ml.dataFrameAnalyticsCreation.continueToValidationStep(); + await ml.dataFrameAnalyticsCreation.assertValidationCalloutsExists(); await a11y.testAppSnapshot(); }); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts index 1847b3fbbce2a..59efbaf24d9e0 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts @@ -132,7 +132,7 @@ async function alwaysFiringExecutor(alertExecutorOptions: any) { } } - await services.scopedClusterClient.index({ + await services.scopedClusterClient.asCurrentUser.index({ index: params.index, refresh: 'wait_for', body: { @@ -212,7 +212,7 @@ function getNeverFiringAlertType() { defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', async executor({ services, params, state }) { - await services.callCluster('index', { + await services.scopedClusterClient.asCurrentUser.index({ index: params.index, refresh: 'wait_for', body: { @@ -252,7 +252,7 @@ function getFailingAlertType() { defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', async executor({ services, params, state }) { - await services.callCluster('index', { + await services.scopedClusterClient.asCurrentUser.index({ index: params.index, refresh: 'wait_for', body: { @@ -269,7 +269,6 @@ function getFailingAlertType() { } function getAuthorizationAlertType(core: CoreSetup) { - const clusterClient = core.elasticsearch.legacy.client; const paramsSchema = schema.object({ callClusterAuthorizationIndex: schema.string(), savedObjectsClientType: schema.string(), @@ -298,7 +297,7 @@ function getAuthorizationAlertType(core: CoreSetup) { let callClusterSuccess = false; let callClusterError; try { - await services.callCluster('index', { + await services.scopedClusterClient.asCurrentUser.index({ index: params.callClusterAuthorizationIndex, refresh: 'wait_for', body: { @@ -310,11 +309,11 @@ function getAuthorizationAlertType(core: CoreSetup) { callClusterError = e; } // Call scoped cluster - const scopedClusterClient = services.getLegacyScopedClusterClient(clusterClient); + const scopedClusterClient = services.scopedClusterClient; let callScopedClusterSuccess = false; let callScopedClusterError; try { - await scopedClusterClient.callAsCurrentUser('index', { + await scopedClusterClient.asCurrentUser.index({ index: params.callClusterAuthorizationIndex, refresh: 'wait_for', body: { @@ -338,7 +337,7 @@ function getAuthorizationAlertType(core: CoreSetup) { savedObjectsClientError = e; } // Save the result - await services.callCluster('index', { + await services.scopedClusterClient.asCurrentUser.index({ index: params.index, refresh: 'wait_for', body: { @@ -417,7 +416,7 @@ function getPatternFiringAlertType() { } if (params.reference) { - await services.scopedClusterClient.index({ + await services.scopedClusterClient.asCurrentUser.index({ index: ES_TEST_INDEX_NAME, refresh: 'wait_for', body: { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 05060a2fcf7a9..536c4cbbd710f 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -533,11 +533,9 @@ instanceStateValue: true savedObjectsClientSuccess: false, callClusterError: { ...searchResult.hits.hits[0]._source.state.callClusterError, - statusCode: 403, }, callScopedClusterError: { ...searchResult.hits.hits[0]._source.state.callScopedClusterError, - statusCode: 403, }, savedObjectsClientError: { ...searchResult.hits.hits[0]._source.state.savedObjectsClientError, diff --git a/x-pack/test/api_integration/apis/management/index_management/component_templates.ts b/x-pack/test/api_integration/apis/management/index_management/component_templates.ts index 4f23a29eff898..89cb45df22d56 100644 --- a/x-pack/test/api_integration/apis/management/index_management/component_templates.ts +++ b/x-pack/test/api_integration/apis/management/index_management/component_templates.ts @@ -349,7 +349,7 @@ export default function ({ getService }: FtrProviderContext) { }); }); - it('should return an error for any component templates not sucessfully deleted', async () => { + it.skip('should return an error for any component templates not sucessfully deleted', async () => { const COMPONENT_DOES_NOT_EXIST = 'component_does_not_exist'; const { name: componentTemplateName } = componentTemplateD; @@ -359,7 +359,7 @@ export default function ({ getService }: FtrProviderContext) { expect(body.itemsDeleted).to.eql([componentTemplateName]); expect(body.errors[0].name).to.eql(COMPONENT_DOES_NOT_EXIST); - expect(body.errors[0].error.msg).to.contain('index_template_missing_exception'); + expect(body.errors[0].error.msg).to.contain('resource_not_found_exception'); }); }); diff --git a/x-pack/test/api_integration/apis/ml/job_validation/validate.ts b/x-pack/test/api_integration/apis/ml/job_validation/validate.ts index 5b87d6e0670c8..3f349d0b289e8 100644 --- a/x-pack/test/api_integration/apis/ml/job_validation/validate.ts +++ b/x-pack/test/api_integration/apis/ml/job_validation/validate.ts @@ -6,18 +6,20 @@ */ import expect from '@kbn/expect'; +import { + basicValidJobMessages, + basicInvalidJobMessages, + nonBasicIssuesMessages, +} from '../../../../../../x-pack/plugins/ml/common/constants/messages.test.mock'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { USER } from '../../../../functional/services/ml/security_common'; import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; -import pkg from '../../../../../../package.json'; export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const supertest = getService('supertestWithoutAuth'); const ml = getService('ml'); - const VALIDATED_SEPARATELY = 'this value is not validated directly'; - describe('Validate job', function () { before(async () => { await esArchiver.loadIfNeeded('ml/ecommerce'); @@ -75,44 +77,7 @@ export default ({ getService }: FtrProviderContext) => { .send(requestBody) .expect(200); - expect(body).to.eql([ - { - id: 'job_id_valid', - heading: 'Job ID format is valid', - text: - 'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, starts and ends with an alphanumeric character, and is no more than 64 characters long.', - url: `https://www.elastic.co/guide/en/elasticsearch/reference/${pkg.branch}/ml-job-resource.html#ml-job-resource`, - status: 'success', - }, - { - id: 'detectors_function_not_empty', - heading: 'Detector functions', - text: 'Presence of detector functions validated in all detectors.', - url: `https://www.elastic.co/guide/en/machine-learning/${pkg.branch}/create-jobs.html#detectors`, - status: 'success', - }, - { - id: 'success_bucket_span', - bucketSpan: '15m', - heading: 'Bucket span', - text: 'Format of "15m" is valid and passed validation checks.', - url: `https://www.elastic.co/guide/en/machine-learning/${pkg.branch}/create-jobs.html#bucket-span`, - status: 'success', - }, - { - id: 'success_time_range', - heading: 'Time range', - text: 'Valid and long enough to model patterns in the data.', - status: 'success', - }, - { - id: 'success_mml', - heading: 'Model memory limit', - text: 'Valid and within the estimated model memory limit.', - url: `https://www.elastic.co/guide/en/machine-learning/${pkg.branch}/create-jobs.html#model-memory-limits`, - status: 'success', - }, - ]); + expect(body).to.eql(basicValidJobMessages); }); it('should recognize a basic invalid job configuration and skip advanced checks', async () => { @@ -156,36 +121,7 @@ export default ({ getService }: FtrProviderContext) => { .send(requestBody) .expect(200); - expect(body).to.eql([ - { - id: 'job_id_invalid', - text: - 'Job ID is invalid. It can contain lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores and must start and end with an alphanumeric character.', - url: `https://www.elastic.co/guide/en/elasticsearch/reference/${pkg.branch}/ml-job-resource.html#ml-job-resource`, - status: 'error', - }, - { - id: 'detectors_function_not_empty', - heading: 'Detector functions', - text: 'Presence of detector functions validated in all detectors.', - url: `https://www.elastic.co/guide/en/machine-learning/${pkg.branch}/create-jobs.html#detectors`, - status: 'success', - }, - { - id: 'bucket_span_valid', - bucketSpan: '15m', - heading: 'Bucket span', - text: 'Format of "15m" is valid.', - url: `https://www.elastic.co/guide/en/elasticsearch/reference/${pkg.branch}/ml-job-resource.html#ml-analysisconfig`, - status: 'success', - }, - { - id: 'skipped_extended_tests', - text: - 'Skipped additional checks because the basic requirements of the job configuration were not met.', - status: 'warning', - }, - ]); + expect(body).to.eql(basicInvalidJobMessages); }); it('should recognize non-basic issues in job configuration', async () => { @@ -244,74 +180,7 @@ export default ({ getService }: FtrProviderContext) => { } }); - const expectedResponse = [ - { - id: 'job_id_valid', - heading: 'Job ID format is valid', - text: - 'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, starts and ends with an alphanumeric character, and is no more than 64 characters long.', - url: `https://www.elastic.co/guide/en/elasticsearch/reference/${pkg.branch}/ml-job-resource.html#ml-job-resource`, - status: 'success', - }, - { - id: 'detectors_function_not_empty', - heading: 'Detector functions', - text: 'Presence of detector functions validated in all detectors.', - url: `https://www.elastic.co/guide/en/machine-learning/${pkg.branch}/create-jobs.html#detectors`, - status: 'success', - }, - { - id: 'cardinality_model_plot_high', - modelPlotCardinality: VALIDATED_SEPARATELY, - text: VALIDATED_SEPARATELY, - status: VALIDATED_SEPARATELY, - }, - { - id: 'cardinality_partition_field', - fieldName: 'order_id', - text: - 'Cardinality of partition_field "order_id" is above 1000 and might result in high memory usage.', - url: `https://www.elastic.co/guide/en/machine-learning/${pkg.branch}/create-jobs.html#cardinality`, - status: 'warning', - }, - { - id: 'bucket_span_high', - heading: 'Bucket span', - text: - 'Bucket span is 1 day or more. Be aware that days are considered as UTC days, not local days.', - url: `https://www.elastic.co/guide/en/machine-learning/${pkg.branch}/create-jobs.html#bucket-span`, - status: 'info', - }, - { - bucketSpanCompareFactor: 25, - id: 'time_range_short', - minTimeSpanReadable: '2 hours', - heading: 'Time range', - text: - 'The selected or available time range might be too short. The recommended minimum time range should be at least 2 hours and 25 times the bucket span.', - status: 'warning', - }, - { - id: 'success_influencers', - text: 'Influencer configuration passed the validation checks.', - url: `https://www.elastic.co/guide/en/machine-learning/${pkg.branch}/ml-influencers.html`, - status: 'success', - }, - { - id: 'half_estimated_mml_greater_than_mml', - mml: '1MB', - text: - 'The specified model memory limit is less than half of the estimated model memory limit and will likely hit the hard limit.', - url: `https://www.elastic.co/guide/en/machine-learning/${pkg.branch}/create-jobs.html#model-memory-limits`, - status: 'warning', - }, - { - id: 'missing_summary_count_field_name', - status: 'error', - text: - 'A job configured with a datafeed with aggregations must set summary_count_field_name; use doc_count or suitable alternative.', - }, - ]; + const expectedResponse = nonBasicIssuesMessages; expect(body.length).to.eql( expectedResponse.length, @@ -327,12 +196,6 @@ export default ({ getService }: FtrProviderContext) => { if (entry.id === 'cardinality_model_plot_high') { // don't check the exact value of modelPlotCardinality as this is an approximation expect(responseEntry).to.have.property('modelPlotCardinality'); - expect(responseEntry) - .to.have.property('text') - .match( - /^The estimated cardinality of [0-9]+ of fields relevant to creating model plots might result in resource intensive jobs./ - ); - expect(responseEntry).to.have.property('status', 'warning'); } else { expect(responseEntry).to.eql(entry); } diff --git a/x-pack/test/api_integration/apis/telemetry/index.js b/x-pack/test/api_integration/apis/telemetry/index.ts similarity index 79% rename from x-pack/test/api_integration/apis/telemetry/index.js rename to x-pack/test/api_integration/apis/telemetry/index.ts index 5fa88fa761a0e..9fc67a35e6b19 100644 --- a/x-pack/test/api_integration/apis/telemetry/index.js +++ b/x-pack/test/api_integration/apis/telemetry/index.ts @@ -5,7 +5,9 @@ * 2.0. */ -export default function ({ loadTestFile }) { +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { describe('Telemetry', () => { loadTestFile(require.resolve('./telemetry')); loadTestFile(require.resolve('./telemetry_local')); diff --git a/x-pack/test/api_integration/apis/telemetry/telemetry.js b/x-pack/test/api_integration/apis/telemetry/telemetry.ts similarity index 59% rename from x-pack/test/api_integration/apis/telemetry/telemetry.js rename to x-pack/test/api_integration/apis/telemetry/telemetry.ts index 39d18fdaa3387..fdf55fd6f4670 100644 --- a/x-pack/test/api_integration/apis/telemetry/telemetry.js +++ b/x-pack/test/api_integration/apis/telemetry/telemetry.ts @@ -7,8 +7,19 @@ import expect from '@kbn/expect'; import moment from 'moment'; -import multiClusterFixture from './fixtures/multicluster'; -import basicClusterFixture from './fixtures/basiccluster'; +import type { SuperTest } from 'supertest'; +import type supertestAsPromised from 'supertest-as-promised'; +import deepmerge from 'deepmerge'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +import multiClusterFixture from './fixtures/multicluster.json'; +import basicClusterFixture from './fixtures/basiccluster.json'; +import ossRootTelemetrySchema from '../../../../../src/plugins/telemetry/schema/oss_root.json'; +import xpackRootTelemetrySchema from '../../../../plugins/telemetry_collection_xpack/schema/xpack_root.json'; +import monitoringRootTelemetrySchema from '../../../../plugins/telemetry_collection_xpack/schema/xpack_monitoring.json'; +import ossPluginsTelemetrySchema from '../../../../../src/plugins/telemetry/schema/oss_plugins.json'; +import xpackPluginsTelemetrySchema from '../../../../plugins/telemetry_collection_xpack/schema/xpack_plugins.json'; +import { assertTelemetryPayload } from '../../../../../test/api_integration/apis/telemetry/utils'; /** * Update the .monitoring-* documents loaded via the archiver to the recent `timestamp` @@ -17,7 +28,12 @@ import basicClusterFixture from './fixtures/basiccluster'; * @param toTimestamp The upper timestamp limit to query the documents from * @param timestamp The new timestamp to be set */ -function updateMonitoringDates(esSupertest, fromTimestamp, toTimestamp, timestamp) { +function updateMonitoringDates( + esSupertest: SuperTest, + fromTimestamp: string, + toTimestamp: string, + timestamp: string +) { return Promise.all([ esSupertest .post('/.monitoring-es-*/_update_by_query?refresh=true') @@ -58,7 +74,7 @@ function updateMonitoringDates(esSupertest, fromTimestamp, toTimestamp, timestam ]); } -export default function ({ getService }) { +export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const esSupertest = getService('esSupertest'); @@ -66,23 +82,52 @@ export default function ({ getService }) { describe('/api/telemetry/v2/clusters/_stats', () => { const timestamp = new Date().toISOString(); describe('monitoring/multicluster', () => { + let localXPack: Record; + let monitoring: Array>; + const archive = 'monitoring/multicluster'; const fromTimestamp = '2017-08-15T21:00:00.000Z'; const toTimestamp = '2017-08-16T00:00:00.000Z'; + before(async () => { await esArchiver.load(archive); await updateMonitoringDates(esSupertest, fromTimestamp, toTimestamp, timestamp); - }); - after(() => esArchiver.unload(archive)); - it('should load multiple trial-license clusters', async () => { + const { body } = await supertest .post('/api/telemetry/v2/clusters/_stats') .set('kbn-xsrf', 'xxx') .send({ unencrypted: true }) .expect(200); - expect(body).length(4); - const [localXPack, ...monitoring] = body; + expect(body.length).to.be.greaterThan(1); + localXPack = body.shift(); + monitoring = body; + }); + after(() => esArchiver.unload(archive)); + + it('should pass the schema validations', () => { + const root = deepmerge(ossRootTelemetrySchema, xpackRootTelemetrySchema); + + // Merging root to monitoring because `kibana` may be passed in some cases for old collection methods reporting to a newer monitoring cluster + const monitoringRoot = deepmerge( + root, + // It's nested because of the way it's collected and declared + monitoringRootTelemetrySchema.properties.monitoringTelemetry.properties.stats.items + ); + const plugins = deepmerge(ossPluginsTelemetrySchema, xpackPluginsTelemetrySchema); + try { + assertTelemetryPayload({ root, plugins }, localXPack); + monitoring.forEach((stats) => { + assertTelemetryPayload({ root: monitoringRoot, plugins }, stats); + }); + } catch (err) { + err.message = `The telemetry schemas in 'x-pack/plugins/telemetry_collection_xpack/schema/' are out-of-date, please update it as required: ${err.message}`; + throw err; + } + }); + + it('should load multiple trial-license clusters', async () => { + expect(monitoring).length(3); expect(localXPack.collectionSource).to.eql('local_xpack'); expect(monitoring).to.eql(multiClusterFixture.map((item) => ({ ...item, timestamp }))); }); diff --git a/x-pack/test/apm_api_integration/tests/transactions/distribution.ts b/x-pack/test/apm_api_integration/tests/transactions/distribution.ts index 770fc90267680..cfedcc9ac2232 100644 --- a/x-pack/test/apm_api_integration/tests/transactions/distribution.ts +++ b/x-pack/test/apm_api_integration/tests/transactions/distribution.ts @@ -82,16 +82,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { .toMatchInline(` Array [ Object { - "traceId": "a4eb3781a21dc11d289293076fd1a1b3", - "transactionId": "21892bde4ff1364d", + "traceId": "af0f18dc0841cfc1f567e7e1d55cfda7", + "transactionId": "925f02e5ac122897", }, Object { "traceId": "ccd327537120e857bdfa407434dfb9a4", "transactionId": "c5f923159cc1b8a6", }, Object { - "traceId": "af0f18dc0841cfc1f567e7e1d55cfda7", - "transactionId": "925f02e5ac122897", + "traceId": "a4eb3781a21dc11d289293076fd1a1b3", + "transactionId": "21892bde4ff1364d", }, ] `); diff --git a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts index 7b72e7af6bbad..77da9ecce3294 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts @@ -14,7 +14,7 @@ export default function (providerContext: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - describe('fleet_reassign_agent', () => { + describe('reassign agent(s)', () => { before(async () => { await esArchiver.load('fleet/empty_fleet_server'); }); @@ -29,99 +29,121 @@ export default function (providerContext: FtrProviderContext) { await esArchiver.unload('fleet/empty_fleet_server'); }); - it('should allow to reassign single agent', async () => { - await supertest - .put(`/api/fleet/agents/agent1/reassign`) - .set('kbn-xsrf', 'xxx') - .send({ - policy_id: 'policy2', - }) - .expect(200); - const { body } = await supertest.get(`/api/fleet/agents/agent1`); - expect(body.item.policy_id).to.eql('policy2'); - }); + describe('reassign single agent', () => { + it('should allow to reassign single agent', async () => { + await supertest + .put(`/api/fleet/agents/agent1/reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: 'policy2', + }) + .expect(200); + const { body } = await supertest.get(`/api/fleet/agents/agent1`); + expect(body.item.policy_id).to.eql('policy2'); + }); - it('should throw an error for invalid policy id for single reassign', async () => { - await supertest - .put(`/api/fleet/agents/agent1/reassign`) - .set('kbn-xsrf', 'xxx') - .send({ - policy_id: 'INVALID_ID', - }) - .expect(404); - }); + it('should throw an error for invalid policy id for single reassign', async () => { + await supertest + .put(`/api/fleet/agents/agent1/reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: 'INVALID_ID', + }) + .expect(404); + }); - it('should allow to reassign multiple agents by id', async () => { - await supertest - .post(`/api/fleet/agents/bulk_reassign`) - .set('kbn-xsrf', 'xxx') - .send({ - agents: ['agent2', 'agent3'], - policy_id: 'policy2', - }) - .expect(200); - const [agent2data, agent3data] = await Promise.all([ - supertest.get(`/api/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), - supertest.get(`/api/fleet/agents/agent3`).set('kbn-xsrf', 'xxx'), - ]); - expect(agent2data.body.item.policy_id).to.eql('policy2'); - expect(agent3data.body.item.policy_id).to.eql('policy2'); - }); + it('can reassign from unmanaged policy to unmanaged', async () => { + // policy2 is not managed + // reassign succeeds + await supertest + .put(`/api/fleet/agents/agent1/reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: 'policy2', + }) + .expect(200); + }); + + it('cannot reassign from unmanaged policy to managed', async () => { + // agent1 is enrolled in policy1. set policy1 to managed + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: true }) + .expect(200); - it('should allow to reassign multiple agents by kuery', async () => { - await supertest - .post(`/api/fleet/agents/bulk_reassign`) - .set('kbn-xsrf', 'xxx') - .send({ - agents: 'fleet-agents.active: true', - policy_id: 'policy2', - }) - .expect(200); - const { body } = await supertest.get(`/api/fleet/agents`).set('kbn-xsrf', 'xxx'); - expect(body.total).to.eql(4); - body.list.forEach((agent: any) => { - expect(agent.policy_id).to.eql('policy2'); + // reassign fails + await supertest + .put(`/api/fleet/agents/agent1/reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: 'policy2', + }) + .expect(400); }); }); - it('should throw an error for invalid policy id for bulk reassign', async () => { - await supertest - .post(`/api/fleet/agents/bulk_reassign`) - .set('kbn-xsrf', 'xxx') - .send({ - agents: ['agent2', 'agent3'], - policy_id: 'INVALID_ID', - }) - .expect(404); - }); + describe('bulk reassign agents', () => { + it('should allow to reassign multiple agents by id', async () => { + await supertest + .post(`/api/fleet/agents/bulk_reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent2', 'agent3'], + policy_id: 'policy2', + }) + .expect(200); + const [agent2data, agent3data] = await Promise.all([ + supertest.get(`/api/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/fleet/agents/agent3`).set('kbn-xsrf', 'xxx'), + ]); + expect(agent2data.body.item.policy_id).to.eql('policy2'); + expect(agent3data.body.item.policy_id).to.eql('policy2'); + }); - it('can reassign from unmanaged policy to unmanaged', async () => { - // policy2 is not managed - // reassign succeeds - await supertest - .put(`/api/fleet/agents/agent1/reassign`) - .set('kbn-xsrf', 'xxx') - .send({ - policy_id: 'policy2', - }) - .expect(200); - }); - it('cannot reassign from unmanaged policy to managed', async () => { - // agent1 is enrolled in policy1. set policy1 to managed - await supertest - .put(`/api/fleet/agent_policies/policy1`) - .set('kbn-xsrf', 'xxx') - .send({ name: 'Test policy', namespace: 'default', is_managed: true }) - .expect(200); + it('should allow to reassign multiple agents by id -- some invalid', async () => { + await supertest + .post(`/api/fleet/agents/bulk_reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent2', 'INVALID_ID', 'agent3', 'MISSING_ID', 'etc'], + policy_id: 'policy2', + }) + .expect(200); + const [agent2data, agent3data] = await Promise.all([ + supertest.get(`/api/fleet/agents/agent2`), + supertest.get(`/api/fleet/agents/agent3`), + ]); + expect(agent2data.body.item.policy_id).to.eql('policy2'); + expect(agent3data.body.item.policy_id).to.eql('policy2'); + }); - // reassign fails - await supertest - .put(`/api/fleet/agents/agent1/reassign`) - .set('kbn-xsrf', 'xxx') - .send({ - policy_id: 'policy2', - }) - .expect(400); + it('should allow to reassign multiple agents by kuery', async () => { + await supertest + .post(`/api/fleet/agents/bulk_reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: 'fleet-agents.active: true', + policy_id: 'policy2', + }) + .expect(200); + const { body } = await supertest.get(`/api/fleet/agents`).set('kbn-xsrf', 'xxx'); + expect(body.total).to.eql(4); + body.list.forEach((agent: any) => { + expect(agent.policy_id).to.eql('policy2'); + }); + }); + + it('should throw an error for invalid policy id for bulk reassign', async () => { + await supertest + .post(`/api/fleet/agents/bulk_reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent2', 'agent3'], + policy_id: 'INVALID_ID', + }) + .expect(404); + }); }); }); } diff --git a/x-pack/test/functional/apps/dashboard/reporting/__snapshots__/download_csv.snap b/x-pack/test/functional/apps/dashboard/reporting/__snapshots__/download_csv.snap new file mode 100644 index 0000000000000..10384b865c82e --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/reporting/__snapshots__/download_csv.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dashboard Reporting Download CSV E-Commerce Data Download CSV export of a saved search panel 1`] = ` +"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"products.created_on\\",sku +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,19,716724,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes, Women's Clothing\\",EUR,45,591503,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0006400064, ZO0150601506\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0638206382, ZO0038800388\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0297602976, ZO0565605656\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0561405614, ZO0281602816\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,41,591636,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0385003850, ZO0408604086\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0505605056, ZO0513605136\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0276702767, ZO0291702917\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0046600466, ZO0050800508\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing, Men's Shoes\\",EUR,48,590970,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0455604556, ZO0680806808\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,46,591299,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0229002290, ZO0674406744\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0529905299, ZO0617006170\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,13,591175,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0299402994, ZO0433504335\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,21,591297,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0257502575, ZO0451704517\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,14,591149,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0584905849, ZO0578405784\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,27,591754,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0335803358, ZO0325903259\\" +" +`; + +exports[`dashboard Reporting Download CSV E-Commerce Data Downloads a filtered CSV export of a saved search panel 1`] = ` +"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"products.created_on\\",sku +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,19,716724,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes, Women's Clothing\\",EUR,45,591503,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0006400064, ZO0150601506\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0638206382, ZO0038800388\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0297602976, ZO0565605656\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0561405614, ZO0281602816\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,41,591636,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0385003850, ZO0408604086\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0505605056, ZO0513605136\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0276702767, ZO0291702917\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0046600466, ZO0050800508\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing, Men's Shoes\\",EUR,48,590970,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0455604556, ZO0680806808\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,46,591299,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0229002290, ZO0674406744\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0529905299, ZO0617006170\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,13,591175,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0299402994, ZO0433504335\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,21,591297,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0257502575, ZO0451704517\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,14,591149,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0584905849, ZO0578405784\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,27,591754,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0335803358, ZO0325903259\\" +" +`; + +exports[`dashboard Reporting Download CSV Field Formatters and Scripted Fields Download CSV export of a saved search panel 1`] = ` +"date,\\"_id\\",name,gender,value,year,\\"years_ago\\",\\"date_informal\\" +\\"Jan 1, 1984 @ 00:00:00.000\\",\\"1984-Fethany-F\\",Fethany,F,5,1984,\\"35.00000000000000000000\\",\\"Jan 1st 84\\" +\\"Jan 1, 1983 @ 00:00:00.000\\",\\"1983-Fethany-F\\",Fethany,F,\\"1,043\\",1983,\\"36.00000000000000000000\\",\\"Jan 1st 83\\" +\\"Jan 1, 1982 @ 00:00:00.000\\",\\"1982-Fethany-F\\",Fethany,F,780,1982,\\"37.00000000000000000000\\",\\"Jan 1st 82\\" +\\"Jan 1, 1981 @ 00:00:00.000\\",\\"1981-Fethany-F\\",Fethany,F,655,1981,\\"38.00000000000000000000\\",\\"Jan 1st 81\\" +\\"Jan 1, 1980 @ 00:00:00.000\\",\\"1980-Fethany-F\\",Fethany,F,702,1980,\\"39.00000000000000000000\\",\\"Jan 1st 80\\" +" +`; diff --git a/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts index 72f07ef90d703..d4a909f6a0474 100644 --- a/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts +++ b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts @@ -9,91 +9,139 @@ import { REPO_ROOT } from '@kbn/utils'; import expect from '@kbn/expect'; import fs from 'fs'; import path from 'path'; -import * as Rx from 'rxjs'; -import { filter, first, map, timeout } from 'rxjs/operators'; import { FtrProviderContext } from '../../../ftr_provider_context'; -const csvPath = path.resolve(REPO_ROOT, 'target/functional-tests/downloads/Ecommerce Data.csv'); - -// checks every 100ms for the file to exist in the download dir -// just wait up to 5 seconds -const getDownload$ = (filePath: string) => { - return Rx.interval(100).pipe( - map(() => fs.existsSync(filePath)), - filter((value) => value === true), - first(), - timeout(5000) - ); -}; - export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const browser = getService('browser'); const dashboardPanelActions = getService('dashboardPanelActions'); const log = getService('log'); const testSubjects = getService('testSubjects'); + const filterBar = getService('filterBar'); const find = getService('find'); - const PageObjects = getPageObjects(['reporting', 'common', 'dashboard']); + const retry = getService('retry'); + const PageObjects = getPageObjects(['reporting', 'common', 'dashboard', 'timePicker']); + + const getCsvPath = (name: string) => + path.resolve(REPO_ROOT, `target/functional-tests/downloads/${name}.csv`); + + // checks every 100ms for the file to exist in the download dir + // just wait up to 5 seconds + const getDownload = (filePath: string) => { + return retry.tryForTime(5000, async () => { + expect(fs.existsSync(filePath)).to.be(true); + return fs.readFileSync(filePath).toString(); + }); + }; + + const clickActionsMenu = async (headingTestSubj: string) => { + const savedSearchPanel = await testSubjects.find('embeddablePanelHeading-' + headingTestSubj); + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + }; + + const clickDownloadCsv = async () => { + log.debug('click "More"'); + await dashboardPanelActions.clickContextMenuMoreItem(); + + const actionItemTestSubj = 'embeddablePanelAction-downloadCsvReport'; + await testSubjects.existOrFail(actionItemTestSubj); // wait for the full panel to display or else the test runner could click the wrong option! + log.debug('click "Download CSV"'); + await testSubjects.click(actionItemTestSubj); + await testSubjects.existOrFail('csvDownloadStarted'); // validate toast panel + }; describe('Download CSV', () => { before('initialize tests', async () => { log.debug('ReportingPage:initTests'); - await esArchiver.loadIfNeeded('reporting/ecommerce'); - await esArchiver.loadIfNeeded('reporting/ecommerce_kibana'); await browser.setWindowSize(1600, 850); }); - after('clean up archives and previous file download', async () => { - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); - }); - afterEach('remove download', () => { try { - fs.unlinkSync(csvPath); + fs.unlinkSync(getCsvPath('Ecommerce Data')); } catch (e) { // it might not have been there to begin with } }); - it('Downloads a CSV export of a saved search panel', async function () { - await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); - const savedSearchPanel = await testSubjects.find('embeddablePanelHeading-EcommerceData'); - await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + describe('E-Commerce Data', () => { + before(async () => { + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana'); + }); + after(async () => { + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + }); - const actionExists = await testSubjects.exists('embeddablePanelAction-downloadCsvReport'); - if (!actionExists) { - await dashboardPanelActions.clickContextMenuMoreItem(); - } - await testSubjects.existOrFail('embeddablePanelAction-downloadCsvReport'); // wait for the full panel to display or else the test runner could click the wrong option! - await testSubjects.click('embeddablePanelAction-downloadCsvReport'); - await testSubjects.existOrFail('csvDownloadStarted'); // validate toast panel + it('Download CSV export of a saved search panel', async function () { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); + await clickActionsMenu('EcommerceData'); + await clickDownloadCsv(); + + const csvFile = await getDownload(getCsvPath('Ecommerce Data')); + expectSnapshot(csvFile).toMatch(); + }); - const fileExists = await getDownload$(csvPath).toPromise(); - expect(fileExists).to.be(true); + it('Downloads a filtered CSV export of a saved search panel', async function () { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); - // no need to validate download contents, API Integration tests do that some different variations + // add a filter + await filterBar.addFilter('currency', 'is', 'EUR'); + + await clickActionsMenu('EcommerceData'); + await clickDownloadCsv(); + + const csvFile = await getDownload(getCsvPath('Ecommerce Data')); + expectSnapshot(csvFile).toMatch(); + }); + + it('Gets the correct filename if panel titles are hidden', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard Hidden Panel Titles'); + const savedSearchPanel = await find.byCssSelector( + '[data-test-embeddable-id="94eab06f-60ac-4a85-b771-3a8ed475c9bb"]' + ); // panel title is hidden + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + + await clickDownloadCsv(); + await testSubjects.existOrFail('csvDownloadStarted'); + + const csvFile = await getDownload(getCsvPath('Ecommerce Data')); // file exists with proper name + expect(csvFile).to.not.be(null); + }); }); - it('Gets the correct filename if panel titles are hidden', async () => { - await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard Hidden Panel Titles'); - const savedSearchPanel = await find.byCssSelector( - '[data-test-embeddable-id="94eab06f-60ac-4a85-b771-3a8ed475c9bb"]' - ); // panel title is hidden - await dashboardPanelActions.toggleContextMenu(savedSearchPanel); - - const actionExists = await testSubjects.exists('embeddablePanelAction-downloadCsvReport'); - if (!actionExists) { - await dashboardPanelActions.clickContextMenuMoreItem(); - } - await testSubjects.existOrFail('embeddablePanelAction-downloadCsvReport'); - await testSubjects.click('embeddablePanelAction-downloadCsvReport'); - await testSubjects.existOrFail('csvDownloadStarted'); + describe('Field Formatters and Scripted Fields', () => { + before(async () => { + await esArchiver.load('reporting/hugedata'); + }); + after(async () => { + await esArchiver.unload('reporting/hugedata'); + }); + + it('Download CSV export of a saved search panel', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('names dashboard'); + await PageObjects.timePicker.setAbsoluteRange( + 'Jan 01, 1980 @ 00:00:00.000', + 'Dec 31, 1984 @ 23:59:59.000' + ); + + await PageObjects.common.sleep(1000); + + await filterBar.addFilter('name.keyword', 'is', 'Fethany'); + + await PageObjects.common.sleep(1000); + + await clickActionsMenu('namessearch'); + await clickDownloadCsv(); - const fileExists = await getDownload$(csvPath).toPromise(); // file exists with proper name - expect(fileExists).to.be(true); + const csvFile = await getDownload(getCsvPath('namessearch')); + expectSnapshot(csvFile).toMatch(); + }); }); }); } diff --git a/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap b/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap index 43771b00525cc..5ddef936b41ae 100644 --- a/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap +++ b/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap @@ -1,40 +1,88 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`discover Discover Generate CSV: archived search generates a report with data 1`] = ` -"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"order_date\\",\\"products.created_on\\",sku -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Shoes\\"\\",\\"\\"Men's Clothing\\"\\",\\"\\"Women's Accessories\\"\\",\\"\\"Men's Accessories\\"\\"]\\",EUR,19,716724,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0687606876\\"\\",\\"\\"ZO0290502905\\"\\",\\"\\"ZO0126701267\\"\\",\\"\\"ZO0308503085\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Women's Shoes\\"\\",\\"\\"Women's Clothing\\"\\"]\\",EUR,45,591503,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0006400064\\"\\",\\"\\"ZO0150601506\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0638206382\\"\\",\\"\\"ZO0038800388\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0297602976\\"\\",\\"\\"ZO0565605656\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0561405614\\"\\",\\"\\"ZO0281602816\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Shoes\\"\\",\\"\\"Men's Clothing\\"\\"]\\",EUR,41,591636,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0385003850\\"\\",\\"\\"ZO0408604086\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0505605056\\"\\",\\"\\"ZO0513605136\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0276702767\\"\\",\\"\\"ZO0291702917\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0046600466\\"\\",\\"\\"ZO0050800508\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Clothing\\"\\",\\"\\"Men's Shoes\\"\\"]\\",EUR,48,590970,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0455604556\\"\\",\\"\\"ZO0680806808\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Women's Clothing\\"\\",\\"\\"Women's Shoes\\"\\"]\\",EUR,46,591299,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0229002290\\"\\",\\"\\"ZO0674406744\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0529905299\\"\\",\\"\\"ZO0617006170\\"\\"]\\" +exports[`discover Discover CSV Export Generate CSV: archived search generates a report with data 1`] = ` +"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"products.created_on\\",sku +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,19,716724,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes, Women's Clothing\\",EUR,45,591503,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0006400064, ZO0150601506\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0638206382, ZO0038800388\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0297602976, ZO0565605656\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0561405614, ZO0281602816\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,41,591636,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0385003850, ZO0408604086\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0505605056, ZO0513605136\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0276702767, ZO0291702917\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0046600466, ZO0050800508\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing, Men's Shoes\\",EUR,48,590970,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0455604556, ZO0680806808\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,46,591299,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0229002290, ZO0674406744\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0529905299, ZO0617006170\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,13,591175,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0299402994, ZO0433504335\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,21,591297,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0257502575, ZO0451704517\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,14,591149,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0584905849, ZO0578405784\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,27,591754,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0335803358, ZO0325903259\\" " `; -exports[`discover Discover Generate CSV: archived search generates a report with filtered data 1`] = ` -"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"order_date\\",\\"products.created_on\\",sku -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Shoes\\"\\",\\"\\"Men's Clothing\\"\\",\\"\\"Women's Accessories\\"\\",\\"\\"Men's Accessories\\"\\"]\\",EUR,19,716724,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0687606876\\"\\",\\"\\"ZO0290502905\\"\\",\\"\\"ZO0126701267\\"\\",\\"\\"ZO0308503085\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Women's Shoes\\"\\",\\"\\"Women's Clothing\\"\\"]\\",EUR,45,591503,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0006400064\\"\\",\\"\\"ZO0150601506\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0638206382\\"\\",\\"\\"ZO0038800388\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0297602976\\"\\",\\"\\"ZO0565605656\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0561405614\\"\\",\\"\\"ZO0281602816\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Shoes\\"\\",\\"\\"Men's Clothing\\"\\"]\\",EUR,41,591636,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0385003850\\"\\",\\"\\"ZO0408604086\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0505605056\\"\\",\\"\\"ZO0513605136\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0276702767\\"\\",\\"\\"ZO0291702917\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0046600466\\"\\",\\"\\"ZO0050800508\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Clothing\\"\\",\\"\\"Men's Shoes\\"\\"]\\",EUR,48,590970,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0455604556\\"\\",\\"\\"ZO0680806808\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Women's Clothing\\"\\",\\"\\"Women's Shoes\\"\\"]\\",EUR,46,591299,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0229002290\\"\\",\\"\\"ZO0674406744\\"\\"]\\" -\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0529905299\\"\\",\\"\\"ZO0617006170\\"\\"]\\" +exports[`discover Discover CSV Export Generate CSV: archived search generates a report with discover:searchFieldsFromSource = true 1`] = ` +"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"products.created_on\\",sku +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,19,716724,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes, Women's Clothing\\",EUR,45,591503,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0006400064, ZO0150601506\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0638206382, ZO0038800388\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0297602976, ZO0565605656\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0561405614, ZO0281602816\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,41,591636,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0385003850, ZO0408604086\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0505605056, ZO0513605136\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0276702767, ZO0291702917\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0046600466, ZO0050800508\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing, Men's Shoes\\",EUR,48,590970,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0455604556, ZO0680806808\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,46,591299,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0229002290, ZO0674406744\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0529905299, ZO0617006170\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,13,591175,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0299402994, ZO0433504335\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,21,591297,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0257502575, ZO0451704517\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,14,591149,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0584905849, ZO0578405784\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,27,591754,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0335803358, ZO0325903259\\" " `; -exports[`discover Discover Generate CSV: new search generates a report with data 1`] = ` +exports[`discover Discover CSV Export Generate CSV: archived search generates a report with filtered data 1`] = ` +"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"products.created_on\\",sku +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,19,716724,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes, Women's Clothing\\",EUR,45,591503,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0006400064, ZO0150601506\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0638206382, ZO0038800388\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0297602976, ZO0565605656\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0561405614, ZO0281602816\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,41,591636,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0385003850, ZO0408604086\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0505605056, ZO0513605136\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0276702767, ZO0291702917\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0046600466, ZO0050800508\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing, Men's Shoes\\",EUR,48,590970,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0455604556, ZO0680806808\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,46,591299,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0229002290, ZO0674406744\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0529905299, ZO0617006170\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,13,591175,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0299402994, ZO0433504335\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,21,591297,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0257502575, ZO0451704517\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,14,591149,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0584905849, ZO0578405784\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,27,591754,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0335803358, ZO0325903259\\" +" +`; + +exports[`discover Discover CSV Export Generate CSV: new search generates a report from a new search with data: default 1`] = ` +"\\"_id\\",\\"_index\\",\\"_score\\",\\"_type\\",category,\\"category.keyword\\",currency,\\"customer_first_name\\",\\"customer_first_name.keyword\\",\\"customer_full_name\\",\\"customer_full_name.keyword\\",\\"customer_gender\\",\\"customer_id\\",\\"customer_last_name\\",\\"customer_last_name.keyword\\",\\"customer_phone\\",\\"day_of_week\\",\\"day_of_week_i\\",email,\\"geoip.city_name\\",\\"geoip.continent_name\\",\\"geoip.country_iso_code\\",\\"geoip.location\\",\\"geoip.region_name\\",manufacturer,\\"manufacturer.keyword\\",\\"order_date\\",\\"order_id\\",\\"products._id\\",\\"products._id.keyword\\",\\"products.base_price\\",\\"products.base_unit_price\\",\\"products.category\\",\\"products.category.keyword\\",\\"products.created_on\\",\\"products.discount_amount\\",\\"products.discount_percentage\\",\\"products.manufacturer\\",\\"products.manufacturer.keyword\\",\\"products.min_price\\",\\"products.price\\",\\"products.product_id\\",\\"products.product_name\\",\\"products.product_name.keyword\\",\\"products.quantity\\",\\"products.sku\\",\\"products.tax_amount\\",\\"products.taxful_price\\",\\"products.taxless_price\\",\\"products.unit_discount_amount\\",sku,\\"taxful_total_price\\",\\"taxless_total_price\\",\\"total_quantity\\",\\"total_unique_products\\",type,user +3AMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,\\"Sultan Al\\",\\"Sultan Al\\",\\"Sultan Al Boone\\",\\"Sultan Al Boone\\",MALE,19,Boone,Boone,,Saturday,5,\\"sultan al@boone-family.zzz\\",\\"Abu Dhabi\\",Asia,AE,\\"{ + \\"\\"coordinates\\"\\": [ + 54.4, + 24.5 + ], + \\"\\"type\\"\\": \\"\\"Point\\"\\" +}\\",\\"Abu Dhabi\\",\\"Angeldale, Oceanavigations, Microlutions\\",\\"Angeldale, Oceanavigations, Microlutions\\",\\"Jul 12, 2019 @ 00:00:00.000\\",716724,\\"sold_product_716724_23975, sold_product_716724_6338, sold_product_716724_14116, sold_product_716724_15290\\",\\"sold_product_716724_23975, sold_product_716724_6338, sold_product_716724_14116, sold_product_716724_15290\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"0, 0, 0, 0\\",\\"0, 0, 0, 0\\",\\"Angeldale, Oceanavigations, Microlutions, Oceanavigations\\",\\"Angeldale, Oceanavigations, Microlutions, Oceanavigations\\",\\"42.39, 32.99, 10.34, 6.11\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"23,975, 6,338, 14,116, 15,290\\",\\"Winter boots - cognac, Trenchcoat - black, Watch - black, Hat - light grey multicolor\\",\\"Winter boots - cognac, Trenchcoat - black, Watch - black, Hat - light grey multicolor\\",\\"1, 1, 1, 1\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\",\\"0, 0, 0, 0\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"0, 0, 0, 0\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\",\\"173.96\\",\\"173.96\\",4,4,order,sultan +" +`; + +exports[`discover Discover CSV Export Generate CSV: new search generates a report from a new search with data: discover:searchFieldsFromSource 1`] = ` "\\"_id\\",\\"_index\\",\\"_score\\",\\"_type\\",category,\\"category.keyword\\",currency,\\"customer_first_name\\",\\"customer_first_name.keyword\\",\\"customer_full_name\\",\\"customer_full_name.keyword\\",\\"customer_gender\\",\\"customer_id\\",\\"customer_last_name\\",\\"customer_last_name.keyword\\",\\"customer_phone\\",\\"day_of_week\\",\\"day_of_week_i\\",email,\\"geoip.city_name\\",\\"geoip.continent_name\\",\\"geoip.country_iso_code\\",\\"geoip.location\\",\\"geoip.region_name\\",manufacturer,\\"manufacturer.keyword\\",\\"order_date\\",\\"order_id\\",\\"products._id\\",\\"products._id.keyword\\",\\"products.base_price\\",\\"products.base_unit_price\\",\\"products.category\\",\\"products.category.keyword\\",\\"products.created_on\\",\\"products.discount_amount\\",\\"products.discount_percentage\\",\\"products.manufacturer\\",\\"products.manufacturer.keyword\\",\\"products.min_price\\",\\"products.price\\",\\"products.product_id\\",\\"products.product_name\\",\\"products.product_name.keyword\\",\\"products.quantity\\",\\"products.sku\\",\\"products.tax_amount\\",\\"products.taxful_price\\",\\"products.taxless_price\\",\\"products.unit_discount_amount\\",sku,\\"taxful_total_price\\",\\"taxless_total_price\\",\\"total_quantity\\",\\"total_unique_products\\",type,user +3AMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,\\"Sultan Al\\",\\"Sultan Al\\",\\"Sultan Al Boone\\",\\"Sultan Al Boone\\",MALE,19,Boone,Boone,,Saturday,5,\\"sultan al@boone-family.zzz\\",\\"Abu Dhabi\\",Asia,AE,\\"{ + \\"\\"coordinates\\"\\": [ + 54.4, + 24.5 + ], + \\"\\"type\\"\\": \\"\\"Point\\"\\" +}\\",\\"Abu Dhabi\\",\\"Angeldale, Oceanavigations, Microlutions\\",\\"Angeldale, Oceanavigations, Microlutions\\",\\"Jul 12, 2019 @ 00:00:00.000\\",716724,\\"sold_product_716724_23975, sold_product_716724_6338, sold_product_716724_14116, sold_product_716724_15290\\",\\"sold_product_716724_23975, sold_product_716724_6338, sold_product_716724_14116, sold_product_716724_15290\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"0, 0, 0, 0\\",\\"0, 0, 0, 0\\",\\"Angeldale, Oceanavigations, Microlutions, Oceanavigations\\",\\"Angeldale, Oceanavigations, Microlutions, Oceanavigations\\",\\"42.39, 32.99, 10.34, 6.11\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"23,975, 6,338, 14,116, 15,290\\",\\"Winter boots - cognac, Trenchcoat - black, Watch - black, Hat - light grey multicolor\\",\\"Winter boots - cognac, Trenchcoat - black, Watch - black, Hat - light grey multicolor\\",\\"1, 1, 1, 1\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\",\\"0, 0, 0, 0\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"0, 0, 0, 0\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\",\\"173.96\\",\\"173.96\\",4,4,order,sultan " `; diff --git a/x-pack/test/functional/apps/discover/reporting.ts b/x-pack/test/functional/apps/discover/reporting.ts index dfc44a8e0e12d..d7dd961e2f103 100644 --- a/x-pack/test/functional/apps/discover/reporting.ts +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -12,18 +12,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const es = getService('es'); const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); const browser = getService('browser'); const PageObjects = getPageObjects(['reporting', 'common', 'discover', 'timePicker']); const filterBar = getService('filterBar'); - describe('Discover', () => { + const setFieldsFromSource = async (setValue: boolean) => { + await kibanaServer.uiSettings.update({ 'discover:searchFieldsFromSource': setValue }); + }; + + describe('Discover CSV Export', () => { before('initialize tests', async () => { log.debug('ReportingPage:initTests'); - await esArchiver.loadIfNeeded('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana'); await browser.setWindowSize(1600, 850); }); after('clean up archives', async () => { await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); await es.deleteByQuery({ index: '.reporting-*', refresh: true, @@ -31,7 +38,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('Generate CSV: new search', () => { + describe('Check Available', () => { beforeEach(() => PageObjects.common.navigateToApp('discover')); it('is not available if new', async () => { @@ -63,8 +70,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.reporting.openCsvReportingPanel(); expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); }); + }); - it('generates a report with data', async () => { + describe('Generate CSV: new search', () => { + beforeEach(async () => { + await esArchiver.load('reporting/ecommerce_kibana'); // reload the archive to wipe out changes made by each test + await PageObjects.common.navigateToApp('discover'); + }); + + it('generates a report from a new search with data: default', async () => { await PageObjects.discover.clickNewSearchButton(); await PageObjects.reporting.setTimepickerInDataRange(); await PageObjects.discover.saveSearch('my search - with data - expectReportCanBeCreated'); @@ -79,6 +93,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expectSnapshot(res.text).toMatch(); }); + it('generates a report from a new search with data: discover:searchFieldsFromSource', async () => { + await setFieldsFromSource(true); + await PageObjects.discover.clickNewSearchButton(); + await PageObjects.reporting.setTimepickerInDataRange(); + await PageObjects.discover.saveSearch( + 'my search - with fieldsFromSource data - expectReportCanBeCreated' + ); + await PageObjects.reporting.openCsvReportingPanel(); + await PageObjects.reporting.clickGenerateReportButton(); + + const url = await PageObjects.reporting.getReportURL(60000); + const res = await PageObjects.reporting.getResponse(url); + + expect(res.status).to.equal(200); + expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); + expectSnapshot(res.text).toMatch(); + await setFieldsFromSource(false); + }); + it('generates a report with no data', async () => { await PageObjects.reporting.setTimepickerInNoDataRange(); await PageObjects.discover.saveSearch('my search - no data - expectReportCanBeCreated'); @@ -98,6 +131,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('Generate CSV: archived search', () => { + const setupPage = async () => { + const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; + const toTime = 'Aug 23, 2019 @ 16:18:51.821'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + }; + + const getReport = async () => { + await PageObjects.reporting.openCsvReportingPanel(); + await PageObjects.reporting.clickGenerateReportButton(); + + const url = await PageObjects.reporting.getReportURL(60000); + const res = await PageObjects.reporting.getResponse(url); + + expect(res.status).to.equal(200); + expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); + return res; + }; + before(async () => { await esArchiver.load('reporting/ecommerce'); await esArchiver.load('reporting/ecommerce_kibana'); @@ -111,41 +162,36 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { beforeEach(() => PageObjects.common.navigateToApp('discover')); it('generates a report with data', async () => { + await setupPage(); await PageObjects.discover.loadSavedSearch('Ecommerce Data'); - const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; - const toTime = 'Aug 23, 2019 @ 16:18:51.821'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.reporting.openCsvReportingPanel(); - await PageObjects.reporting.clickGenerateReportButton(); - - const url = await PageObjects.reporting.getReportURL(60000); - const res = await PageObjects.reporting.getResponse(url); - - expect(res.status).to.equal(200); - expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); - expectSnapshot(res.text).toMatch(); + const { text } = await getReport(); + expectSnapshot(text).toMatch(); }); it('generates a report with filtered data', async () => { + await setupPage(); await PageObjects.discover.loadSavedSearch('Ecommerce Data'); - const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; - const toTime = 'Aug 23, 2019 @ 16:18:51.821'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); // filter and re-save await filterBar.addFilter('currency', 'is', 'EUR'); - await PageObjects.discover.saveSearch(`Ecommerce Data: EUR Filtered`); + await PageObjects.discover.saveSearch(`Ecommerce Data: EUR Filtered`); // renamed the search - await PageObjects.reporting.openCsvReportingPanel(); - await PageObjects.reporting.clickGenerateReportButton(); + const { text } = await getReport(); + expectSnapshot(text).toMatch(); + await PageObjects.discover.saveSearch(`Ecommerce Data`); // rename the search back for the next test + }); - const url = await PageObjects.reporting.getReportURL(60000); - const res = await PageObjects.reporting.getResponse(url); + it('generates a report with discover:searchFieldsFromSource = true', async () => { + await setupPage(); + await PageObjects.discover.loadSavedSearch('Ecommerce Data'); - expect(res.status).to.equal(200); - expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); - expectSnapshot(res.text).toMatch(); + await setFieldsFromSource(true); + await browser.refresh(); + + const { text } = await getReport(); + expectSnapshot(text).toMatch(); + await setFieldsFromSource(false); }); }); }); diff --git a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js index 5a21791b2c567..a49ab7d7dd980 100644 --- a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js +++ b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js @@ -10,6 +10,7 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const inspector = getService('inspector'); + const monacoEditor = getService('monacoEditor'); const testSubjects = getService('testSubjects'); const security = getService('security'); @@ -27,7 +28,7 @@ export default function ({ getPageObjects, getService }) { await inspector.open(); await inspector.openInspectorRequestsView(); await testSubjects.click('inspectorRequestDetailResponse'); - const responseBody = await inspector.getCodeEditorValue(); + const responseBody = await monacoEditor.getCodeEditorValue(); await inspector.close(); return JSON.parse(responseBody); } diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 524b0205914bd..0a00bb3cd757f 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -12,7 +12,8 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); const editedDescription = 'Edited description'; - describe('outlier detection creation', function () { + // FAILING: https://github.com/elastic/kibana/issues/94854 + describe.skip('outlier detection creation', function () { before(async () => { await esArchiver.loadIfNeeded('ml/ihp_outlier'); await ml.testResources.createIndexPatternIfNeeded('ft_ihp_outlier', '@timestamp'); @@ -58,8 +59,8 @@ export default function ({ getService }: FtrProviderContext) { { key: '#F5F7FA', value: 2 }, { key: '#D3DAE6', value: 1 }, // scatterplot circles - { key: '#54B399', value: 1 }, - { key: '#54B39A', value: 1 }, + { key: '#69707D', value: 1 }, + { key: '#98A1B3', value: 1 }, ], scatterplotMatrixColorStatsResults: [ // background diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce/data.json.gz b/x-pack/test/functional/es_archives/reporting/ecommerce/data.json.gz index 7736287bc9a37..e1710365c4b41 100644 Binary files a/x-pack/test/functional/es_archives/reporting/ecommerce/data.json.gz and b/x-pack/test/functional/es_archives/reporting/ecommerce/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce/mappings.json b/x-pack/test/functional/es_archives/reporting/ecommerce/mappings.json index 9e3275bd40bfe..6b474059a8e7a 100644 --- a/x-pack/test/functional/es_archives/reporting/ecommerce/mappings.json +++ b/x-pack/test/functional/es_archives/reporting/ecommerce/mappings.json @@ -209,1018 +209,3 @@ } } } - -{ - "type": "index", - "value": { - "aliases": { - ".kibana": { - } - }, - "index": ".kibana_1", - "mappings": { - "_meta": { - "migrationMappingPropertyHashes": { - "apm-telemetry": "07ee1939fa4302c62ddc052ec03fed90", - "canvas-element": "7390014e1091044523666d97247392fc", - "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", - "config": "87aca8fdb053154f11383fce3dbf3edf", - "dashboard": "d00f614b29a80360e1190193fd333bab", - "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", - "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", - "index-pattern": "66eccb05066c5a89924f48a9e9736499", - "infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c", - "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", - "map": "23d7aa4a720d4938ccde3983f87bd58d", - "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d", - "migrationVersion": "4a1746014a75ade3a714e1db5763276f", - "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", - "namespace": "2f4316de49999235636386fe51dc06c1", - "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", - "references": "7997cf5a56cc02bdc9c93361bde732b0", - "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", - "search": "181661168bbadd1eff5902361e2a0d5c", - "server": "ec97f1c5da1a19609a60874e5af1100c", - "siem-ui-timeline": "1f6f0860ad7bc0dba3e42467ca40470d", - "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", - "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", - "space": "25de8c2deec044392922989cfcf24c54", - "telemetry": "e1c8bc94e443aefd9458932cc0697a4d", - "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", - "type": "2f4316de49999235636386fe51dc06c1", - "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", - "updated_at": "00da57df13e94e9d98437d13ace4bfe0", - "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6", - "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", - "url": "c7f66a0df8b1b52f17c28c4adb111105", - "visualization": "52d7a13ad68a150c4525b292d23e12cc" - } - }, - "dynamic": "strict", - "properties": { - "apm-telemetry": { - "properties": { - "has_any_services": { - "type": "boolean" - }, - "services_per_agent": { - "properties": { - "dotnet": { - "null_value": 0, - "type": "long" - }, - "go": { - "null_value": 0, - "type": "long" - }, - "java": { - "null_value": 0, - "type": "long" - }, - "js-base": { - "null_value": 0, - "type": "long" - }, - "nodejs": { - "null_value": 0, - "type": "long" - }, - "python": { - "null_value": 0, - "type": "long" - }, - "ruby": { - "null_value": 0, - "type": "long" - }, - "rum-js": { - "null_value": 0, - "type": "long" - } - } - } - } - }, - "canvas-element": { - "dynamic": "false", - "properties": { - "@created": { - "type": "date" - }, - "@timestamp": { - "type": "date" - }, - "content": { - "type": "text" - }, - "help": { - "type": "text" - }, - "image": { - "type": "text" - }, - "name": { - "fields": { - "keyword": { - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "canvas-workpad": { - "dynamic": "false", - "properties": { - "@created": { - "type": "date" - }, - "@timestamp": { - "type": "date" - }, - "name": { - "fields": { - "keyword": { - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "config": { - "dynamic": "true", - "properties": { - "buildNum": { - "type": "keyword" - }, - "dateFormat:tz": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "defaultIndex": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "dashboard": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "optionsJSON": { - "type": "text" - }, - "panelsJSON": { - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "type": "keyword" - }, - "pause": { - "type": "boolean" - }, - "section": { - "type": "integer" - }, - "value": { - "type": "integer" - } - } - }, - "timeFrom": { - "type": "keyword" - }, - "timeRestore": { - "type": "boolean" - }, - "timeTo": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "file-upload-telemetry": { - "properties": { - "filesUploadedTotalCount": { - "type": "long" - } - } - }, - "graph-workspace": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "numLinks": { - "type": "integer" - }, - "numVertices": { - "type": "integer" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "wsState": { - "type": "text" - } - } - }, - "index-pattern": { - "properties": { - "fieldFormatMap": { - "type": "text" - }, - "fields": { - "type": "text" - }, - "intervalName": { - "type": "keyword" - }, - "notExpandable": { - "type": "boolean" - }, - "sourceFilters": { - "type": "text" - }, - "timeFieldName": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "type": { - "type": "keyword" - }, - "typeMeta": { - "type": "keyword" - } - } - }, - "infrastructure-ui-source": { - "properties": { - "description": { - "type": "text" - }, - "fields": { - "properties": { - "container": { - "type": "keyword" - }, - "host": { - "type": "keyword" - }, - "pod": { - "type": "keyword" - }, - "tiebreaker": { - "type": "keyword" - }, - "timestamp": { - "type": "keyword" - } - } - }, - "logAlias": { - "type": "keyword" - }, - "logColumns": { - "properties": { - "fieldColumn": { - "properties": { - "field": { - "type": "keyword" - }, - "id": { - "type": "keyword" - } - } - }, - "messageColumn": { - "properties": { - "id": { - "type": "keyword" - } - } - }, - "timestampColumn": { - "properties": { - "id": { - "type": "keyword" - } - } - } - }, - "type": "nested" - }, - "metricAlias": { - "type": "keyword" - }, - "name": { - "type": "text" - } - } - }, - "kql-telemetry": { - "properties": { - "optInCount": { - "type": "long" - }, - "optOutCount": { - "type": "long" - } - } - }, - "map": { - "properties": { - "bounds": { - "type": "geo_shape" - }, - "description": { - "type": "text" - }, - "layerListJSON": { - "type": "text" - }, - "mapStateJSON": { - "type": "text" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "maps-telemetry": { - "properties": { - "attributesPerMap": { - "properties": { - "dataSourcesCount": { - "properties": { - "avg": { - "type": "long" - }, - "max": { - "type": "long" - }, - "min": { - "type": "long" - } - } - }, - "emsVectorLayersCount": { - "dynamic": "true", - "type": "object" - }, - "layerTypesCount": { - "dynamic": "true", - "type": "object" - }, - "layersCount": { - "properties": { - "avg": { - "type": "long" - }, - "max": { - "type": "long" - }, - "min": { - "type": "long" - } - } - } - } - }, - "mapsTotalCount": { - "type": "long" - }, - "timeCaptured": { - "type": "date" - } - } - }, - "migrationVersion": { - "dynamic": "true", - "properties": { - "index-pattern": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "space": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "ml-telemetry": { - "properties": { - "file_data_visualizer": { - "properties": { - "index_creation_count": { - "type": "long" - } - } - } - } - }, - "namespace": { - "type": "keyword" - }, - "query": { - "properties": { - "description": { - "type": "text" - }, - "filters": { - "enabled": false, - "type": "object" - }, - "query": { - "properties": { - "language": { - "type": "keyword" - }, - "query": { - "index": false, - "type": "keyword" - } - } - }, - "timefilter": { - "enabled": false, - "type": "object" - }, - "title": { - "type": "text" - } - } - }, - "references": { - "properties": { - "id": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "type": { - "type": "keyword" - } - }, - "type": "nested" - }, - "sample-data-telemetry": { - "properties": { - "installCount": { - "type": "long" - }, - "unInstallCount": { - "type": "long" - } - } - }, - "search": { - "properties": { - "columns": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "sort": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "server": { - "properties": { - "uuid": { - "type": "keyword" - } - } - }, - "siem-ui-timeline": { - "properties": { - "columns": { - "properties": { - "aggregatable": { - "type": "boolean" - }, - "category": { - "type": "keyword" - }, - "columnHeaderType": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "example": { - "type": "text" - }, - "id": { - "type": "keyword" - }, - "indexes": { - "type": "keyword" - }, - "name": { - "type": "text" - }, - "placeholder": { - "type": "text" - }, - "searchable": { - "type": "boolean" - }, - "type": { - "type": "keyword" - } - } - }, - "created": { - "type": "date" - }, - "createdBy": { - "type": "text" - }, - "dataProviders": { - "properties": { - "and": { - "properties": { - "enabled": { - "type": "boolean" - }, - "excluded": { - "type": "boolean" - }, - "id": { - "type": "keyword" - }, - "kqlQuery": { - "type": "text" - }, - "name": { - "type": "text" - }, - "queryMatch": { - "properties": { - "displayField": { - "type": "text" - }, - "displayValue": { - "type": "text" - }, - "field": { - "type": "text" - }, - "operator": { - "type": "text" - }, - "value": { - "type": "text" - } - } - } - } - }, - "enabled": { - "type": "boolean" - }, - "excluded": { - "type": "boolean" - }, - "id": { - "type": "keyword" - }, - "kqlQuery": { - "type": "text" - }, - "name": { - "type": "text" - }, - "queryMatch": { - "properties": { - "displayField": { - "type": "text" - }, - "displayValue": { - "type": "text" - }, - "field": { - "type": "text" - }, - "operator": { - "type": "text" - }, - "value": { - "type": "text" - } - } - } - } - }, - "dateRange": { - "properties": { - "end": { - "type": "date" - }, - "start": { - "type": "date" - } - } - }, - "description": { - "type": "text" - }, - "favorite": { - "properties": { - "favoriteDate": { - "type": "date" - }, - "fullName": { - "type": "text" - }, - "keySearch": { - "type": "text" - }, - "userName": { - "type": "text" - } - } - }, - "kqlMode": { - "type": "keyword" - }, - "kqlQuery": { - "properties": { - "filterQuery": { - "properties": { - "kuery": { - "properties": { - "expression": { - "type": "text" - }, - "kind": { - "type": "keyword" - } - } - }, - "serializedQuery": { - "type": "text" - } - } - } - } - }, - "sort": { - "properties": { - "columnId": { - "type": "keyword" - }, - "sortDirection": { - "type": "keyword" - } - } - }, - "title": { - "type": "text" - }, - "updated": { - "type": "date" - }, - "updatedBy": { - "type": "text" - } - } - }, - "siem-ui-timeline-note": { - "properties": { - "created": { - "type": "date" - }, - "createdBy": { - "type": "text" - }, - "eventId": { - "type": "keyword" - }, - "note": { - "type": "text" - }, - "timelineId": { - "type": "keyword" - }, - "updated": { - "type": "date" - }, - "updatedBy": { - "type": "text" - } - } - }, - "siem-ui-timeline-pinned-event": { - "properties": { - "created": { - "type": "date" - }, - "createdBy": { - "type": "text" - }, - "eventId": { - "type": "keyword" - }, - "timelineId": { - "type": "keyword" - }, - "updated": { - "type": "date" - }, - "updatedBy": { - "type": "text" - } - } - }, - "space": { - "properties": { - "_reserved": { - "type": "boolean" - }, - "color": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "disabledFeatures": { - "type": "keyword" - }, - "initials": { - "type": "keyword" - }, - "name": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "telemetry": { - "properties": { - "enabled": { - "type": "boolean" - } - } - }, - "timelion-sheet": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "timelion_chart_height": { - "type": "integer" - }, - "timelion_columns": { - "type": "integer" - }, - "timelion_interval": { - "type": "keyword" - }, - "timelion_other_interval": { - "type": "keyword" - }, - "timelion_rows": { - "type": "integer" - }, - "timelion_sheet": { - "type": "text" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "type": { - "type": "keyword" - }, - "ui-metric": { - "properties": { - "count": { - "type": "integer" - } - } - }, - "updated_at": { - "type": "date" - }, - "upgrade-assistant-reindex-operation": { - "dynamic": "true", - "properties": { - "indexName": { - "type": "keyword" - }, - "status": { - "type": "integer" - } - } - }, - "upgrade-assistant-telemetry": { - "properties": { - "features": { - "properties": { - "deprecation_logging": { - "properties": { - "enabled": { - "null_value": true, - "type": "boolean" - } - } - } - } - }, - "ui_open": { - "properties": { - "cluster": { - "null_value": 0, - "type": "long" - }, - "indices": { - "null_value": 0, - "type": "long" - }, - "overview": { - "null_value": 0, - "type": "long" - } - } - }, - "ui_reindex": { - "properties": { - "close": { - "null_value": 0, - "type": "long" - }, - "open": { - "null_value": 0, - "type": "long" - }, - "start": { - "null_value": 0, - "type": "long" - }, - "stop": { - "null_value": 0, - "type": "long" - } - } - } - } - }, - "url": { - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "visualization": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchRefName": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } - } - } - }, - "settings": { - "index": { - "auto_expand_replicas": "0-1", - "number_of_replicas": "0", - "number_of_shards": "1" - } - } - } -} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json index a0f384a96e6b4..64d04ec6f49a9 100644 --- a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json +++ b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json @@ -1,31 +1,449 @@ -{ "type": "doc", "value": { "id": "config:7.0.0", "index": ".kibana_1", "source": { "config": { "buildNum": 9007199254740991, "dateFormat:tz": "UTC", "defaultIndex": "5193f870-d861-11e9-a311-0fa548c5f953" }, "migrationVersion": { "config": "7.13.0" }, "references": [ ], "type": "config", "updated_at": "2019-09-16T09:06:51.201Z" } } } +{ + "type": "doc", + "value": { + "id": "config:7.0.0", + "index": ".kibana_1", + "source": { + "config": { + "buildNum": 9007199254740991, + "dateFormat:tz": "UTC", + "defaultIndex": "5193f870-d861-11e9-a311-0fa548c5f953" + }, + "migrationVersion": { + "config": "7.13.0" + }, + "references": [], + "type": "config", + "updated_at": "2019-09-16T09:06:51.201Z" + } + } +} -{ "type": "doc", "value": { "id": "config:8.0.0", "index": ".kibana_1", "source": { "config": { "buildNum": 9007199254740991, "dateFormat:tz": "UTC", "defaultIndex": "5193f870-d861-11e9-a311-0fa548c5f953" }, "migrationVersion": { "config": "7.13.0" }, "references": [ ], "type": "config", "updated_at": "2019-12-11T23:22:12.698Z" } } } +{ + "type": "doc", + "value": { + "id": "config:8.0.0", + "index": ".kibana_1", + "source": { + "config": { + "buildNum": 9007199254740991, + "dateFormat:tz": "UTC", + "defaultIndex": "5193f870-d861-11e9-a311-0fa548c5f953" + }, + "migrationVersion": { + "config": "7.13.0" + }, + "references": [], + "type": "config", + "updated_at": "2019-12-11T23:22:12.698Z" + } + } +} -{ "type": "doc", "value": { "id": "index-pattern:5193f870-d861-11e9-a311-0fa548c5f953", "index": ".kibana_1", "source": { "index-pattern": { "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"category\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"category.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"category\"}}},{\"name\":\"currency\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"customer_birth_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"customer_first_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"customer_first_name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"customer_first_name\"}}},{\"name\":\"customer_full_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"customer_full_name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"customer_full_name\"}}},{\"name\":\"customer_gender\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"customer_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"customer_last_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"customer_last_name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"customer_last_name\"}}},{\"name\":\"customer_phone\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"day_of_week\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"day_of_week_i\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"email\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.city_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.continent_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.country_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.region_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"manufacturer\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"manufacturer.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"manufacturer\"}}},{\"name\":\"order_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"order_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products._id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"products._id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"products._id\"}}},{\"name\":\"products.base_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.base_unit_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.category\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"products.category.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"products.category\"}}},{\"name\":\"products.created_on\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.discount_amount\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.discount_percentage\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.manufacturer\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"products.manufacturer.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"products.manufacturer\"}}},{\"name\":\"products.min_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.product_id\",\"type\":\"number\",\"esTypes\":[\"long\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.product_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"products.product_name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"products.product_name\"}}},{\"name\":\"products.quantity\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.sku\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.tax_amount\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.taxful_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.taxless_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.unit_discount_amount\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sku\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"taxful_total_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"taxless_total_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"total_quantity\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"total_unique_products\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", "timeFieldName": "order_date", "title": "ecommerce" }, "migrationVersion": { "index-pattern": "7.11.0" }, "references": [ ], "type": "index-pattern", "updated_at": "2019-12-11T23:24:13.381Z" } } } +{ + "type": "doc", + "value": { + "id": "index-pattern:5193f870-d861-11e9-a311-0fa548c5f953", + "index": ".kibana_1", + "source": { + "index-pattern": { + "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"category\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"category.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"category\"}}},{\"name\":\"currency\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"customer_birth_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"customer_first_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"customer_first_name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"customer_first_name\"}}},{\"name\":\"customer_full_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"customer_full_name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"customer_full_name\"}}},{\"name\":\"customer_gender\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"customer_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"customer_last_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"customer_last_name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"customer_last_name\"}}},{\"name\":\"customer_phone\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"day_of_week\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"day_of_week_i\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"email\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.city_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.continent_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.country_iso_code\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.location\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geoip.region_name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"manufacturer\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"manufacturer.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"manufacturer\"}}},{\"name\":\"order_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"order_id\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products._id\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"products._id.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"products._id\"}}},{\"name\":\"products.base_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.base_unit_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.category\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"products.category.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"products.category\"}}},{\"name\":\"products.created_on\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.discount_amount\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.discount_percentage\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.manufacturer\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"products.manufacturer.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"products.manufacturer\"}}},{\"name\":\"products.min_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.product_id\",\"type\":\"number\",\"esTypes\":[\"long\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.product_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"products.product_name.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"products.product_name\"}}},{\"name\":\"products.quantity\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.sku\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.tax_amount\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.taxful_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.taxless_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"products.unit_discount_amount\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sku\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"taxful_total_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"taxless_total_price\",\"type\":\"number\",\"esTypes\":[\"half_float\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"total_quantity\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"total_unique_products\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"user\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "timeFieldName": "order_date", + "title": "ecommerce" + }, + "migrationVersion": { + "index-pattern": "7.11.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2019-12-11T23:24:13.381Z" + } + } +} -{ "type": "doc", "value": { "id": "search:6091ead0-1c6d-11ea-a100-8589bb9d7c6b", "index": ".kibana_1", "source": { "migrationVersion": { "search": "7.9.3" }, "references": [ { "id": "5193f870-d861-11e9-a311-0fa548c5f953", "name": "kibanaSavedObjectMeta.searchSourceJSON.index", "type": "index-pattern" } ], "search": { "columns": [ "category", "currency", "customer_id", "order_id", "day_of_week_i", "order_date", "products.created_on", "sku" ], "description": "", "hits": 0, "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" }, "sort": [ [ "order_date", "desc" ] ], "title": "Ecommerce Data", "version": 1 }, "type": "search", "updated_at": "2019-12-11T23:24:28.540Z" } } } +{ + "type": "doc", + "value": { + "id": "search:6091ead0-1c6d-11ea-a100-8589bb9d7c6b", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "search": "7.9.3" + }, + "references": [ + { + "id": "5193f870-d861-11e9-a311-0fa548c5f953", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "search": { + "columns": [ + "order_date", + "category", + "currency", + "customer_id", + "order_id", + "day_of_week_i", + "products.created_on", + "sku" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"version\":true,\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "order_date", + "desc" + ] + ], + "title": "Ecommerce Data", + "version": 1 + }, + "type": "search", + "updated_at": "2019-12-11T23:24:28.540Z" + } + } +} -{ "type": "doc", "value": { "id": "dashboard:constructed-sample-saved-object-id", "index": ".kibana_1", "source": { "dashboard": { "description": "", "hits": 0, "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" }, "optionsJSON": "{\"hidePanelTitles\":true,\"useMargins\":true}", "panelsJSON": "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1c12c2f2-80c2-4d5c-b722-55b2415006e1\"},\"panelIndex\":\"1c12c2f2-80c2-4d5c-b722-55b2415006e1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_0\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1c4b99e1-7785-444f-a1c5-f592893b1a96\"},\"panelIndex\":\"1c4b99e1-7785-444f-a1c5-f592893b1a96\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":15,\"w\":48,\"h\":18,\"i\":\"94eab06f-60ac-4a85-b771-3a8ed475c9bb\"},\"panelIndex\":\"94eab06f-60ac-4a85-b771-3a8ed475c9bb\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":33,\"w\":48,\"h\":8,\"i\":\"52c19b6b-7117-42ac-a74e-c507a1c3ffc0\"},\"panelIndex\":\"52c19b6b-7117-42ac-a74e-c507a1c3ffc0\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":41,\"w\":11,\"h\":10,\"i\":\"a1e889dc-b80e-4937-a576-979f34d1859b\"},\"panelIndex\":\"a1e889dc-b80e-4937-a576-979f34d1859b\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":11,\"y\":41,\"w\":5,\"h\":10,\"i\":\"4930b035-d756-4cc5-9a18-1af9e67d6f31\"},\"panelIndex\":\"4930b035-d756-4cc5-9a18-1af9e67d6f31\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"}]", "refreshInterval": { "pause": true, "value": 0 }, "timeFrom": "2019-06-26T06:20:28.066Z", "timeRestore": true, "timeTo": "2019-06-26T07:27:58.573Z", "title": "Ecom Dashboard Hidden Panel Titles", "version": 1 }, "migrationVersion": { "dashboard": "7.11.0" }, "references": [ { "id": "0a464230-79f0-11ea-ae7f-13c5d6e410a0", "name": "panel_0", "type": "visualization" }, { "id": "200609c0-79f0-11ea-ae7f-13c5d6e410a0", "name": "panel_1", "type": "visualization" }, { "id": "6091ead0-1c6d-11ea-a100-8589bb9d7c6b", "name": "panel_2", "type": "search" }, { "id": "4a36acd0-7ac3-11ea-b69c-cf0d7935cd67", "name": "panel_3", "type": "visualization" }, { "id": "ef8757d0-7ac2-11ea-b69c-cf0d7935cd67", "name": "panel_4", "type": "visualization" }, { "id": "132ab9c0-7ac3-11ea-b69c-cf0d7935cd67", "name": "panel_5", "type": "visualization" } ], "type": "dashboard", "updated_at": "2020-04-10T00:37:48.462Z" } } } +{ + "type": "doc", + "value": { + "id": "dashboard:constructed-sample-saved-object-id", + "index": ".kibana_1", + "source": { + "dashboard": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" + }, + "optionsJSON": "{\"hidePanelTitles\":true,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1c12c2f2-80c2-4d5c-b722-55b2415006e1\"},\"panelIndex\":\"1c12c2f2-80c2-4d5c-b722-55b2415006e1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_0\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1c4b99e1-7785-444f-a1c5-f592893b1a96\"},\"panelIndex\":\"1c4b99e1-7785-444f-a1c5-f592893b1a96\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":15,\"w\":48,\"h\":18,\"i\":\"94eab06f-60ac-4a85-b771-3a8ed475c9bb\"},\"panelIndex\":\"94eab06f-60ac-4a85-b771-3a8ed475c9bb\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":33,\"w\":48,\"h\":8,\"i\":\"52c19b6b-7117-42ac-a74e-c507a1c3ffc0\"},\"panelIndex\":\"52c19b6b-7117-42ac-a74e-c507a1c3ffc0\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":41,\"w\":11,\"h\":10,\"i\":\"a1e889dc-b80e-4937-a576-979f34d1859b\"},\"panelIndex\":\"a1e889dc-b80e-4937-a576-979f34d1859b\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":11,\"y\":41,\"w\":5,\"h\":10,\"i\":\"4930b035-d756-4cc5-9a18-1af9e67d6f31\"},\"panelIndex\":\"4930b035-d756-4cc5-9a18-1af9e67d6f31\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"}]", + "refreshInterval": { + "pause": true, + "value": 0 + }, + "timeFrom": "2019-06-26T06:20:28.066Z", + "timeRestore": true, + "timeTo": "2019-06-26T07:27:58.573Z", + "title": "Ecom Dashboard Hidden Panel Titles", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.11.0" + }, + "references": [ + { + "id": "0a464230-79f0-11ea-ae7f-13c5d6e410a0", + "name": "panel_0", + "type": "visualization" + }, + { + "id": "200609c0-79f0-11ea-ae7f-13c5d6e410a0", + "name": "panel_1", + "type": "visualization" + }, + { + "id": "6091ead0-1c6d-11ea-a100-8589bb9d7c6b", + "name": "panel_2", + "type": "search" + }, + { + "id": "4a36acd0-7ac3-11ea-b69c-cf0d7935cd67", + "name": "panel_3", + "type": "visualization" + }, + { + "id": "ef8757d0-7ac2-11ea-b69c-cf0d7935cd67", + "name": "panel_4", + "type": "visualization" + }, + { + "id": "132ab9c0-7ac3-11ea-b69c-cf0d7935cd67", + "name": "panel_5", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2020-04-10T00:37:48.462Z" + } + } +} -{ "type": "doc", "value": { "id": "visualization:0a464230-79f0-11ea-ae7f-13c5d6e410a0", "index": ".kibana_1", "source": { "migrationVersion": { "visualization": "7.12.0" }, "references": [ { "id": "5193f870-d861-11e9-a311-0fa548c5f953", "name": "kibanaSavedObjectMeta.searchSourceJSON.index", "type": "index-pattern" } ], "type": "visualization", "updated_at": "2020-04-08T23:24:05.971Z", "visualization": { "description": "", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" }, "title": "e-commerce area chart", "uiStateJSON": "{}", "version": 1, "visState": "{\"type\":\"area\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"order_date\",\"timeRange\":{\"from\":\"2019-06-26T06:20:28.066Z\",\"to\":\"2019-06-26T07:27:58.573Z\"},\"useNormalizedEsInterval\":true,\"scaleMetricValues\":false,\"interval\":\"auto\",\"drop_partials\":false,\"min_doc_count\":1,\"extended_bounds\":{}}}],\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"filter\":true,\"truncate\":100},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":true,\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"lineWidth\":2,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"thresholdLine\":{\"show\":false,\"value\":10,\"width\":1,\"style\":\"full\",\"color\":\"#E7664C\"},\"labels\":{},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"title\":\"e-commerce area chart\"}" } } } } +{ + "type": "doc", + "value": { + "id": "visualization:0a464230-79f0-11ea-ae7f-13c5d6e410a0", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "visualization": "7.12.0" + }, + "references": [ + { + "id": "5193f870-d861-11e9-a311-0fa548c5f953", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2020-04-08T23:24:05.971Z", + "visualization": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "e-commerce area chart", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"type\":\"area\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"order_date\",\"timeRange\":{\"from\":\"2019-06-26T06:20:28.066Z\",\"to\":\"2019-06-26T07:27:58.573Z\"},\"useNormalizedEsInterval\":true,\"scaleMetricValues\":false,\"interval\":\"auto\",\"drop_partials\":false,\"min_doc_count\":1,\"extended_bounds\":{}}}],\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"filter\":true,\"truncate\":100},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":true,\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"lineWidth\":2,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"thresholdLine\":{\"show\":false,\"value\":10,\"width\":1,\"style\":\"full\",\"color\":\"#E7664C\"},\"labels\":{},\"isVislibVis\":true,\"detailedTooltip\":true,\"fittingFunction\":\"zero\"},\"title\":\"e-commerce area chart\"}" + } + } + } +} -{ "type": "doc", "value": { "id": "visualization:200609c0-79f0-11ea-ae7f-13c5d6e410a0", "index": ".kibana_1", "source": { "migrationVersion": { "visualization": "7.12.0" }, "references": [ { "id": "5193f870-d861-11e9-a311-0fa548c5f953", "name": "kibanaSavedObjectMeta.searchSourceJSON.index", "type": "index-pattern" } ], "type": "visualization", "updated_at": "2020-04-08T23:24:42.460Z", "visualization": { "description": "", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" }, "title": "e-commerce pie chart", "uiStateJSON": "{}", "version": 1, "visState": "{\"type\":\"pie\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"order_date\",\"timeRange\":{\"from\":\"2019-06-26T06:20:28.066Z\",\"to\":\"2019-06-26T07:27:58.573Z\"},\"useNormalizedEsInterval\":true,\"scaleMetricValues\":false,\"interval\":\"auto\",\"drop_partials\":false,\"min_doc_count\":1,\"extended_bounds\":{}}}],\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100}},\"title\":\"e-commerce pie chart\"}" } } } } +{ + "type": "doc", + "value": { + "id": "visualization:200609c0-79f0-11ea-ae7f-13c5d6e410a0", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "visualization": "7.12.0" + }, + "references": [ + { + "id": "5193f870-d861-11e9-a311-0fa548c5f953", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2020-04-08T23:24:42.460Z", + "visualization": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "title": "e-commerce pie chart", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"type\":\"pie\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"order_date\",\"timeRange\":{\"from\":\"2019-06-26T06:20:28.066Z\",\"to\":\"2019-06-26T07:27:58.573Z\"},\"useNormalizedEsInterval\":true,\"scaleMetricValues\":false,\"interval\":\"auto\",\"drop_partials\":false,\"min_doc_count\":1,\"extended_bounds\":{}}}],\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100}},\"title\":\"e-commerce pie chart\"}" + } + } + } +} -{ "type": "doc", "value": { "id": "visualization:ef8757d0-7ac2-11ea-b69c-cf0d7935cd67", "index": ".kibana_1", "source": { "migrationVersion": { "visualization": "7.12.0" }, "references": [ { "id": "5193f870-d861-11e9-a311-0fa548c5f953", "name": "kibanaSavedObjectMeta.searchSourceJSON.index", "type": "index-pattern" } ], "type": "visualization", "updated_at": "2020-04-10T00:33:44.909Z", "visualization": { "description": "", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}" }, "title": "게이지", "uiStateJSON": "{}", "version": 1, "visState": "{\"type\":\"gauge\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}],\"params\":{\"type\":\"gauge\",\"addTooltip\":true,\"addLegend\":true,\"isDisplayWarning\":false,\"gauge\":{\"alignment\":\"automatic\",\"extendRange\":true,\"percentageMode\":false,\"gaugeType\":\"Arc\",\"gaugeStyle\":\"Full\",\"backStyle\":\"Full\",\"orientation\":\"vertical\",\"colorSchema\":\"Green to Red\",\"gaugeColorMode\":\"Labels\",\"colorsRange\":[{\"from\":0,\"to\":50},{\"from\":50,\"to\":75},{\"from\":75,\"to\":100}],\"invertColors\":false,\"labels\":{\"show\":true,\"color\":\"black\"},\"scale\":{\"show\":true,\"labels\":false,\"color\":\"rgba(105,112,125,0.2)\"},\"type\":\"meter\",\"style\":{\"bgWidth\":0.9,\"width\":0.9,\"mask\":false,\"bgMask\":false,\"maskBars\":50,\"bgFill\":\"rgba(105,112,125,0.2)\",\"bgColor\":true,\"subText\":\"\",\"fontSize\":60}}},\"title\":\"게이지\"}" } } } } +{ + "type": "doc", + "value": { + "id": "visualization:ef8757d0-7ac2-11ea-b69c-cf0d7935cd67", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "visualization": "7.12.0" + }, + "references": [ + { + "id": "5193f870-d861-11e9-a311-0fa548c5f953", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2020-04-10T00:33:44.909Z", + "visualization": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}" + }, + "title": "게이지", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"type\":\"gauge\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}],\"params\":{\"type\":\"gauge\",\"addTooltip\":true,\"addLegend\":true,\"isDisplayWarning\":false,\"gauge\":{\"alignment\":\"automatic\",\"extendRange\":true,\"percentageMode\":false,\"gaugeType\":\"Arc\",\"gaugeStyle\":\"Full\",\"backStyle\":\"Full\",\"orientation\":\"vertical\",\"colorSchema\":\"Green to Red\",\"gaugeColorMode\":\"Labels\",\"colorsRange\":[{\"from\":0,\"to\":50},{\"from\":50,\"to\":75},{\"from\":75,\"to\":100}],\"invertColors\":false,\"labels\":{\"show\":true,\"color\":\"black\"},\"scale\":{\"show\":true,\"labels\":false,\"color\":\"rgba(105,112,125,0.2)\"},\"type\":\"meter\",\"style\":{\"bgWidth\":0.9,\"width\":0.9,\"mask\":false,\"bgMask\":false,\"maskBars\":50,\"bgFill\":\"rgba(105,112,125,0.2)\",\"bgColor\":true,\"subText\":\"\",\"fontSize\":60}}},\"title\":\"게이지\"}" + } + } + } +} -{ "type": "doc", "value": { "id": "visualization:132ab9c0-7ac3-11ea-b69c-cf0d7935cd67", "index": ".kibana_1", "source": { "migrationVersion": { "visualization": "7.12.0" }, "references": [ { "id": "5193f870-d861-11e9-a311-0fa548c5f953", "name": "kibanaSavedObjectMeta.searchSourceJSON.index", "type": "index-pattern" } ], "type": "visualization", "updated_at": "2020-04-10T00:34:44.700Z", "visualization": { "description": "", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}" }, "title": "Українська", "uiStateJSON": "{}", "version": 1, "visState": "{\"type\":\"metric\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}],\"params\":{\"addTooltip\":true,\"addLegend\":false,\"type\":\"metric\",\"metric\":{\"percentageMode\":false,\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"metricColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"labels\":{\"show\":true},\"invertColors\":false,\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"title\":\"Українська\"}" } } } } +{ + "type": "doc", + "value": { + "id": "visualization:132ab9c0-7ac3-11ea-b69c-cf0d7935cd67", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "visualization": "7.12.0" + }, + "references": [ + { + "id": "5193f870-d861-11e9-a311-0fa548c5f953", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "visualization", + "updated_at": "2020-04-10T00:34:44.700Z", + "visualization": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"filter\":[]}" + }, + "title": "Українська", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"type\":\"metric\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}],\"params\":{\"addTooltip\":true,\"addLegend\":false,\"type\":\"metric\",\"metric\":{\"percentageMode\":false,\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"metricColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"labels\":{\"show\":true},\"invertColors\":false,\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"title\":\"Українська\"}" + } + } + } +} -{ "type": "doc", "value": { "id": "visualization:4a36acd0-7ac3-11ea-b69c-cf0d7935cd67", "index": ".kibana_1", "source": { "migrationVersion": { "visualization": "7.12.0" }, "references": [ ], "type": "visualization", "updated_at": "2020-04-10T00:36:17.053Z", "visualization": { "description": "", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" }, "title": "Tiểu thuyết", "uiStateJSON": "{}", "version": 1, "visState": "{\"type\":\"markdown\",\"aggs\":[],\"params\":{\"fontSize\":12,\"openLinksInNewTab\":false,\"markdown\":\"Tiểu thuyết là một thể loại văn xuôi có hư cấu, thông qua nhân vật, hoàn cảnh, sự việc để phản ánh bức tranh xã hội rộng lớn và những vấn đề của cuộc sống con người, biểu hiện tính chất tường thuật, tính chất kể chuyện bằng ngôn ngữ văn xuôi theo những chủ đề xác định.\\n\\nTrong một cách hiểu khác, nhận định của Belinski: \\\"tiểu thuyết là sử thi của đời tư\\\" chỉ ra khái quát nhất về một dạng thức tự sự, trong đó sự trần thuật tập trung vào số phận của một cá nhân trong quá trình hình thành và phát triển của nó. Sự trần thuật ở đây được khai triển trong không gian và thời gian nghệ thuật đến mức đủ để truyền đạt cơ cấu của nhân cách[1].\\n\\n\\n[1]^ Mục từ Tiểu thuyết trong cuốn 150 thuật ngữ văn học, Lại Nguyên Ân biên soạn, Nhà xuất bản Đại học Quốc gia Hà Nội, in lần thứ 2 có sửa đổi bổ sung. H. 2003. Trang 326.\"},\"title\":\"Tiểu thuyết\"}" } } } } +{ + "type": "doc", + "value": { + "id": "visualization:4a36acd0-7ac3-11ea-b69c-cf0d7935cd67", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "visualization": "7.12.0" + }, + "references": [], + "type": "visualization", + "updated_at": "2020-04-10T00:36:17.053Z", + "visualization": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "title": "Tiểu thuyết", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"type\":\"markdown\",\"aggs\":[],\"params\":{\"fontSize\":12,\"openLinksInNewTab\":false,\"markdown\":\"Tiểu thuyết là một thể loại văn xuôi có hư cấu, thông qua nhân vật, hoàn cảnh, sự việc để phản ánh bức tranh xã hội rộng lớn và những vấn đề của cuộc sống con người, biểu hiện tính chất tường thuật, tính chất kể chuyện bằng ngôn ngữ văn xuôi theo những chủ đề xác định.\\n\\nTrong một cách hiểu khác, nhận định của Belinski: \\\"tiểu thuyết là sử thi của đời tư\\\" chỉ ra khái quát nhất về một dạng thức tự sự, trong đó sự trần thuật tập trung vào số phận của một cá nhân trong quá trình hình thành và phát triển của nó. Sự trần thuật ở đây được khai triển trong không gian và thời gian nghệ thuật đến mức đủ để truyền đạt cơ cấu của nhân cách[1].\\n\\n\\n[1]^ Mục từ Tiểu thuyết trong cuốn 150 thuật ngữ văn học, Lại Nguyên Ân biên soạn, Nhà xuất bản Đại học Quốc gia Hà Nội, in lần thứ 2 có sửa đổi bổ sung. H. 2003. Trang 326.\"},\"title\":\"Tiểu thuyết\"}" + } + } + } +} -{ "type": "doc", "value": { "id": "space:default", "index": ".kibana_1", "source": { "space": { "_reserved": true, "description": "This is the default space", "disabledFeatures": [ ], "name": "Default Space" }, "type": "space", "updated_at": "2021-01-07T00:17:12.785Z" } } } +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana_1", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "disabledFeatures": [], + "name": "Default Space" + }, + "type": "space", + "updated_at": "2021-01-07T00:17:12.785Z" + } + } +} -{ "type": "doc", "value": { "id": "ui-counter:visualize:06012021:click:tagcloud", "index": ".kibana_1", "source": { "type": "ui-counter", "ui-counter": { "count": 1 }, "updated_at": "2021-01-07T00:18:52.592Z" } } } +{ + "type": "doc", + "value": { + "id": "dashboard:6c263e00-1c6d-11ea-a100-8589bb9d7c6b", + "index": ".kibana_1", + "source": { + "dashboard": { + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" + }, + "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", + "panelsJSON": "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1c12c2f2-80c2-4d5c-b722-55b2415006e1\"},\"panelIndex\":\"1c12c2f2-80c2-4d5c-b722-55b2415006e1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_0\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1c4b99e1-7785-444f-a1c5-f592893b1a96\"},\"panelIndex\":\"1c4b99e1-7785-444f-a1c5-f592893b1a96\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":35,\"w\":48,\"h\":18,\"i\":\"94eab06f-60ac-4a85-b771-3a8ed475c9bb\"},\"panelIndex\":\"94eab06f-60ac-4a85-b771-3a8ed475c9bb\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":15,\"w\":48,\"h\":8,\"i\":\"52c19b6b-7117-42ac-a74e-c507a1c3ffc0\"},\"panelIndex\":\"52c19b6b-7117-42ac-a74e-c507a1c3ffc0\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":23,\"w\":16,\"h\":12,\"i\":\"a1e889dc-b80e-4937-a576-979f34d1859b\"},\"panelIndex\":\"a1e889dc-b80e-4937-a576-979f34d1859b\",\"embeddableConfig\":{\"enhancements\":{},\"vis\":null},\"panelRefName\":\"panel_4\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":16,\"y\":23,\"w\":12,\"h\":12,\"i\":\"4930b035-d756-4cc5-9a18-1af9e67d6f31\"},\"panelIndex\":\"4930b035-d756-4cc5-9a18-1af9e67d6f31\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":28,\"y\":23,\"w\":20,\"h\":12,\"i\":\"55112375-d6f0-44f7-a8fb-867c8f7d464d\"},\"panelIndex\":\"55112375-d6f0-44f7-a8fb-867c8f7d464d\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6\"}]", + "refreshInterval": { + "pause": true, + "value": 0 + }, + "timeFrom": "2019-03-23T03:06:17.785Z", + "timeRestore": true, + "timeTo": "2019-10-04T02:33:16.708Z", + "title": "Ecom Dashboard", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.11.0" + }, + "references": [ + { + "id": "0a464230-79f0-11ea-ae7f-13c5d6e410a0", + "name": "panel_0", + "type": "visualization" + }, + { + "id": "200609c0-79f0-11ea-ae7f-13c5d6e410a0", + "name": "panel_1", + "type": "visualization" + }, + { + "id": "6091ead0-1c6d-11ea-a100-8589bb9d7c6b", + "name": "panel_2", + "type": "search" + }, + { + "id": "4a36acd0-7ac3-11ea-b69c-cf0d7935cd67", + "name": "panel_3", + "type": "visualization" + }, + { + "id": "ef8757d0-7ac2-11ea-b69c-cf0d7935cd67", + "name": "panel_4", + "type": "visualization" + }, + { + "id": "132ab9c0-7ac3-11ea-b69c-cf0d7935cd67", + "name": "panel_5", + "type": "visualization" + }, + { + "id": "1bba55f0-507e-11eb-9c0d-97106882b997", + "name": "panel_6", + "type": "visualization" + } + ], + "type": "dashboard", + "updated_at": "2021-01-07T00:22:16.102Z" + } + } +} -{ "type": "doc", "value": { "id": "ui-counter:data_plugin:06012021:click:discover:query_submitted", "index": ".kibana_1", "source": { "type": "ui-counter", "ui-counter": { "count": 1 }, "updated_at": "2021-01-07T00:18:52.592Z" } } } - -{ "type": "doc", "value": { "id": "dashboard:6c263e00-1c6d-11ea-a100-8589bb9d7c6b", "index": ".kibana_1", "source": { "dashboard": { "description": "", "hits": 0, "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" }, "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", "panelsJSON": "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1c12c2f2-80c2-4d5c-b722-55b2415006e1\"},\"panelIndex\":\"1c12c2f2-80c2-4d5c-b722-55b2415006e1\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_0\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1c4b99e1-7785-444f-a1c5-f592893b1a96\"},\"panelIndex\":\"1c4b99e1-7785-444f-a1c5-f592893b1a96\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_1\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":35,\"w\":48,\"h\":18,\"i\":\"94eab06f-60ac-4a85-b771-3a8ed475c9bb\"},\"panelIndex\":\"94eab06f-60ac-4a85-b771-3a8ed475c9bb\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_2\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":15,\"w\":48,\"h\":8,\"i\":\"52c19b6b-7117-42ac-a74e-c507a1c3ffc0\"},\"panelIndex\":\"52c19b6b-7117-42ac-a74e-c507a1c3ffc0\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":23,\"w\":16,\"h\":12,\"i\":\"a1e889dc-b80e-4937-a576-979f34d1859b\"},\"panelIndex\":\"a1e889dc-b80e-4937-a576-979f34d1859b\",\"embeddableConfig\":{\"enhancements\":{},\"vis\":null},\"panelRefName\":\"panel_4\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":16,\"y\":23,\"w\":12,\"h\":12,\"i\":\"4930b035-d756-4cc5-9a18-1af9e67d6f31\"},\"panelIndex\":\"4930b035-d756-4cc5-9a18-1af9e67d6f31\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_5\"},{\"version\":\"8.0.0\",\"gridData\":{\"x\":28,\"y\":23,\"w\":20,\"h\":12,\"i\":\"55112375-d6f0-44f7-a8fb-867c8f7d464d\"},\"panelIndex\":\"55112375-d6f0-44f7-a8fb-867c8f7d464d\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_6\"}]", "refreshInterval": { "pause": true, "value": 0 }, "timeFrom": "2019-03-23T03:06:17.785Z", "timeRestore": true, "timeTo": "2019-10-04T02:33:16.708Z", "title": "Ecom Dashboard", "version": 1 }, "migrationVersion": { "dashboard": "7.11.0" }, "references": [ { "id": "0a464230-79f0-11ea-ae7f-13c5d6e410a0", "name": "panel_0", "type": "visualization" }, { "id": "200609c0-79f0-11ea-ae7f-13c5d6e410a0", "name": "panel_1", "type": "visualization" }, { "id": "6091ead0-1c6d-11ea-a100-8589bb9d7c6b", "name": "panel_2", "type": "search" }, { "id": "4a36acd0-7ac3-11ea-b69c-cf0d7935cd67", "name": "panel_3", "type": "visualization" }, { "id": "ef8757d0-7ac2-11ea-b69c-cf0d7935cd67", "name": "panel_4", "type": "visualization" }, { "id": "132ab9c0-7ac3-11ea-b69c-cf0d7935cd67", "name": "panel_5", "type": "visualization" }, { "id": "1bba55f0-507e-11eb-9c0d-97106882b997", "name": "panel_6", "type": "visualization" } ], "type": "dashboard", "updated_at": "2021-01-07T00:22:16.102Z" } } } - -{ "type": "doc", "value": { "id": "visualization:1bba55f0-507e-11eb-9c0d-97106882b997", "index": ".kibana_1", "source": { "migrationVersion": { "visualization": "7.12.0" }, "references": [ { "id": "6091ead0-1c6d-11ea-a100-8589bb9d7c6b", "name": "search_0", "type": "search" } ], "type": "visualization", "updated_at": "2021-01-07T00:23:04.624Z", "visualization": { "description": "", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" }, "savedSearchRefName": "search_0", "title": "Tag Cloud of Names", "uiStateJSON": "{}", "version": 1, "visState": "{\"title\":\"Tag Cloud of Names\",\"type\":\"tagcloud\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"customer_first_name.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":10,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"},\"schema\":\"segment\"}],\"params\":{\"scale\":\"linear\",\"orientation\":\"single\",\"minFontSize\":18,\"maxFontSize\":72,\"showLabel\":true}}" } } } } - -{ "type": "doc", "value": { "id": "ui-counter:DashboardPanelVersionInUrl:06012021:loaded:8.0.0", "index": ".kibana_1", "source": { "type": "ui-counter", "ui-counter": { "count": 85 }, "updated_at": "2021-01-07T00:23:25.741Z" } } } +{ + "type": "doc", + "value": { + "id": "visualization:1bba55f0-507e-11eb-9c0d-97106882b997", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "visualization": "7.12.0" + }, + "references": [ + { + "id": "6091ead0-1c6d-11ea-a100-8589bb9d7c6b", + "name": "search_0", + "type": "search" + } + ], + "type": "visualization", + "updated_at": "2021-01-07T00:23:04.624Z", + "visualization": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "savedSearchRefName": "search_0", + "title": "Tag Cloud of Names", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Tag Cloud of Names\",\"type\":\"tagcloud\",\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"params\":{},\"schema\":\"metric\"},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"params\":{\"field\":\"customer_first_name.keyword\",\"orderBy\":\"1\",\"order\":\"desc\",\"size\":10,\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"},\"schema\":\"segment\"}],\"params\":{\"scale\":\"linear\",\"orientation\":\"single\",\"minFontSize\":18,\"maxFontSize\":72,\"showLabel\":true}}" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/data.json b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/data.json index 98bbbde8b83b7..06543e44de56c 100644 --- a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/data.json +++ b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/data.json @@ -81,7 +81,6 @@ "migrationVersion": { "config": "7.13.0" }, - "namespace": "default", "references": [ ], "type": "config", diff --git a/x-pack/test/functional/es_archives/reporting/hugedata/data.json.gz b/x-pack/test/functional/es_archives/reporting/hugedata/data.json.gz index 83da642be8762..e5fb8a73234e4 100644 Binary files a/x-pack/test/functional/es_archives/reporting/hugedata/data.json.gz and b/x-pack/test/functional/es_archives/reporting/hugedata/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/reporting/hugedata/mappings.json b/x-pack/test/functional/es_archives/reporting/hugedata/mappings.json index d36bbc72f4ffa..8580f216a06f6 100644 --- a/x-pack/test/functional/es_archives/reporting/hugedata/mappings.json +++ b/x-pack/test/functional/es_archives/reporting/hugedata/mappings.json @@ -7,86 +7,16 @@ }, "index": ".kibana_1", "mappings": { - "_meta": { - "migrationMappingPropertyHashes": { - "apm-telemetry": "0383a570af33654a51c8a1352417bc6b", - "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", - "config": "87aca8fdb053154f11383fce3dbf3edf", - "dashboard": "eb3789e1af878e73f85304333240f65f", - "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", - "index-pattern": "66eccb05066c5a89924f48a9e9736499", - "infrastructure-ui-source": "10acdf67d9a06d462e198282fd6d4b81", - "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", - "map": "23d7aa4a720d4938ccde3983f87bd58d", - "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d", - "migrationVersion": "4a1746014a75ade3a714e1db5763276f", - "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", - "namespace": "2f4316de49999235636386fe51dc06c1", - "references": "7997cf5a56cc02bdc9c93361bde732b0", - "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", - "search": "181661168bbadd1eff5902361e2a0d5c", - "server": "ec97f1c5da1a19609a60874e5af1100c", - "space": "0d5011d73a0ef2f0f615bb42f26f187e", - "telemetry": "e1c8bc94e443aefd9458932cc0697a4d", - "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", - "type": "2f4316de49999235636386fe51dc06c1", - "updated_at": "00da57df13e94e9d98437d13ace4bfe0", - "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6", - "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", - "url": "c7f66a0df8b1b52f17c28c4adb111105", - "user-action": "0d409297dc5ebe1e3a1da691c6ee32e3", - "visualization": "52d7a13ad68a150c4525b292d23e12cc" - } - }, "dynamic": "strict", "properties": { - "apm-telemetry": { - "properties": { - "has_any_services": { - "type": "boolean" - }, - "services_per_agent": { - "properties": { - "go": { - "null_value": 0, - "type": "long" - }, - "java": { - "null_value": 0, - "type": "long" - }, - "js-base": { - "null_value": 0, - "type": "long" - }, - "nodejs": { - "null_value": 0, - "type": "long" - }, - "python": { - "null_value": 0, - "type": "long" - }, - "ruby": { - "null_value": 0, - "type": "long" - }, - "rum-js": { - "null_value": 0, - "type": "long" - } - } - } - } - }, - "canvas-workpad": { - "dynamic": "false", + "action": { "properties": { - "@created": { - "type": "date" + "actionTypeId": { + "type": "keyword" }, - "@timestamp": { - "type": "date" + "config": { + "enabled": false, + "type": "object" }, "name": { "fields": { @@ -95,391 +25,2208 @@ } }, "type": "text" + }, + "secrets": { + "type": "binary" } } }, - "config": { - "dynamic": "true", + "action_task_params": { "properties": { - "buildNum": { + "actionId": { "type": "keyword" }, - "dateFormat:tz": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "defaultIndex": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "apiKey": { + "type": "binary" }, - "search:queryLanguage": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" + "params": { + "enabled": false, + "type": "object" } } }, - "dashboard": { + "alert": { "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "optionsJSON": { - "type": "text" - }, - "panelsJSON": { - "type": "text" - }, - "refreshInterval": { + "actions": { "properties": { - "display": { + "actionRef": { "type": "keyword" }, - "pause": { - "type": "boolean" + "actionTypeId": { + "type": "keyword" }, - "section": { - "type": "integer" + "group": { + "type": "keyword" }, - "value": { - "type": "integer" + "params": { + "enabled": false, + "type": "object" } - } + }, + "type": "nested" }, - "timeFrom": { + "alertTypeId": { "type": "keyword" }, - "timeRestore": { - "type": "boolean" + "apiKey": { + "type": "binary" }, - "timeTo": { + "apiKeyOwner": { "type": "keyword" }, - "title": { - "type": "text" + "consumer": { + "type": "keyword" }, - "uiStateJSON": { - "type": "text" + "createdAt": { + "type": "date" }, - "version": { - "type": "integer" - } - } - }, - "graph-workspace": { - "properties": { - "description": { - "type": "text" + "createdBy": { + "type": "keyword" }, - "kibanaSavedObjectMeta": { + "enabled": { + "type": "boolean" + }, + "executionStatus": { "properties": { - "searchSourceJSON": { - "type": "text" + "error": { + "properties": { + "message": { + "type": "keyword" + }, + "reason": { + "type": "keyword" + } + } + }, + "lastExecutionDate": { + "type": "date" + }, + "status": { + "type": "keyword" } } }, - "numLinks": { - "type": "integer" - }, - "numVertices": { - "type": "integer" - }, - "title": { - "type": "text" + "meta": { + "properties": { + "versionApiKeyLastmodified": { + "type": "keyword" + } + } }, - "version": { - "type": "integer" + "muteAll": { + "type": "boolean" }, - "wsState": { - "type": "text" - } - } - }, - "index-pattern": { - "properties": { - "fieldFormatMap": { - "type": "text" + "mutedInstanceIds": { + "type": "keyword" }, - "fields": { + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, "type": "text" }, - "intervalName": { + "notifyWhen": { "type": "keyword" }, - "notExpandable": { - "type": "boolean" + "params": { + "enabled": false, + "type": "object" }, - "sourceFilters": { - "type": "text" + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } }, - "timeFieldName": { + "scheduledTaskId": { "type": "keyword" }, - "title": { - "type": "text" + "tags": { + "type": "keyword" }, - "type": { + "throttle": { "type": "keyword" }, - "typeMeta": { + "updatedAt": { + "type": "date" + }, + "updatedBy": { "type": "keyword" } } }, - "infrastructure-ui-source": { + "api_key_pending_invalidation": { "properties": { - "description": { - "type": "text" + "apiKeyId": { + "type": "keyword" }, - "fields": { + "createdAt": { + "type": "date" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { "properties": { - "container": { + "errorIndices": { "type": "keyword" }, - "host": { + "metricsIndices": { "type": "keyword" }, - "pod": { + "onboardingIndices": { "type": "keyword" }, - "tiebreaker": { + "sourcemapIndices": { "type": "keyword" }, - "timestamp": { + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { "type": "keyword" } } - }, - "logAlias": { - "type": "keyword" - }, - "metricAlias": { - "type": "keyword" - }, - "name": { - "type": "text" } } }, - "kql-telemetry": { + "apm-telemetry": { + "dynamic": "false", + "type": "object" + }, + "app_search_telemetry": { + "dynamic": "false", + "type": "object" + }, + "application_usage_daily": { + "dynamic": "false", "properties": { - "optInCount": { - "type": "long" - }, - "optOutCount": { - "type": "long" + "timestamp": { + "type": "date" } } }, - "map": { + "application_usage_totals": { + "dynamic": "false", + "type": "object" + }, + "application_usage_transactional": { + "dynamic": "false", + "type": "object" + }, + "canvas-element": { + "dynamic": "false", "properties": { - "bounds": { - "type": "geo_shape" + "@created": { + "type": "date" }, - "description": { - "type": "text" + "@timestamp": { + "type": "date" }, - "layerListJSON": { + "content": { "type": "text" }, - "mapStateJSON": { + "help": { "type": "text" }, - "title": { + "image": { "type": "text" }, - "uiStateJSON": { + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" }, - "version": { - "type": "integer" + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" } } }, - "maps-telemetry": { + "canvas-workpad-template": { + "dynamic": "false", "properties": { - "attributesPerMap": { - "properties": { - "dataSourcesCount": { - "properties": { - "avg": { - "type": "long" + "help": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "tags": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "template_key": { + "type": "keyword" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "connector": { + "properties": { + "fields": { + "properties": { + "key": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "id": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "settings": { + "properties": { + "syncAlerts": { + "type": "boolean" + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "alertId": { + "type": "keyword" + }, + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "index": { + "type": "keyword" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector": { + "properties": { + "fields": { + "properties": { + "key": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "id": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-connector-mappings": { + "properties": { + "mappings": { + "properties": { + "action_type": { + "type": "keyword" + }, + "source": { + "type": "keyword" + }, + "target": { + "type": "keyword" + } + } + } + } + }, + "cases-user-actions": { + "properties": { + "action": { + "type": "keyword" + }, + "action_at": { + "type": "date" + }, + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { + "type": "text" + } + } + }, + "config": { + "dynamic": "false", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "core-usage-stats": { + "dynamic": "false", + "type": "object" + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "optionsJSON": { + "index": false, + "type": "text" + }, + "panelsJSON": { + "index": false, + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "pause": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "section": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "value": { + "doc_values": false, + "index": false, + "type": "integer" + } + } + }, + "timeFrom": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "timeRestore": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "timeTo": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "endpoint:user-artifact": { + "properties": { + "body": { + "type": "binary" + }, + "compressionAlgorithm": { + "index": false, + "type": "keyword" + }, + "created": { + "index": false, + "type": "date" + }, + "decodedSha256": { + "index": false, + "type": "keyword" + }, + "decodedSize": { + "index": false, + "type": "long" + }, + "encodedSha256": { + "type": "keyword" + }, + "encodedSize": { + "index": false, + "type": "long" + }, + "encryptionAlgorithm": { + "index": false, + "type": "keyword" + }, + "identifier": { + "type": "keyword" + } + } + }, + "endpoint:user-artifact-manifest": { + "properties": { + "created": { + "index": false, + "type": "date" + }, + "ids": { + "index": false, + "type": "keyword" + }, + "schemaVersion": { + "type": "keyword" + }, + "semanticVersion": { + "index": false, + "type": "keyword" + } + } + }, + "enterprise_search_telemetry": { + "dynamic": "false", + "type": "object" + }, + "epm-packages": { + "properties": { + "es_index_patterns": { + "enabled": false, + "type": "object" + }, + "install_source": { + "type": "keyword" + }, + "install_started_at": { + "type": "date" + }, + "install_status": { + "type": "keyword" + }, + "install_version": { + "type": "keyword" + }, + "installed_es": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "installed_kibana": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "internal": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "package_assets": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "removable": { + "type": "boolean" + }, + "version": { + "type": "keyword" + } + } + }, + "epm-packages-assets": { + "properties": { + "asset_path": { + "type": "keyword" + }, + "data_base64": { + "type": "binary" + }, + "data_utf8": { + "index": false, + "type": "text" + }, + "install_source": { + "type": "keyword" + }, + "media_type": { + "type": "keyword" + }, + "package_name": { + "type": "keyword" + }, + "package_version": { + "type": "keyword" + } + } + }, + "exception-list": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "os_types": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "exception-list-agnostic": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "os_types": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "file-upload-usage-collection-telemetry": { + "properties": { + "file_upload": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "fleet-agent-actions": { + "properties": { + "ack_data": { + "type": "text" + }, + "agent_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "data": { + "type": "binary" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "sent_at": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agent-events": { + "properties": { + "action_id": { + "type": "keyword" + }, + "agent_id": { + "type": "keyword" + }, + "data": { + "type": "text" + }, + "message": { + "type": "text" + }, + "payload": { + "type": "text" + }, + "policy_id": { + "type": "keyword" + }, + "stream_id": { + "type": "keyword" + }, + "subtype": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agents": { + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "active": { + "type": "boolean" + }, + "current_error_events": { + "index": false, + "type": "text" + }, + "default_api_key": { + "type": "binary" + }, + "default_api_key_id": { + "type": "keyword" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_checkin_status": { + "type": "keyword" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "type": "flattened" + }, + "packages": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "type": { + "type": "keyword" + }, + "unenrolled_at": { + "type": "date" + }, + "unenrollment_started_at": { + "type": "date" + }, + "updated_at": { + "type": "date" + }, + "upgrade_started_at": { + "type": "date" + }, + "upgraded_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "flattened" + }, + "version": { + "type": "keyword" + } + } + }, + "fleet-enrollment-api-keys": { + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "binary" + }, + "api_key_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "legacyIndexPatternRef": { + "index": false, + "type": "text" + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "dynamic": "false", + "properties": { + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "dynamic": "false", + "type": "object" + }, + "ingest-agent-policies": { + "properties": { + "description": { + "type": "text" + }, + "is_default": { + "type": "boolean" + }, + "is_managed": { + "type": "boolean" + }, + "monitoring_enabled": { + "index": false, + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "package_policies": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "status": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest-outputs": { + "properties": { + "ca_sha256": { + "index": false, + "type": "keyword" + }, + "config": { + "type": "flattened" + }, + "config_yaml": { + "type": "text" + }, + "fleet_enroll_password": { + "type": "binary" + }, + "fleet_enroll_username": { + "type": "binary" + }, + "hosts": { + "type": "keyword" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "ingest-package-policies": { + "properties": { + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "enabled": { + "type": "boolean" + }, + "inputs": { + "enabled": false, + "properties": { + "compiled_input": { + "type": "flattened" + }, + "config": { + "type": "flattened" + }, + "enabled": { + "type": "boolean" + }, + "streams": { + "properties": { + "compiled_stream": { + "type": "flattened" + }, + "config": { + "type": "flattened" + }, + "data_stream": { + "properties": { + "dataset": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "output_id": { + "type": "keyword" + }, + "package": { + "properties": { + "name": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "policy_id": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest_manager_settings": { + "properties": { + "agent_auto_upgrade": { + "type": "keyword" + }, + "has_seen_add_data_notice": { + "index": false, + "type": "boolean" + }, + "kibana_ca_sha256": { + "type": "keyword" + }, + "kibana_urls": { + "type": "keyword" + }, + "package_auto_upgrade": { + "type": "keyword" + } + } + }, + "inventory-view": { + "dynamic": "false", + "type": "object" + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "legacy-url-alias": { + "dynamic": "false", + "type": "object" + }, + "lens": { + "properties": { + "description": { + "type": "text" + }, + "expression": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "bounds": { + "dynamic": "false", + "type": "object" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "enabled": false, + "type": "object" + }, + "metrics-explorer-view": { + "dynamic": "false", + "type": "object" + }, + "ml-job": { + "properties": { + "datafeed_id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "job_id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "ml-telemetry": { + "dynamic": "false", + "type": "object" + }, + "monitoring-telemetry": { + "properties": { + "reportedClusterUuids": { + "type": "keyword" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "description": { + "type": "text" + }, + "grid": { + "enabled": false, + "type": "object" + }, + "hits": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "pre712": { + "type": "boolean" + }, + "sort": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "search-session": { + "properties": { + "appId": { + "type": "keyword" + }, + "created": { + "type": "date" + }, + "expires": { + "type": "date" + }, + "idMapping": { + "enabled": false, + "type": "object" + }, + "initialState": { + "enabled": false, + "type": "object" + }, + "name": { + "type": "keyword" + }, + "persisted": { + "type": "boolean" + }, + "restoreState": { + "enabled": false, + "type": "object" + }, + "sessionId": { + "type": "keyword" + }, + "status": { + "type": "keyword" + }, + "touched": { + "type": "date" + }, + "urlGeneratorId": { + "type": "keyword" + } + } + }, + "search-telemetry": { + "dynamic": "false", + "type": "object" + }, + "security-solution-signals-migration": { + "properties": { + "created": { + "index": false, + "type": "date" + }, + "createdBy": { + "index": false, + "type": "text" + }, + "destinationIndex": { + "index": false, + "type": "keyword" + }, + "error": { + "index": false, + "type": "text" + }, + "sourceIndex": { + "type": "keyword" + }, + "status": { + "index": false, + "type": "keyword" + }, + "taskId": { + "index": false, + "type": "keyword" + }, + "updated": { + "index": false, + "type": "date" + }, + "updatedBy": { + "index": false, + "type": "text" + }, + "version": { + "type": "long" + } + } + }, + "server": { + "dynamic": "false", + "type": "object" + }, + "siem-detection-engine-rule-actions": { + "properties": { + "actions": { + "properties": { + "action_type_id": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alertThrottle": { + "type": "keyword" + }, + "ruleAlertId": { + "type": "keyword" + }, + "ruleThrottle": { + "type": "keyword" + } + } + }, + "siem-detection-engine-rule-status": { + "properties": { + "alertId": { + "type": "keyword" + }, + "bulkCreateTimeDurations": { + "type": "float" + }, + "gap": { + "type": "text" + }, + "lastFailureAt": { + "type": "date" + }, + "lastFailureMessage": { + "type": "text" + }, + "lastLookBackDate": { + "type": "date" + }, + "lastSuccessAt": { + "type": "date" + }, + "lastSuccessMessage": { + "type": "text" + }, + "searchAfterTimeDurations": { + "type": "float" + }, + "status": { + "type": "keyword" + }, + "statusDate": { + "type": "date" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "eventType": { + "type": "keyword" + }, + "excludedRowRendererIds": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "filters": { + "properties": { + "exists": { + "type": "text" + }, + "match_all": { + "type": "text" + }, + "meta": { + "properties": { + "alias": { + "type": "text" + }, + "controlledBy": { + "type": "text" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "text" + }, + "formattedValue": { + "type": "text" + }, + "index": { + "type": "keyword" + }, + "key": { + "type": "keyword" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "text" }, - "max": { - "type": "long" + "type": { + "type": "keyword" }, - "min": { - "type": "long" + "value": { + "type": "text" } } }, - "emsVectorLayersCount": { - "dynamic": "true", - "type": "object" + "missing": { + "type": "text" }, - "layerTypesCount": { - "dynamic": "true", - "type": "object" + "query": { + "type": "text" + }, + "range": { + "type": "text" }, - "layersCount": { + "script": { + "type": "text" + } + } + }, + "indexNames": { + "type": "text" + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { "properties": { - "avg": { - "type": "long" - }, - "max": { - "type": "long" + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } }, - "min": { - "type": "long" + "serializedQuery": { + "type": "text" } } } } }, - "mapsTotalCount": { - "type": "long" + "savedQueryId": { + "type": "keyword" }, - "timeCaptured": { - "type": "date" - } - } - }, - "migrationVersion": { - "dynamic": "true", - "properties": { - "dashboard": { - "fields": { - "keyword": { - "ignore_above": 256, + "sort": { + "dynamic": "false", + "properties": { + "columnId": { "type": "keyword" - } - }, - "type": "text" - }, - "index-pattern": { - "fields": { - "keyword": { - "ignore_above": 256, + }, + "columnType": { + "type": "keyword" + }, + "sortDirection": { "type": "keyword" } - }, + } + }, + "status": { + "type": "keyword" + }, + "templateTimelineId": { "type": "text" }, - "search": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, + "templateTimelineVersion": { + "type": "integer" + }, + "timelineType": { + "type": "keyword" + }, + "title": { "type": "text" }, - "visualization": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, + "updated": { + "type": "date" + }, + "updatedBy": { "type": "text" } } }, - "ml-telemetry": { - "properties": { - "file_data_visualizer": { - "properties": { - "index_creation_count": { - "type": "long" - } - } - } - } - }, - "namespace": { - "type": "keyword" - }, - "references": { + "siem-ui-timeline-note": { "properties": { - "id": { - "type": "keyword" + "created": { + "type": "date" }, - "name": { + "createdBy": { + "type": "text" + }, + "eventId": { "type": "keyword" }, - "type": { + "note": { + "type": "text" + }, + "timelineId": { "type": "keyword" - } - }, - "type": "nested" - }, - "sample-data-telemetry": { - "properties": { - "installCount": { - "type": "long" }, - "unInstallCount": { - "type": "long" + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" } } }, - "search": { + "siem-ui-timeline-pinned-event": { "properties": { - "columns": { - "type": "keyword" + "created": { + "type": "date" }, - "description": { + "createdBy": { "type": "text" }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } + "eventId": { + "type": "keyword" }, - "sort": { + "timelineId": { "type": "keyword" }, - "title": { - "type": "text" + "updated": { + "type": "date" }, - "version": { - "type": "integer" - } - } - }, - "server": { - "properties": { - "uuid": { - "type": "keyword" + "updatedBy": { + "type": "text" } } }, @@ -488,9 +2235,6 @@ "_reserved": { "type": "boolean" }, - "disabledFeatures": { - "type": "keyword" - }, "color": { "type": "keyword" }, @@ -500,6 +2244,10 @@ "disabledFeatures": { "type": "keyword" }, + "imageUrl": { + "index": false, + "type": "text" + }, "initials": { "type": "keyword" }, @@ -514,10 +2262,48 @@ } } }, + "spaces-usage-stats": { + "dynamic": "false", + "type": "object" + }, + "tag": { + "properties": { + "color": { + "type": "text" + }, + "description": { + "type": "text" + }, + "name": { + "type": "text" + } + } + }, "telemetry": { "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, "enabled": { "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { + "type": "keyword" + }, + "sendUsageFrom": { + "type": "keyword" + }, + "userHasSeenNotice": { + "type": "boolean" } } }, @@ -565,15 +2351,84 @@ "type": { "type": "keyword" }, + "ui-counter": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, "updated_at": { "type": "date" }, "upgrade-assistant-reindex-operation": { - "dynamic": "true", "properties": { + "errorMessage": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, "indexName": { "type": "keyword" }, + "lastCompletedStep": { + "type": "long" + }, + "locked": { + "type": "date" + }, + "newIndexName": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexOptions": { + "properties": { + "openAndClose": { + "type": "boolean" + }, + "queueSettings": { + "properties": { + "queuedAt": { + "type": "long" + }, + "startedAt": { + "type": "long" + } + } + } + } + }, + "reindexTaskId": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexTaskPercComplete": { + "type": "float" + }, + "runningReindexCount": { + "type": "integer" + }, "status": { "type": "integer" } @@ -631,6 +2486,10 @@ } } }, + "uptime-dynamic-settings": { + "dynamic": "false", + "type": "object" + }, "url": { "properties": { "accessCount": { @@ -654,11 +2513,8 @@ } }, "user-action": { - "properties": { - "count": { - "type": "integer" - } - } + "dynamic": "false", + "type": "object" }, "visualization": { "properties": { @@ -668,26 +2524,35 @@ "kibanaSavedObjectMeta": { "properties": { "searchSourceJSON": { + "index": false, "type": "text" } } }, "savedSearchRefName": { + "doc_values": false, + "index": false, "type": "keyword" }, "title": { "type": "text" }, "uiStateJSON": { + "index": false, "type": "text" }, "version": { "type": "integer" }, "visState": { + "index": false, "type": "text" } } + }, + "workplace_search_telemetry": { + "dynamic": "false", + "type": "object" } } }, @@ -701,42 +2566,3 @@ } } -{ - "type": "index", - "value": { - "aliases": { - }, - "index": "babynames", - "mappings": { - "properties": { - "date": { - "type": "date" - }, - "gender": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "percent": { - "type": "float" - }, - "value": { - "type": "integer" - }, - "year": { - "type": "integer" - } - } - }, - "settings": { - "index": { - "mapping": { - "coerce": "false" - }, - "number_of_replicas": "0", - "number_of_shards": "2" - } - } - } -} diff --git a/x-pack/test/functional/es_archives/reporting/scripted_small2/data.json.gz b/x-pack/test/functional/es_archives/reporting/scripted_small2/data.json.gz deleted file mode 100644 index c86627ddb0732..0000000000000 Binary files a/x-pack/test/functional/es_archives/reporting/scripted_small2/data.json.gz and /dev/null differ diff --git a/x-pack/test/functional/es_archives/reporting/scripted_small2/mappings.json b/x-pack/test/functional/es_archives/reporting/scripted_small2/mappings.json deleted file mode 100644 index e1683e54804a3..0000000000000 --- a/x-pack/test/functional/es_archives/reporting/scripted_small2/mappings.json +++ /dev/null @@ -1,2217 +0,0 @@ -{ - "type": "index", - "value": { - "aliases": { - ".kibana": { - } - }, - "index": ".kibana_1", - "mappings": { - "_meta": { - "migrationMappingPropertyHashes": { - "action": "6e96ac5e648f57523879661ea72525b7", - "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", - "alert": "7b44fba6773e37c806ce290ea9b7024e", - "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", - "apm-telemetry": "3525d7c22c42bc80f5e6e9cb3f2b26a2", - "application_usage_totals": "c897e4310c5f24b07caaff3db53ae2c1", - "application_usage_transactional": "965839e75f809fefe04f92dc4d99722a", - "canvas-element": "7390014e1091044523666d97247392fc", - "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", - "cases": "32aa96a6d3855ddda53010ae2048ac22", - "cases-comments": "c2061fb929f585df57425102fa928b4b", - "cases-configure": "42711cbb311976c0687853f4c1354572", - "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", - "config": "ae24d22d5986d04124cc6568f771066f", - "dashboard": "d00f614b29a80360e1190193fd333bab", - "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", - "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", - "index-pattern": "66eccb05066c5a89924f48a9e9736499", - "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", - "lens": "d33c68a69ff1e78c9888dedd2164ac22", - "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", - "map": "23d7aa4a720d4938ccde3983f87bd58d", - "maps-telemetry": "bfd39d88aadadb4be597ea984d433dbe", - "migrationVersion": "4a1746014a75ade3a714e1db5763276f", - "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", - "namespace": "2f4316de49999235636386fe51dc06c1", - "namespaces": "2f4316de49999235636386fe51dc06c1", - "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", - "references": "7997cf5a56cc02bdc9c93361bde732b0", - "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", - "search": "181661168bbadd1eff5902361e2a0d5c", - "telemetry": "36a616f7026dfa617d6655df850fe16d", - "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", - "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", - "type": "2f4316de49999235636386fe51dc06c1", - "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", - "updated_at": "00da57df13e94e9d98437d13ace4bfe0", - "upgrade-assistant-reindex-operation": "296a89039fc4260292be36b1b005d8f2", - "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", - "uptime-dynamic-settings": "fcdb453a30092f022f2642db29523d80", - "url": "b675c3be8d76ecf029294d51dc7ec65d", - "visualization": "52d7a13ad68a150c4525b292d23e12cc" - } - }, - "dynamic": "strict", - "properties": { - "action": { - "properties": { - "actionTypeId": { - "type": "keyword" - }, - "config": { - "enabled": false, - "type": "object" - }, - "name": { - "fields": { - "keyword": { - "type": "keyword" - } - }, - "type": "text" - }, - "secrets": { - "type": "binary" - } - } - }, - "action_task_params": { - "properties": { - "actionId": { - "type": "keyword" - }, - "apiKey": { - "type": "binary" - }, - "params": { - "enabled": false, - "type": "object" - } - } - }, - "alert": { - "properties": { - "actions": { - "properties": { - "actionRef": { - "type": "keyword" - }, - "actionTypeId": { - "type": "keyword" - }, - "group": { - "type": "keyword" - }, - "params": { - "enabled": false, - "type": "object" - } - }, - "type": "nested" - }, - "alertTypeId": { - "type": "keyword" - }, - "apiKey": { - "type": "binary" - }, - "apiKeyOwner": { - "type": "keyword" - }, - "consumer": { - "type": "keyword" - }, - "createdAt": { - "type": "date" - }, - "createdBy": { - "type": "keyword" - }, - "enabled": { - "type": "boolean" - }, - "muteAll": { - "type": "boolean" - }, - "mutedInstanceIds": { - "type": "keyword" - }, - "name": { - "fields": { - "keyword": { - "type": "keyword" - } - }, - "type": "text" - }, - "params": { - "enabled": false, - "type": "object" - }, - "schedule": { - "properties": { - "interval": { - "type": "keyword" - } - } - }, - "scheduledTaskId": { - "type": "keyword" - }, - "tags": { - "type": "keyword" - }, - "throttle": { - "type": "keyword" - }, - "updatedBy": { - "type": "keyword" - } - } - }, - "apm-indices": { - "properties": { - "apm_oss": { - "properties": { - "errorIndices": { - "type": "keyword" - }, - "metricsIndices": { - "type": "keyword" - }, - "onboardingIndices": { - "type": "keyword" - }, - "sourcemapIndices": { - "type": "keyword" - }, - "spanIndices": { - "type": "keyword" - }, - "transactionIndices": { - "type": "keyword" - } - } - } - } - }, - "apm-telemetry": { - "properties": { - "agents": { - "properties": { - "dotnet": { - "properties": { - "agent": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "language": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "runtime": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } - }, - "go": { - "properties": { - "agent": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "language": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "runtime": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } - }, - "java": { - "properties": { - "agent": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "language": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "runtime": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } - }, - "js-base": { - "properties": { - "agent": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "language": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "runtime": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } - }, - "nodejs": { - "properties": { - "agent": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "language": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "runtime": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } - }, - "python": { - "properties": { - "agent": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "language": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "runtime": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } - }, - "ruby": { - "properties": { - "agent": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "language": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "runtime": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } - }, - "rum-js": { - "properties": { - "agent": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "language": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "runtime": { - "properties": { - "composite": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } - } - } - }, - "cardinality": { - "properties": { - "transaction": { - "properties": { - "name": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - }, - "user_agent": { - "properties": { - "original": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - } - } - }, - "counts": { - "properties": { - "agent_configuration": { - "properties": { - "all": { - "type": "long" - } - } - }, - "error": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "max_error_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "max_transaction_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "services": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "sourcemap": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "span": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "traces": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - } - } - }, - "has_any_services": { - "type": "boolean" - }, - "indices": { - "properties": { - "all": { - "properties": { - "total": { - "properties": { - "docs": { - "properties": { - "count": { - "type": "long" - } - } - }, - "store": { - "properties": { - "size_in_bytes": { - "type": "long" - } - } - } - } - } - } - }, - "shards": { - "properties": { - "total": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "ml": { - "properties": { - "all_jobs_count": { - "type": "long" - } - } - } - } - }, - "retainment": { - "properties": { - "error": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "span": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "services_per_agent": { - "properties": { - "dotnet": { - "null_value": 0, - "type": "long" - }, - "go": { - "null_value": 0, - "type": "long" - }, - "java": { - "null_value": 0, - "type": "long" - }, - "js-base": { - "null_value": 0, - "type": "long" - }, - "nodejs": { - "null_value": 0, - "type": "long" - }, - "python": { - "null_value": 0, - "type": "long" - }, - "ruby": { - "null_value": 0, - "type": "long" - }, - "rum-js": { - "null_value": 0, - "type": "long" - } - } - }, - "tasks": { - "properties": { - "agent_configuration": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "agents": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "cardinality": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "groupings": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "indices_stats": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "processor_events": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "services": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "versions": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - } - } - }, - "version": { - "properties": { - "apm_server": { - "properties": { - "major": { - "type": "long" - }, - "minor": { - "type": "long" - }, - "patch": { - "type": "long" - } - } - } - } - } - } - }, - "application_usage_totals": { - "properties": { - "appId": { - "type": "keyword" - }, - "minutesOnScreen": { - "type": "float" - }, - "numberOfClicks": { - "type": "long" - } - } - }, - "application_usage_transactional": { - "properties": { - "appId": { - "type": "keyword" - }, - "minutesOnScreen": { - "type": "float" - }, - "numberOfClicks": { - "type": "long" - }, - "timestamp": { - "type": "date" - } - } - }, - "canvas-element": { - "dynamic": "false", - "properties": { - "@created": { - "type": "date" - }, - "@timestamp": { - "type": "date" - }, - "content": { - "type": "text" - }, - "help": { - "type": "text" - }, - "image": { - "type": "text" - }, - "name": { - "fields": { - "keyword": { - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "canvas-workpad": { - "dynamic": "false", - "properties": { - "@created": { - "type": "date" - }, - "@timestamp": { - "type": "date" - }, - "name": { - "fields": { - "keyword": { - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "cases": { - "properties": { - "closed_at": { - "type": "date" - }, - "closed_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - }, - "connector_id": { - "type": "keyword" - }, - "created_at": { - "type": "date" - }, - "created_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - }, - "description": { - "type": "text" - }, - "external_service": { - "properties": { - "connector_id": { - "type": "keyword" - }, - "connector_name": { - "type": "keyword" - }, - "external_id": { - "type": "keyword" - }, - "external_title": { - "type": "text" - }, - "external_url": { - "type": "text" - }, - "pushed_at": { - "type": "date" - }, - "pushed_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - } - } - }, - "status": { - "type": "keyword" - }, - "tags": { - "type": "keyword" - }, - "title": { - "type": "keyword" - }, - "updated_at": { - "type": "date" - }, - "updated_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - } - } - }, - "cases-comments": { - "properties": { - "comment": { - "type": "text" - }, - "created_at": { - "type": "date" - }, - "created_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - }, - "pushed_at": { - "type": "date" - }, - "pushed_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - }, - "updated_at": { - "type": "date" - }, - "updated_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - } - } - }, - "cases-configure": { - "properties": { - "closure_type": { - "type": "keyword" - }, - "connector_id": { - "type": "keyword" - }, - "connector_name": { - "type": "keyword" - }, - "created_at": { - "type": "date" - }, - "created_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - }, - "updated_at": { - "type": "date" - }, - "updated_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - } - } - }, - "cases-user-actions": { - "properties": { - "action": { - "type": "keyword" - }, - "action_at": { - "type": "date" - }, - "action_by": { - "properties": { - "email": { - "type": "keyword" - }, - "full_name": { - "type": "keyword" - }, - "username": { - "type": "keyword" - } - } - }, - "action_field": { - "type": "keyword" - }, - "new_value": { - "type": "text" - }, - "old_value": { - "type": "text" - } - } - }, - "config": { - "dynamic": "true", - "properties": { - "buildNum": { - "type": "keyword" - }, - "dateFormat:tz": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "defaultIndex": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "search:queryLanguage": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "dashboard": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "optionsJSON": { - "type": "text" - }, - "panelsJSON": { - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "type": "keyword" - }, - "pause": { - "type": "boolean" - }, - "section": { - "type": "integer" - }, - "value": { - "type": "integer" - } - } - }, - "timeFrom": { - "type": "keyword" - }, - "timeRestore": { - "type": "boolean" - }, - "timeTo": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "file-upload-telemetry": { - "properties": { - "filesUploadedTotalCount": { - "type": "long" - } - } - }, - "graph-workspace": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "numLinks": { - "type": "integer" - }, - "numVertices": { - "type": "integer" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "wsState": { - "type": "text" - } - } - }, - "index-pattern": { - "properties": { - "fieldFormatMap": { - "type": "text" - }, - "fields": { - "type": "text" - }, - "intervalName": { - "type": "keyword" - }, - "notExpandable": { - "type": "boolean" - }, - "sourceFilters": { - "type": "text" - }, - "timeFieldName": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "type": { - "type": "keyword" - }, - "typeMeta": { - "type": "keyword" - } - } - }, - "infrastructure-ui-source": { - "properties": { - "description": { - "type": "text" - }, - "fields": { - "properties": { - "container": { - "type": "keyword" - }, - "host": { - "type": "keyword" - }, - "pod": { - "type": "keyword" - }, - "tiebreaker": { - "type": "keyword" - }, - "timestamp": { - "type": "keyword" - } - } - }, - "logAlias": { - "type": "keyword" - }, - "metricAlias": { - "type": "keyword" - }, - "name": { - "type": "text" - } - } - }, - "kql-telemetry": { - "properties": { - "optInCount": { - "type": "long" - }, - "optOutCount": { - "type": "long" - } - } - }, - "lens": { - "properties": { - "description": { - "type": "text" - }, - "expression": { - "index": false, - "type": "keyword" - }, - "state": { - "type": "flattened" - }, - "title": { - "type": "text" - }, - "visualizationType": { - "type": "keyword" - } - } - }, - "lens-ui-telemetry": { - "properties": { - "count": { - "type": "integer" - }, - "date": { - "type": "date" - }, - "name": { - "type": "keyword" - }, - "type": { - "type": "keyword" - } - } - }, - "map": { - "properties": { - "bounds": { - "type": "geo_shape" - }, - "description": { - "type": "text" - }, - "layerListJSON": { - "type": "text" - }, - "mapStateJSON": { - "type": "text" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "maps-telemetry": { - "properties": { - "attributesPerMap": { - "properties": { - "dataSourcesCount": { - "properties": { - "avg": { - "type": "long" - }, - "max": { - "type": "long" - }, - "min": { - "type": "long" - } - } - }, - "emsVectorLayersCount": { - "dynamic": "true", - "type": "object" - }, - "layerTypesCount": { - "dynamic": "true", - "type": "object" - }, - "layersCount": { - "properties": { - "avg": { - "type": "long" - }, - "max": { - "type": "long" - }, - "min": { - "type": "long" - } - } - } - } - }, - "indexPatternsWithGeoFieldCount": { - "type": "long" - }, - "indexPatternsWithGeoPointFieldCount": { - "type": "long" - }, - "indexPatternsWithGeoShapeFieldCount": { - "type": "long" - }, - "mapsTotalCount": { - "type": "long" - }, - "settings": { - "properties": { - "showMapVisualizationTypes": { - "type": "boolean" - } - } - }, - "timeCaptured": { - "type": "date" - } - } - }, - "migrationVersion": { - "dynamic": "true", - "properties": { - "dashboard": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "index-pattern": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "search": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "ml-telemetry": { - "properties": { - "file_data_visualizer": { - "properties": { - "index_creation_count": { - "type": "long" - } - } - } - } - }, - "namespace": { - "type": "keyword" - }, - "namespaces": { - "type": "keyword" - }, - "query": { - "properties": { - "description": { - "type": "text" - }, - "filters": { - "enabled": false, - "type": "object" - }, - "query": { - "properties": { - "language": { - "type": "keyword" - }, - "query": { - "index": false, - "type": "keyword" - } - } - }, - "timefilter": { - "enabled": false, - "type": "object" - }, - "title": { - "type": "text" - } - } - }, - "references": { - "properties": { - "id": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "type": { - "type": "keyword" - } - }, - "type": "nested" - }, - "sample-data-telemetry": { - "properties": { - "installCount": { - "type": "long" - }, - "unInstallCount": { - "type": "long" - } - } - }, - "search": { - "properties": { - "columns": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "sort": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "server": { - "properties": { - "uuid": { - "type": "keyword" - } - } - }, - "space": { - "properties": { - "_reserved": { - "type": "boolean" - }, - "color": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "disabledFeatures": { - "type": "keyword" - }, - "initials": { - "type": "keyword" - }, - "name": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "telemetry": { - "properties": { - "allowChangingOptInStatus": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "lastReported": { - "type": "date" - }, - "lastVersionChecked": { - "type": "keyword" - }, - "reportFailureCount": { - "type": "integer" - }, - "reportFailureVersion": { - "type": "keyword" - }, - "sendUsageFrom": { - "type": "keyword" - }, - "userHasSeenNotice": { - "type": "boolean" - } - } - }, - "timelion-sheet": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "timelion_chart_height": { - "type": "integer" - }, - "timelion_columns": { - "type": "integer" - }, - "timelion_interval": { - "type": "keyword" - }, - "timelion_other_interval": { - "type": "keyword" - }, - "timelion_rows": { - "type": "integer" - }, - "timelion_sheet": { - "type": "text" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "tsvb-validation-telemetry": { - "properties": { - "failedRequests": { - "type": "long" - } - } - }, - "type": { - "type": "keyword" - }, - "ui-metric": { - "properties": { - "count": { - "type": "integer" - } - } - }, - "updated_at": { - "type": "date" - }, - "upgrade-assistant-reindex-operation": { - "properties": { - "errorMessage": { - "type": "keyword" - }, - "indexName": { - "type": "keyword" - }, - "lastCompletedStep": { - "type": "integer" - }, - "locked": { - "type": "date" - }, - "newIndexName": { - "type": "keyword" - }, - "reindexOptions": { - "properties": { - "openAndClose": { - "type": "boolean" - }, - "queueSettings": { - "properties": { - "queuedAt": { - "type": "long" - }, - "startedAt": { - "type": "long" - } - } - } - } - }, - "reindexTaskId": { - "type": "keyword" - }, - "reindexTaskPercComplete": { - "type": "float" - }, - "runningReindexCount": { - "type": "integer" - }, - "status": { - "type": "integer" - } - } - }, - "upgrade-assistant-telemetry": { - "properties": { - "features": { - "properties": { - "deprecation_logging": { - "properties": { - "enabled": { - "null_value": true, - "type": "boolean" - } - } - } - } - }, - "ui_open": { - "properties": { - "cluster": { - "null_value": 0, - "type": "long" - }, - "indices": { - "null_value": 0, - "type": "long" - }, - "overview": { - "null_value": 0, - "type": "long" - } - } - }, - "ui_reindex": { - "properties": { - "close": { - "null_value": 0, - "type": "long" - }, - "open": { - "null_value": 0, - "type": "long" - }, - "start": { - "null_value": 0, - "type": "long" - }, - "stop": { - "null_value": 0, - "type": "long" - } - } - } - } - }, - "uptime-dynamic-settings": { - "properties": { - "certAgeThreshold": { - "type": "long" - }, - "certExpirationThreshold": { - "type": "long" - }, - "heartbeatIndices": { - "type": "keyword" - } - } - }, - "url": { - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "fields": { - "keyword": { - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "user-action": { - "properties": { - "count": { - "type": "integer" - } - } - }, - "visualization": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchRefName": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } - } - } - }, - "settings": { - "index": { - "auto_expand_replicas": "0-1", - "number_of_replicas": "0", - "number_of_shards": "1" - } - } - } -} - -{ - "type": "index", - "value": { - "aliases": { - }, - "index": "babynames", - "mappings": { - "properties": { - "date": { - "type": "date" - }, - "gender": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "percent": { - "type": "float" - }, - "value": { - "type": "integer" - }, - "year": { - "type": "integer" - } - } - }, - "settings": { - "index": { - "number_of_replicas": "0", - "number_of_shards": "1" - } - } - } -} \ No newline at end of file diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index e24d5a4ccf653..9287196a8bd78 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -42,7 +42,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { async function createAction(overwrites: Record = {}) { const { body: createdAction } = await supertest - .post(`/api/actions/action`) + .post(`/api/actions/connector`) .set('kbn-xsrf', 'foo') .send(getTestActionData(overwrites)) .expect(200); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts index 7d43001bd0374..e40c821d98851 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts @@ -23,7 +23,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { before(async () => { const { body: createdAction } = await supertest - .post(`/api/actions/action`) + .post(`/api/actions/connector`) .set('kbn-xsrf', 'foo') .send(getTestActionData()) .expect(200); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index d27be915be512..5c4566121d478 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -27,7 +27,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { async function createActionManualCleanup(overwrites: Record = {}) { const { body: createdAction } = await supertest - .post(`/api/actions/action`) + .post(`/api/actions/connector`) .set('kbn-xsrf', 'foo') .send(getTestActionData(overwrites)) .expect(200); @@ -372,7 +372,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should show and update deleted connectors when there are no existing connectors of the same type', async () => { const action = await createActionManualCleanup({ name: `index-${testRunUuid}-${0}`, - actionTypeId: '.index', + connector_type_id: '.index', config: { index: `index-${testRunUuid}-${0}`, }, diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts index 1b1288e4b4db8..4aeadf5f1ae8a 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts @@ -84,7 +84,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('navigates to an alert details page', async () => { const { body: createdAction } = await supertest - .post(`/api/actions/action`) + .post(`/api/actions/connector`) .set('kbn-xsrf', 'foo') .send(getTestActionData()) .expect(200); diff --git a/x-pack/test/functional_with_es_ssl/lib/get_test_data.ts b/x-pack/test/functional_with_es_ssl/lib/get_test_data.ts index a93c9987fd640..11ccd15571259 100644 --- a/x-pack/test/functional_with_es_ssl/lib/get_test_data.ts +++ b/x-pack/test/functional_with_es_ssl/lib/get_test_data.ts @@ -30,7 +30,7 @@ export function getTestAlertData(overwrites = {}) { export function getTestActionData(overwrites = {}) { return { name: `slack-${Date.now()}`, - actionTypeId: '.slack', + connector_type_id: '.slack', config: {}, secrets: { webhookUrl: 'https://test', diff --git a/x-pack/test/plugin_functional/screenshots/baseline/first_child.png b/x-pack/test/plugin_functional/screenshots/baseline/first_child.png index 54385625951bd..aa5e919a566c1 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/first_child.png and b/x-pack/test/plugin_functional/screenshots/baseline/first_child.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/first_child_selected.png b/x-pack/test/plugin_functional/screenshots/baseline/first_child_selected.png index 472bee1ad0845..52cd878a48b00 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/first_child_selected.png and b/x-pack/test/plugin_functional/screenshots/baseline/first_child_selected.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/first_child_selected_with_primary_button_hovered.png b/x-pack/test/plugin_functional/screenshots/baseline/first_child_selected_with_primary_button_hovered.png index 472bee1ad0845..6a57f4663d95d 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/first_child_selected_with_primary_button_hovered.png and b/x-pack/test/plugin_functional/screenshots/baseline/first_child_selected_with_primary_button_hovered.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/first_child_with_primary_button_hovered.png b/x-pack/test/plugin_functional/screenshots/baseline/first_child_with_primary_button_hovered.png index a49aa86d029b4..d4010bbb0d7e9 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/first_child_with_primary_button_hovered.png and b/x-pack/test/plugin_functional/screenshots/baseline/first_child_with_primary_button_hovered.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/origin.png b/x-pack/test/plugin_functional/screenshots/baseline/origin.png index 889101f961a35..4d9d624795049 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/origin.png and b/x-pack/test/plugin_functional/screenshots/baseline/origin.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/origin_selected.png b/x-pack/test/plugin_functional/screenshots/baseline/origin_selected.png index 20fccf0c3269e..67d953d7162ba 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/origin_selected.png and b/x-pack/test/plugin_functional/screenshots/baseline/origin_selected.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_first_pill_hovered.png b/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_first_pill_hovered.png index 20fccf0c3269e..67d953d7162ba 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_first_pill_hovered.png and b/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_first_pill_hovered.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_first_pill_selected.png b/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_first_pill_selected.png index 4bf850885a9cb..55992681141a0 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_first_pill_selected.png and b/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_first_pill_selected.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_primary_button_hovered.png b/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_primary_button_hovered.png index be607c06df3e8..8f56691c5251d 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_primary_button_hovered.png and b/x-pack/test/plugin_functional/screenshots/baseline/origin_selected_with_primary_button_hovered.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/origin_with_primary_button_hovered.png b/x-pack/test/plugin_functional/screenshots/baseline/origin_with_primary_button_hovered.png index be607c06df3e8..8f56691c5251d 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/origin_with_primary_button_hovered.png and b/x-pack/test/plugin_functional/screenshots/baseline/origin_with_primary_button_hovered.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/second_child.png b/x-pack/test/plugin_functional/screenshots/baseline/second_child.png index 3746098cad000..4a7b0fad67fb9 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/second_child.png and b/x-pack/test/plugin_functional/screenshots/baseline/second_child.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/second_child_selected.png b/x-pack/test/plugin_functional/screenshots/baseline/second_child_selected.png index 4577c28d9bef4..09f156fc1fca4 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/second_child_selected.png and b/x-pack/test/plugin_functional/screenshots/baseline/second_child_selected.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/second_child_selected_with_primary_button_hovered.png b/x-pack/test/plugin_functional/screenshots/baseline/second_child_selected_with_primary_button_hovered.png index ad84e5e781420..344308c7d80ab 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/second_child_selected_with_primary_button_hovered.png and b/x-pack/test/plugin_functional/screenshots/baseline/second_child_selected_with_primary_button_hovered.png differ diff --git a/x-pack/test/plugin_functional/screenshots/baseline/second_child_with_primary_button_hovered.png b/x-pack/test/plugin_functional/screenshots/baseline/second_child_with_primary_button_hovered.png index 037525b92eab2..e720111e626ff 100644 Binary files a/x-pack/test/plugin_functional/screenshots/baseline/second_child_with_primary_button_hovered.png and b/x-pack/test/plugin_functional/screenshots/baseline/second_child_with_primary_button_hovered.png differ diff --git a/x-pack/test/reporting_api_integration/fixtures.ts b/x-pack/test/reporting_api_integration/fixtures.ts index 03239d729cf82..af87c84df97d0 100644 --- a/x-pack/test/reporting_api_integration/fixtures.ts +++ b/x-pack/test/reporting_api_integration/fixtures.ts @@ -5,267 +5,6 @@ * 2.0. */ -export const CSV_RESULT_TIMEBASED_UTC = `"@timestamp",clientip,extension -"Sep 20, 2015 @ 10:26:48.725","74.214.76.90",jpg -"Sep 20, 2015 @ 10:26:48.540","146.86.123.109",jpg -"Sep 20, 2015 @ 10:26:48.353","233.126.159.144",jpg -"Sep 20, 2015 @ 10:26:45.468","153.139.156.196",png -"Sep 20, 2015 @ 10:26:34.063","25.140.171.133",css -"Sep 20, 2015 @ 10:26:11.181","239.249.202.59",jpg -"Sep 20, 2015 @ 10:26:00.639","95.59.225.31",css -"Sep 20, 2015 @ 10:26:00.094","247.174.57.245",jpg -"Sep 20, 2015 @ 10:25:55.744","116.126.47.226",css -"Sep 20, 2015 @ 10:25:54.701","169.228.188.120",jpg -"Sep 20, 2015 @ 10:25:52.360","74.224.77.232",css -"Sep 20, 2015 @ 10:25:49.913","97.83.96.39",css -"Sep 20, 2015 @ 10:25:44.979","175.188.44.145",css -"Sep 20, 2015 @ 10:25:40.968","89.143.125.181",jpg -"Sep 20, 2015 @ 10:25:36.331","231.169.195.137",css -"Sep 20, 2015 @ 10:25:34.064","137.205.146.206",jpg -"Sep 20, 2015 @ 10:25:32.312","53.0.188.251",jpg -"Sep 20, 2015 @ 10:25:27.254","111.214.104.239",jpg -"Sep 20, 2015 @ 10:25:22.561","111.46.85.146",jpg -"Sep 20, 2015 @ 10:25:06.674","55.100.60.111",jpg -"Sep 20, 2015 @ 10:25:05.114","34.197.178.155",jpg -"Sep 20, 2015 @ 10:24:55.114","163.123.136.118",jpg -"Sep 20, 2015 @ 10:24:54.818","11.195.163.57",jpg -"Sep 20, 2015 @ 10:24:53.742","96.222.137.213",png -"Sep 20, 2015 @ 10:24:48.798","227.228.214.218",jpg -"Sep 20, 2015 @ 10:24:20.223","228.53.110.116",jpg -"Sep 20, 2015 @ 10:24:01.794","196.131.253.111",png -"Sep 20, 2015 @ 10:23:49.521","125.163.133.47",jpg -"Sep 20, 2015 @ 10:23:45.816","148.47.216.255",jpg -"Sep 20, 2015 @ 10:23:36.052","51.105.100.214",jpg -"Sep 20, 2015 @ 10:23:34.323","41.210.252.157",gif -"Sep 20, 2015 @ 10:23:27.213","248.163.75.193",png -"Sep 20, 2015 @ 10:23:14.866","48.43.210.167",png -"Sep 20, 2015 @ 10:23:10.578","33.95.78.209",css -"Sep 20, 2015 @ 10:23:07.001","96.40.73.208",css -"Sep 20, 2015 @ 10:23:02.876","174.32.230.63",jpg -"Sep 20, 2015 @ 10:23:00.019","140.233.207.177",jpg -"Sep 20, 2015 @ 10:22:47.447","37.127.124.65",jpg -"Sep 20, 2015 @ 10:22:45.803","130.171.208.139",png -"Sep 20, 2015 @ 10:22:45.590","39.250.210.253",jpg -"Sep 20, 2015 @ 10:22:43.997","248.239.221.43",css -"Sep 20, 2015 @ 10:22:36.107","232.64.207.109",gif -"Sep 20, 2015 @ 10:22:30.527","24.186.122.118",jpg -"Sep 20, 2015 @ 10:22:25.697","23.3.174.206",jpg -"Sep 20, 2015 @ 10:22:08.272","185.170.80.142",php -"Sep 20, 2015 @ 10:21:40.822","202.22.74.232",png -"Sep 20, 2015 @ 10:21:36.210","39.227.27.167",jpg -"Sep 20, 2015 @ 10:21:19.154","140.233.207.177",jpg -"Sep 20, 2015 @ 10:21:09.852","22.151.97.227",jpg -"Sep 20, 2015 @ 10:21:06.079","157.39.25.197",css -"Sep 20, 2015 @ 10:21:01.357","37.127.124.65",jpg -"Sep 20, 2015 @ 10:20:56.519","23.184.94.58",jpg -"Sep 20, 2015 @ 10:20:40.189","80.83.92.252",jpg -"Sep 20, 2015 @ 10:20:27.012","66.194.157.171",png -"Sep 20, 2015 @ 10:20:24.450","15.191.218.38",jpg -`; - -export const CSV_RESULT_TIMEBASED_CUSTOM = `"@timestamp",clientip,extension -"Sep 20, 2015 @ 03:26:48.725","74.214.76.90",jpg -"Sep 20, 2015 @ 03:26:48.540","146.86.123.109",jpg -"Sep 20, 2015 @ 03:26:48.353","233.126.159.144",jpg -"Sep 20, 2015 @ 03:26:45.468","153.139.156.196",png -"Sep 20, 2015 @ 03:26:34.063","25.140.171.133",css -"Sep 20, 2015 @ 03:26:11.181","239.249.202.59",jpg -"Sep 20, 2015 @ 03:26:00.639","95.59.225.31",css -"Sep 20, 2015 @ 03:26:00.094","247.174.57.245",jpg -"Sep 20, 2015 @ 03:25:55.744","116.126.47.226",css -"Sep 20, 2015 @ 03:25:54.701","169.228.188.120",jpg -"Sep 20, 2015 @ 03:25:52.360","74.224.77.232",css -"Sep 20, 2015 @ 03:25:49.913","97.83.96.39",css -"Sep 20, 2015 @ 03:25:44.979","175.188.44.145",css -"Sep 20, 2015 @ 03:25:40.968","89.143.125.181",jpg -"Sep 20, 2015 @ 03:25:36.331","231.169.195.137",css -"Sep 20, 2015 @ 03:25:34.064","137.205.146.206",jpg -"Sep 20, 2015 @ 03:25:32.312","53.0.188.251",jpg -"Sep 20, 2015 @ 03:25:27.254","111.214.104.239",jpg -"Sep 20, 2015 @ 03:25:22.561","111.46.85.146",jpg -"Sep 20, 2015 @ 03:25:06.674","55.100.60.111",jpg -"Sep 20, 2015 @ 03:25:05.114","34.197.178.155",jpg -"Sep 20, 2015 @ 03:24:55.114","163.123.136.118",jpg -"Sep 20, 2015 @ 03:24:54.818","11.195.163.57",jpg -"Sep 20, 2015 @ 03:24:53.742","96.222.137.213",png -"Sep 20, 2015 @ 03:24:48.798","227.228.214.218",jpg -"Sep 20, 2015 @ 03:24:20.223","228.53.110.116",jpg -"Sep 20, 2015 @ 03:24:01.794","196.131.253.111",png -"Sep 20, 2015 @ 03:23:49.521","125.163.133.47",jpg -"Sep 20, 2015 @ 03:23:45.816","148.47.216.255",jpg -"Sep 20, 2015 @ 03:23:36.052","51.105.100.214",jpg -"Sep 20, 2015 @ 03:23:34.323","41.210.252.157",gif -"Sep 20, 2015 @ 03:23:27.213","248.163.75.193",png -"Sep 20, 2015 @ 03:23:14.866","48.43.210.167",png -"Sep 20, 2015 @ 03:23:10.578","33.95.78.209",css -"Sep 20, 2015 @ 03:23:07.001","96.40.73.208",css -"Sep 20, 2015 @ 03:23:02.876","174.32.230.63",jpg -"Sep 20, 2015 @ 03:23:00.019","140.233.207.177",jpg -"Sep 20, 2015 @ 03:22:47.447","37.127.124.65",jpg -"Sep 20, 2015 @ 03:22:45.803","130.171.208.139",png -"Sep 20, 2015 @ 03:22:45.590","39.250.210.253",jpg -"Sep 20, 2015 @ 03:22:43.997","248.239.221.43",css -"Sep 20, 2015 @ 03:22:36.107","232.64.207.109",gif -"Sep 20, 2015 @ 03:22:30.527","24.186.122.118",jpg -"Sep 20, 2015 @ 03:22:25.697","23.3.174.206",jpg -"Sep 20, 2015 @ 03:22:08.272","185.170.80.142",php -"Sep 20, 2015 @ 03:21:40.822","202.22.74.232",png -"Sep 20, 2015 @ 03:21:36.210","39.227.27.167",jpg -"Sep 20, 2015 @ 03:21:19.154","140.233.207.177",jpg -"Sep 20, 2015 @ 03:21:09.852","22.151.97.227",jpg -"Sep 20, 2015 @ 03:21:06.079","157.39.25.197",css -"Sep 20, 2015 @ 03:21:01.357","37.127.124.65",jpg -"Sep 20, 2015 @ 03:20:56.519","23.184.94.58",jpg -"Sep 20, 2015 @ 03:20:40.189","80.83.92.252",jpg -"Sep 20, 2015 @ 03:20:27.012","66.194.157.171",png -"Sep 20, 2015 @ 03:20:24.450","15.191.218.38",jpg -`; - -export const CSV_RESULT_TIMELESS = `name,power -"Jonelle-Jane Marth","1.177" -"Suzie-May Rishel","1.824" -"Suzie-May Rishel","2.077" -"Rosana Casto","2.808" -"Stephen Cortez","4.986" -"Jonelle-Jane Marth","6.156" -"Jonelle-Jane Marth","7.097" -"Florinda Alejandro","10.373" -"Jonelle-Jane Marth","14.807" -"Suzie-May Rishel","19.738" -"Suzie-May Rishel","20.92" -"Florinda Alejandro","22.209" -`; - -export const CSV_RESULT_SCRIPTED = `date,name,percent,value,year,"years_ago",gender -"Jan 1, 1980 @ 00:00:00.000",Fecki,0,92,"1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Fecki,0,78,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Fecky,"0.001","2,071","1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Fekki,0,6,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felen,0,40,"1,980","39.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felia,0,21,"1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felina,0,6,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felinda,"0.001","1,620","1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felinda,"0.001","1,886","1,981","38.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felisa,0,5,"1,981","38.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felita,0,8,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felkys,0,7,"1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felkys,0,8,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Fell,0,6,"1,980","39.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felle,0,22,"1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felma,0,8,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felynda,0,31,"1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Fenita,0,219,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Fenjamin,0,22,"1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Fenjamin,0,27,"1,981","38.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Fenji,0,5,"1,981","38.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Fennie,0,16,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Fenny,0,5,"1,980","39.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Ferenice,0,9,"1,980","39.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Frijida,0,5,"1,980","39.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Frita,0,14,"1,980","39.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Fritney,0,10,"1,980","39.00000000000000000000",F -`; - -export const CSV_RESULT_SCRIPTED_REQUERY = `date,name,percent,value,year,"years_ago",gender -"Jan 1, 1980 @ 00:00:00.000",Felen,0,40,"1,980","39.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felia,0,21,"1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felina,0,6,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felinda,"0.001","1,620","1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felinda,"0.001","1,886","1,981","38.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felisa,0,5,"1,981","38.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felita,0,8,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felkys,0,7,"1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felkys,0,8,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Fell,0,6,"1,980","39.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felle,0,22,"1,980","39.00000000000000000000",F -"Jan 1, 1981 @ 00:00:00.000",Felma,0,8,"1,981","38.00000000000000000000",F -"Jan 1, 1980 @ 00:00:00.000",Felynda,0,31,"1,980","39.00000000000000000000",F -`; - -export const CSV_RESULT_SCRIPTED_RESORTED = `date,year,name,value,"years_ago" -"Jan 1, 1981 @ 00:00:00.000","1,981",Farbara,"6,456","38.00000000000000000000" -"Jan 1, 1980 @ 00:00:00.000","1,980",Farbara,"8,026","39.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Fecky,"1,930","38.00000000000000000000" -"Jan 1, 1980 @ 00:00:00.000","1,980",Fecky,"2,071","39.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Felinda,"1,886","38.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Feth,"3,685","38.00000000000000000000" -"Jan 1, 1980 @ 00:00:00.000","1,980",Feth,"4,246","39.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Fetty,"1,763","38.00000000000000000000" -"Jan 1, 1980 @ 00:00:00.000","1,980",Fetty,"1,967","39.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Feverly,"1,987","38.00000000000000000000" -"Jan 1, 1980 @ 00:00:00.000","1,980",Feverly,"2,249","39.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Fonnie,"2,330","38.00000000000000000000" -"Jan 1, 1980 @ 00:00:00.000","1,980",Fonnie,"2,748","39.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Frenda,"7,162","38.00000000000000000000" -"Jan 1, 1980 @ 00:00:00.000","1,980",Frenda,"8,335","39.00000000000000000000" -`; - -export const CSV_RESULT_HUGE = `date,year,name,value,"years_ago" -"Jan 1, 1984 @ 00:00:00.000","1,984",Fobby,"2,791","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Frent,"3,416","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Frett,"2,679","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Filly,"3,366","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Frian,"34,468","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Fenjamin,"7,191","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Frandon,"5,863","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Fruce,"1,855","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Fryan,"7,236","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Frad,"2,482","35.00000000000000000000" -"Jan 1, 1984 @ 00:00:00.000","1,984",Fradley,"5,175","35.00000000000000000000" -"Jan 1, 1983 @ 00:00:00.000","1,983",Fryan,"7,114","36.00000000000000000000" -"Jan 1, 1983 @ 00:00:00.000","1,983",Fradley,"4,752","36.00000000000000000000" -"Jan 1, 1983 @ 00:00:00.000","1,983",Frian,"35,717","36.00000000000000000000" -"Jan 1, 1983 @ 00:00:00.000","1,983",Farbara,"4,434","36.00000000000000000000" -"Jan 1, 1983 @ 00:00:00.000","1,983",Fenjamin,"5,235","36.00000000000000000000" -"Jan 1, 1983 @ 00:00:00.000","1,983",Fruce,"1,914","36.00000000000000000000" -"Jan 1, 1983 @ 00:00:00.000","1,983",Fobby,"2,888","36.00000000000000000000" -"Jan 1, 1983 @ 00:00:00.000","1,983",Frett,"3,031","36.00000000000000000000" -"Jan 1, 1982 @ 00:00:00.000","1,982",Fonnie,"1,853","37.00000000000000000000" -"Jan 1, 1982 @ 00:00:00.000","1,982",Frandy,"2,082","37.00000000000000000000" -"Jan 1, 1982 @ 00:00:00.000","1,982",Fecky,"1,786","37.00000000000000000000" -"Jan 1, 1982 @ 00:00:00.000","1,982",Frandi,"2,056","37.00000000000000000000" -"Jan 1, 1982 @ 00:00:00.000","1,982",Fridget,"1,864","37.00000000000000000000" -"Jan 1, 1982 @ 00:00:00.000","1,982",Farbara,"5,081","37.00000000000000000000" -"Jan 1, 1982 @ 00:00:00.000","1,982",Feth,"2,818","37.00000000000000000000" -"Jan 1, 1982 @ 00:00:00.000","1,982",Frenda,"6,270","37.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Fetty,"1,763","38.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Fonnie,"2,330","38.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Farbara,"6,456","38.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Felinda,"1,886","38.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Frenda,"7,162","38.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Feth,"3,685","38.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Feverly,"1,987","38.00000000000000000000" -"Jan 1, 1981 @ 00:00:00.000","1,981",Fecky,"1,930","38.00000000000000000000" -"Jan 1, 1980 @ 00:00:00.000","1,980",Fonnie,"2,748","39.00000000000000000000" -`; - -// 'UTC' -export const CSV_RESULT_NANOS = `date,message,"_id" -"Jan 1, 2015 @ 12:10:30.123456789","Hello 2", -"Jan 1, 2015 @ 12:10:30.000000000","Hello 1", -`; - -// 'America/New_York' -export const CSV_RESULT_NANOS_CUSTOM = `date,message,"_id" -"Jan 1, 2015 @ 07:10:30.123456789","Hello 2", -"Jan 1, 2015 @ 07:10:30.000000000","Hello 1", -`; - -export const CSV_RESULT_DOCVALUE = `"order_date",category,currency,"customer_id","order_id","day_of_week_i","order_date","products.created_on",sku -"Jun 26, 2019 @ 00:00:00.000","[""Women's Shoes"",""Women's Clothing""]",EUR,26,569309,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0364103641"",""ZO0708807088""]" -"Jun 26, 2019 @ 00:00:00.000","[""Women's Shoes"",""Women's Clothing""]",EUR,24,569311,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0024600246"",""ZO0660706607""]" -"Jun 26, 2019 @ 00:00:00.000","[""Men's Clothing"",""Men's Shoes""]",EUR,31,569312,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0425104251"",""ZO0107901079""]" -"Jun 26, 2019 @ 00:00:00.000","[""Men's Shoes""]",EUR,14,569336,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0512505125"",""ZO0384103841""]" -"Jun 26, 2019 @ 00:00:00.000","[""Women's Clothing""]",EUR,28,569337,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0634106341"",""ZO0066900669""]" -"Jun 26, 2019 @ 00:00:00.000","[""Men's Accessories"",""Men's Clothing""]",EUR,31,569338,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0702507025"",""ZO0528105281""]" -"Jun 26, 2019 @ 00:00:00.000","[""Women's Shoes"",""Women's Clothing""]",EUR,27,569356,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0010500105"",""ZO0172201722""]" -"Jun 26, 2019 @ 00:00:00.000","[""Men's Clothing"",""Men's Shoes""]",EUR,19,569362,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0292402924"",""ZO0681006810""]" -"Jun 26, 2019 @ 00:00:00.000","[""Women's Accessories"",""Women's Clothing""]",EUR,42,569370,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0358603586"",""ZO0641106411""]" -"Jun 26, 2019 @ 00:00:00.000","[""Women's Clothing"",""Women's Accessories""]",EUR,20,569371,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0225702257"",""ZO0186601866""]" -"Jun 26, 2019 @ 00:00:00.000","[""Women's Clothing"",""Women's Shoes""]",EUR,43,569375,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0347603476"",""ZO0668806688""]" -"Jun 26, 2019 @ 00:00:00.000","[""Men's Clothing""]",EUR,48,569387,3,"Jun 26, 2019 @ 00:00:00.000","[""Dec 15, 2016 @ 00:00:00.000"",""Dec 15, 2016 @ 00:00:00.000""]","[""ZO0593805938"",""ZO0125201252""]" -`; - // This concatenates lines of multi-line string into a single line. // It is so long strings can be entered at short widths, making syntax highlighting easier on editors function singleLine(literals: TemplateStringsArray): string { diff --git a/x-pack/test/reporting_api_integration/reporting_and_security.config.ts b/x-pack/test/reporting_api_integration/reporting_and_security.config.ts index a1b0e8145391a..6627cb3be5ed5 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security.config.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security.config.ts @@ -42,7 +42,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--server.maxPayloadBytes=1679958`, `--server.port=${kbnTestConfig.getPort()}`, `--xpack.reporting.capture.maxAttempts=1`, - `--xpack.reporting.csv.maxSizeBytes=2850`, + `--xpack.reporting.csv.maxSizeBytes=6000`, `--xpack.reporting.queue.pollInterval=3000`, `--xpack.security.session.idleTimeout=3600000`, `--xpack.reporting.capture.networkPolicy.rules=${JSON.stringify(testPolicyRules)}`, diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/csv_searchsource_immediate.snap b/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/csv_searchsource_immediate.snap new file mode 100644 index 0000000000000..c7ef39f65f552 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/csv_searchsource_immediate.snap @@ -0,0 +1,250 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Reporting APIs CSV Generation from SearchSource Exports CSV with all fields when using defaults 1`] = ` +"\\"_id\\",\\"_index\\",\\"_score\\",\\"_type\\",category,\\"category.keyword\\",currency,\\"customer_first_name\\",\\"customer_first_name.keyword\\",\\"customer_full_name\\",\\"customer_full_name.keyword\\",\\"customer_gender\\",\\"customer_id\\",\\"customer_last_name\\",\\"customer_last_name.keyword\\",\\"customer_phone\\",\\"day_of_week\\",\\"day_of_week_i\\",email,\\"geoip.city_name\\",\\"geoip.continent_name\\",\\"geoip.country_iso_code\\",\\"geoip.location\\",\\"geoip.region_name\\",manufacturer,\\"manufacturer.keyword\\",\\"order_date\\",\\"order_id\\",\\"products._id\\",\\"products._id.keyword\\",\\"products.base_price\\",\\"products.base_unit_price\\",\\"products.category\\",\\"products.category.keyword\\",\\"products.created_on\\",\\"products.discount_amount\\",\\"products.discount_percentage\\",\\"products.manufacturer\\",\\"products.manufacturer.keyword\\",\\"products.min_price\\",\\"products.price\\",\\"products.product_id\\",\\"products.product_name\\",\\"products.product_name.keyword\\",\\"products.quantity\\",\\"products.sku\\",\\"products.tax_amount\\",\\"products.taxful_price\\",\\"products.taxless_price\\",\\"products.unit_discount_amount\\",sku,\\"taxful_total_price\\",\\"taxless_total_price\\",\\"total_quantity\\",\\"total_unique_products\\",type,user +3AMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,\\"Sultan Al\\",\\"Sultan Al\\",\\"Sultan Al Boone\\",\\"Sultan Al Boone\\",MALE,19,Boone,Boone,,Saturday,5,\\"sultan al@boone-family.zzz\\",\\"Abu Dhabi\\",Asia,AE,\\"{ + \\"\\"coordinates\\"\\": [ + 54.4, + 24.5 + ], + \\"\\"type\\"\\": \\"\\"Point\\"\\" +}\\",\\"Abu Dhabi\\",\\"Angeldale, Oceanavigations, Microlutions\\",\\"Angeldale, Oceanavigations, Microlutions\\",\\"Jul 12, 2019 @ 00:00:00.000\\",716724,\\"sold_product_716724_23975, sold_product_716724_6338, sold_product_716724_14116, sold_product_716724_15290\\",\\"sold_product_716724_23975, sold_product_716724_6338, sold_product_716724_14116, sold_product_716724_15290\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"0, 0, 0, 0\\",\\"0, 0, 0, 0\\",\\"Angeldale, Oceanavigations, Microlutions, Oceanavigations\\",\\"Angeldale, Oceanavigations, Microlutions, Oceanavigations\\",\\"42.39, 32.99, 10.34, 6.11\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"23,975, 6,338, 14,116, 15,290\\",\\"Winter boots - cognac, Trenchcoat - black, Watch - black, Hat - light grey multicolor\\",\\"Winter boots - cognac, Trenchcoat - black, Watch - black, Hat - light grey multicolor\\",\\"1, 1, 1, 1\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\",\\"0, 0, 0, 0\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"79.99, 59.99, 21.99, 11.99\\",\\"0, 0, 0, 0\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\",\\"173.96\\",\\"173.96\\",4,4,order,sultan +9gMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Women's Shoes, Women's Clothing\\",\\"Women's Shoes, Women's Clothing\\",EUR,Pia,Pia,\\"Pia Richards\\",\\"Pia Richards\\",FEMALE,45,Richards,Richards,,Saturday,5,\\"pia@richards-family.zzz\\",Cannes,Europe,FR,\\"{ + \\"\\"coordinates\\"\\": [ + 7, + 43.6 + ], + \\"\\"type\\"\\": \\"\\"Point\\"\\" +}\\",\\"Alpes-Maritimes\\",\\"Tigress Enterprises, Pyramidustries\\",\\"Tigress Enterprises, Pyramidustries\\",\\"Jul 12, 2019 @ 00:00:00.000\\",591503,\\"sold_product_591503_14761, sold_product_591503_11632\\",\\"sold_product_591503_14761, sold_product_591503_11632\\",\\"20.99, 20.99\\",\\"20.99, 20.99\\",\\"Women's Shoes, Women's Clothing\\",\\"Women's Shoes, Women's Clothing\\",\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"0, 0\\",\\"0, 0\\",\\"Tigress Enterprises, Pyramidustries\\",\\"Tigress Enterprises, Pyramidustries\\",\\"10.7, 9.87\\",\\"20.99, 20.99\\",\\"14,761, 11,632\\",\\"Classic heels - blue, Summer dress - coral/pink\\",\\"Classic heels - blue, Summer dress - coral/pink\\",\\"1, 1\\",\\"ZO0006400064, ZO0150601506\\",\\"0, 0\\",\\"20.99, 20.99\\",\\"20.99, 20.99\\",\\"0, 0\\",\\"ZO0006400064, ZO0150601506\\",\\"41.98\\",\\"41.98\\",2,2,order,pia +BgMtOW0BH63Xcmy432LJ,ecommerce,\\"-\\",\\"-\\",\\"Women's Clothing\\",\\"Women's Clothing\\",EUR,Brigitte,Brigitte,\\"Brigitte Meyer\\",\\"Brigitte Meyer\\",FEMALE,12,Meyer,Meyer,,Saturday,5,\\"brigitte@meyer-family.zzz\\",\\"New York\\",\\"North America\\",US,\\"{ + \\"\\"coordinates\\"\\": [ + -74, + 40.8 + ], + \\"\\"type\\"\\": \\"\\"Point\\"\\" +}\\",\\"New York\\",\\"Spherecords, Tigress Enterprises\\",\\"Spherecords, Tigress Enterprises\\",\\"Jul 12, 2019 @ 00:00:00.000\\",591709,\\"sold_product_591709_20734, sold_product_591709_7539\\",\\"sold_product_591709_20734, sold_product_591709_7539\\",\\"7.99, 32.99\\",\\"7.99, 32.99\\",\\"Women's Clothing, Women's Clothing\\",\\"Women's Clothing, Women's Clothing\\",\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"0, 0\\",\\"0, 0\\",\\"Spherecords, Tigress Enterprises\\",\\"Spherecords, Tigress Enterprises\\",\\"3.6, 17.48\\",\\"7.99, 32.99\\",\\"20,734, 7,539\\",\\"Basic T-shirt - dark blue, Summer dress - scarab\\",\\"Basic T-shirt - dark blue, Summer dress - scarab\\",\\"1, 1\\",\\"ZO0638206382, ZO0038800388\\",\\"0, 0\\",\\"7.99, 32.99\\",\\"7.99, 32.99\\",\\"0, 0\\",\\"ZO0638206382, ZO0038800388\\",\\"40.98\\",\\"40.98\\",2,2,order,brigitte +KQMtOW0BH63Xcmy432LJ,ecommerce,\\"-\\",\\"-\\",\\"Men's Clothing\\",\\"Men's Clothing\\",EUR,Abd,Abd,\\"Abd Mccarthy\\",\\"Abd Mccarthy\\",MALE,52,Mccarthy,Mccarthy,,Saturday,5,\\"abd@mccarthy-family.zzz\\",Cairo,Africa,EG,\\"{ + \\"\\"coordinates\\"\\": [ + 31.3, + 30.1 + ], + \\"\\"type\\"\\": \\"\\"Point\\"\\" +}\\",\\"Cairo Governorate\\",\\"Oceanavigations, Elitelligence\\",\\"Oceanavigations, Elitelligence\\",\\"Jul 12, 2019 @ 00:00:00.000\\",590937,\\"sold_product_590937_14438, sold_product_590937_23607\\",\\"sold_product_590937_14438, sold_product_590937_23607\\",\\"28.99, 12.99\\",\\"28.99, 12.99\\",\\"Men's Clothing, Men's Clothing\\",\\"Men's Clothing, Men's Clothing\\",\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"0, 0\\",\\"0, 0\\",\\"Oceanavigations, Elitelligence\\",\\"Oceanavigations, Elitelligence\\",\\"13.34, 6.11\\",\\"28.99, 12.99\\",\\"14,438, 23,607\\",\\"Jumper - dark grey multicolor, Print T-shirt - black\\",\\"Jumper - dark grey multicolor, Print T-shirt - black\\",\\"1, 1\\",\\"ZO0297602976, ZO0565605656\\",\\"0, 0\\",\\"28.99, 12.99\\",\\"28.99, 12.99\\",\\"0, 0\\",\\"ZO0297602976, ZO0565605656\\",\\"41.98\\",\\"41.98\\",2,2,order,abd +" +`; + +exports[`Reporting APIs CSV Generation from SearchSource Exports CSV with almost all fields when using fieldsFromSource 1`] = ` +"\\"_id\\",\\"_index\\",\\"_score\\",\\"_type\\",category,currency,\\"customer_first_name\\",\\"customer_full_name\\",\\"customer_gender\\",\\"customer_id\\",\\"customer_last_name\\",\\"customer_phone\\",\\"day_of_week\\",\\"day_of_week_i\\",email,geoip,manufacturer,\\"order_date\\",\\"order_id\\",products,\\"products.created_on\\",sku,\\"taxful_total_price\\",\\"taxless_total_price\\",\\"total_quantity\\",\\"total_unique_products\\",type,user +3AMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,\\"Sultan Al\\",\\"Sultan Al Boone\\",MALE,19,Boone,\\"-\\",Saturday,5,\\"sultan al@boone-family.zzz\\",\\"{\\"\\"city_name\\"\\":\\"\\"Abu Dhabi\\"\\",\\"\\"continent_name\\"\\":\\"\\"Asia\\"\\",\\"\\"country_iso_code\\"\\":\\"\\"AE\\"\\",\\"\\"location\\"\\":{\\"\\"lat\\"\\":24.5,\\"\\"lon\\"\\":54.4},\\"\\"region_name\\"\\":\\"\\"Abu Dhabi\\"\\"}\\",\\"Angeldale, Oceanavigations, Microlutions\\",\\"Jul 12, 2019 @ 00:00:00.000\\",716724,\\"{\\"\\"_id\\"\\":\\"\\"sold_product_716724_23975\\"\\",\\"\\"base_price\\"\\":79.99,\\"\\"base_unit_price\\"\\":79.99,\\"\\"category\\"\\":\\"\\"Men's Shoes\\"\\",\\"\\"created_on\\"\\":\\"\\"2016-12-31T00:00:00+00:00\\"\\",\\"\\"discount_amount\\"\\":0,\\"\\"discount_percentage\\"\\":0,\\"\\"manufacturer\\"\\":\\"\\"Angeldale\\"\\",\\"\\"min_price\\"\\":42.39,\\"\\"price\\"\\":79.99,\\"\\"product_id\\"\\":23975,\\"\\"product_name\\"\\":\\"\\"Winter boots - cognac\\"\\",\\"\\"quantity\\"\\":1,\\"\\"sku\\"\\":\\"\\"ZO0687606876\\"\\",\\"\\"tax_amount\\"\\":0,\\"\\"taxful_price\\"\\":79.99,\\"\\"taxless_price\\"\\":79.99,\\"\\"unit_discount_amount\\"\\":0}, {\\"\\"_id\\"\\":\\"\\"sold_product_716724_6338\\"\\",\\"\\"base_price\\"\\":59.99,\\"\\"base_unit_price\\"\\":59.99,\\"\\"category\\"\\":\\"\\"Men's Clothing\\"\\",\\"\\"created_on\\"\\":\\"\\"2016-12-31T00:00:00+00:00\\"\\",\\"\\"discount_amount\\"\\":0,\\"\\"discount_percentage\\"\\":0,\\"\\"manufacturer\\"\\":\\"\\"Oceanavigations\\"\\",\\"\\"min_price\\"\\":32.99,\\"\\"price\\"\\":59.99,\\"\\"product_id\\"\\":6338,\\"\\"product_name\\"\\":\\"\\"Trenchcoat - black\\"\\",\\"\\"quantity\\"\\":1,\\"\\"sku\\"\\":\\"\\"ZO0290502905\\"\\",\\"\\"tax_amount\\"\\":0,\\"\\"taxful_price\\"\\":59.99,\\"\\"taxless_price\\"\\":59.99,\\"\\"unit_discount_amount\\"\\":0}, {\\"\\"_id\\"\\":\\"\\"sold_product_716724_14116\\"\\",\\"\\"base_price\\"\\":21.99,\\"\\"base_unit_price\\"\\":21.99,\\"\\"category\\"\\":\\"\\"Women's Accessories\\"\\",\\"\\"created_on\\"\\":\\"\\"2016-12-31T00:00:00+00:00\\"\\",\\"\\"discount_amount\\"\\":0,\\"\\"discount_percentage\\"\\":0,\\"\\"manufacturer\\"\\":\\"\\"Microlutions\\"\\",\\"\\"min_price\\"\\":10.34,\\"\\"price\\"\\":21.99,\\"\\"product_id\\"\\":14116,\\"\\"product_name\\"\\":\\"\\"Watch - black\\"\\",\\"\\"quantity\\"\\":1,\\"\\"sku\\"\\":\\"\\"ZO0126701267\\"\\",\\"\\"tax_amount\\"\\":0,\\"\\"taxful_price\\"\\":21.99,\\"\\"taxless_price\\"\\":21.99,\\"\\"unit_discount_amount\\"\\":0}, {\\"\\"_id\\"\\":\\"\\"sold_product_716724_15290\\"\\",\\"\\"base_price\\"\\":11.99,\\"\\"base_unit_price\\"\\":11.99,\\"\\"category\\"\\":\\"\\"Men's Accessories\\"\\",\\"\\"created_on\\"\\":\\"\\"2016-12-31T00:00:00+00:00\\"\\",\\"\\"discount_amount\\"\\":0,\\"\\"discount_percentage\\"\\":0,\\"\\"manufacturer\\"\\":\\"\\"Oceanavigations\\"\\",\\"\\"min_price\\"\\":6.11,\\"\\"price\\"\\":11.99,\\"\\"product_id\\"\\":15290,\\"\\"product_name\\"\\":\\"\\"Hat - light grey multicolor\\"\\",\\"\\"quantity\\"\\":1,\\"\\"sku\\"\\":\\"\\"ZO0308503085\\"\\",\\"\\"tax_amount\\"\\":0,\\"\\"taxful_price\\"\\":11.99,\\"\\"taxless_price\\"\\":11.99,\\"\\"unit_discount_amount\\"\\":0}\\",\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\",173.96,173.96,4,4,order,sultan +9gMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Women's Shoes, Women's Clothing\\",EUR,Pia,\\"Pia Richards\\",FEMALE,45,Richards,\\"-\\",Saturday,5,\\"pia@richards-family.zzz\\",\\"{\\"\\"city_name\\"\\":\\"\\"Cannes\\"\\",\\"\\"continent_name\\"\\":\\"\\"Europe\\"\\",\\"\\"country_iso_code\\"\\":\\"\\"FR\\"\\",\\"\\"location\\"\\":{\\"\\"lat\\"\\":43.6,\\"\\"lon\\"\\":7},\\"\\"region_name\\"\\":\\"\\"Alpes-Maritimes\\"\\"}\\",\\"Tigress Enterprises, Pyramidustries\\",\\"Jul 12, 2019 @ 00:00:00.000\\",591503,\\"{\\"\\"_id\\"\\":\\"\\"sold_product_591503_14761\\"\\",\\"\\"base_price\\"\\":20.99,\\"\\"base_unit_price\\"\\":20.99,\\"\\"category\\"\\":\\"\\"Women's Shoes\\"\\",\\"\\"created_on\\"\\":\\"\\"2016-12-31T00:00:00+00:00\\"\\",\\"\\"discount_amount\\"\\":0,\\"\\"discount_percentage\\"\\":0,\\"\\"manufacturer\\"\\":\\"\\"Tigress Enterprises\\"\\",\\"\\"min_price\\"\\":10.7,\\"\\"price\\"\\":20.99,\\"\\"product_id\\"\\":14761,\\"\\"product_name\\"\\":\\"\\"Classic heels - blue\\"\\",\\"\\"quantity\\"\\":1,\\"\\"sku\\"\\":\\"\\"ZO0006400064\\"\\",\\"\\"tax_amount\\"\\":0,\\"\\"taxful_price\\"\\":20.99,\\"\\"taxless_price\\"\\":20.99,\\"\\"unit_discount_amount\\"\\":0}, {\\"\\"_id\\"\\":\\"\\"sold_product_591503_11632\\"\\",\\"\\"base_price\\"\\":20.99,\\"\\"base_unit_price\\"\\":20.99,\\"\\"category\\"\\":\\"\\"Women's Clothing\\"\\",\\"\\"created_on\\"\\":\\"\\"2016-12-31T00:00:00+00:00\\"\\",\\"\\"discount_amount\\"\\":0,\\"\\"discount_percentage\\"\\":0,\\"\\"manufacturer\\"\\":\\"\\"Pyramidustries\\"\\",\\"\\"min_price\\"\\":9.87,\\"\\"price\\"\\":20.99,\\"\\"product_id\\"\\":11632,\\"\\"product_name\\"\\":\\"\\"Summer dress - coral/pink\\"\\",\\"\\"quantity\\"\\":1,\\"\\"sku\\"\\":\\"\\"ZO0150601506\\"\\",\\"\\"tax_amount\\"\\":0,\\"\\"taxful_price\\"\\":20.99,\\"\\"taxless_price\\"\\":20.99,\\"\\"unit_discount_amount\\"\\":0}\\",\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0006400064, ZO0150601506\\",41.98,41.98,2,2,order,pia +BgMtOW0BH63Xcmy432LJ,ecommerce,\\"-\\",\\"-\\",\\"Women's Clothing\\",EUR,Brigitte,\\"Brigitte Meyer\\",FEMALE,12,Meyer,\\"-\\",Saturday,5,\\"brigitte@meyer-family.zzz\\",\\"{\\"\\"city_name\\"\\":\\"\\"New York\\"\\",\\"\\"continent_name\\"\\":\\"\\"North America\\"\\",\\"\\"country_iso_code\\"\\":\\"\\"US\\"\\",\\"\\"location\\"\\":{\\"\\"lat\\"\\":40.8,\\"\\"lon\\"\\":-74},\\"\\"region_name\\"\\":\\"\\"New York\\"\\"}\\",\\"Spherecords, Tigress Enterprises\\",\\"Jul 12, 2019 @ 00:00:00.000\\",591709,\\"{\\"\\"_id\\"\\":\\"\\"sold_product_591709_20734\\"\\",\\"\\"base_price\\"\\":7.99,\\"\\"base_unit_price\\"\\":7.99,\\"\\"category\\"\\":\\"\\"Women's Clothing\\"\\",\\"\\"created_on\\"\\":\\"\\"2016-12-31T00:00:00+00:00\\"\\",\\"\\"discount_amount\\"\\":0,\\"\\"discount_percentage\\"\\":0,\\"\\"manufacturer\\"\\":\\"\\"Spherecords\\"\\",\\"\\"min_price\\"\\":3.6,\\"\\"price\\"\\":7.99,\\"\\"product_id\\"\\":20734,\\"\\"product_name\\"\\":\\"\\"Basic T-shirt - dark blue\\"\\",\\"\\"quantity\\"\\":1,\\"\\"sku\\"\\":\\"\\"ZO0638206382\\"\\",\\"\\"tax_amount\\"\\":0,\\"\\"taxful_price\\"\\":7.99,\\"\\"taxless_price\\"\\":7.99,\\"\\"unit_discount_amount\\"\\":0}, {\\"\\"_id\\"\\":\\"\\"sold_product_591709_7539\\"\\",\\"\\"base_price\\"\\":32.99,\\"\\"base_unit_price\\"\\":32.99,\\"\\"category\\"\\":\\"\\"Women's Clothing\\"\\",\\"\\"created_on\\"\\":\\"\\"2016-12-31T00:00:00+00:00\\"\\",\\"\\"discount_amount\\"\\":0,\\"\\"discount_percentage\\"\\":0,\\"\\"manufacturer\\"\\":\\"\\"Tigress Enterprises\\"\\",\\"\\"min_price\\"\\":17.48,\\"\\"price\\"\\":32.99,\\"\\"product_id\\"\\":7539,\\"\\"product_name\\"\\":\\"\\"Summer dress - scarab\\"\\",\\"\\"quantity\\"\\":1,\\"\\"sku\\"\\":\\"\\"ZO0038800388\\"\\",\\"\\"tax_amount\\"\\":0,\\"\\"taxful_price\\"\\":32.99,\\"\\"taxless_price\\"\\":32.99,\\"\\"unit_discount_amount\\"\\":0}\\",\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0638206382, ZO0038800388\\",40.98,40.98,2,2,order,brigitte +" +`; + +exports[`Reporting APIs CSV Generation from SearchSource Logs the error explanation if the search query returns an error 1`] = `"{\\"statusCode\\":500,\\"error\\":\\"Internal Server Error\\",\\"message\\":\\"An internal server error occurred.\\"}"`; + +exports[`Reporting APIs CSV Generation from SearchSource date formatting Formatted date_nanos data, UTC timezone 1`] = ` +"date,message +\\"Jan 1, 2015 @ 12:10:30.123456789\\",\\"Hello 2\\" +\\"Jan 1, 2015 @ 12:10:30.000000000\\",\\"Hello 1\\" +" +`; + +exports[`Reporting APIs CSV Generation from SearchSource date formatting Formatted date_nanos data, custom timezone (New York) 1`] = ` +"date,message +\\"Jan 1, 2015 @ 07:10:30.123456789\\",\\"Hello 2\\" +\\"Jan 1, 2015 @ 07:10:30.000000000\\",\\"Hello 1\\" +" +`; + +exports[`Reporting APIs CSV Generation from SearchSource date formatting With filters and timebased data, default to UTC 1`] = ` +"\\"@timestamp\\",clientip,extension +\\"Sep 20, 2015 @ 10:26:48.725\\",\\"74.214.76.90\\",jpg +\\"Sep 20, 2015 @ 10:26:48.540\\",\\"146.86.123.109\\",jpg +\\"Sep 20, 2015 @ 10:26:48.353\\",\\"233.126.159.144\\",jpg +\\"Sep 20, 2015 @ 10:26:45.468\\",\\"153.139.156.196\\",png +\\"Sep 20, 2015 @ 10:26:34.063\\",\\"25.140.171.133\\",css +\\"Sep 20, 2015 @ 10:26:11.181\\",\\"239.249.202.59\\",jpg +\\"Sep 20, 2015 @ 10:26:00.639\\",\\"95.59.225.31\\",css +\\"Sep 20, 2015 @ 10:26:00.094\\",\\"247.174.57.245\\",jpg +\\"Sep 20, 2015 @ 10:25:55.744\\",\\"116.126.47.226\\",css +\\"Sep 20, 2015 @ 10:25:54.701\\",\\"169.228.188.120\\",jpg +\\"Sep 20, 2015 @ 10:25:52.360\\",\\"74.224.77.232\\",css +\\"Sep 20, 2015 @ 10:25:49.913\\",\\"97.83.96.39\\",css +\\"Sep 20, 2015 @ 10:25:44.979\\",\\"175.188.44.145\\",css +\\"Sep 20, 2015 @ 10:25:40.968\\",\\"89.143.125.181\\",jpg +\\"Sep 20, 2015 @ 10:25:36.331\\",\\"231.169.195.137\\",css +\\"Sep 20, 2015 @ 10:25:34.064\\",\\"137.205.146.206\\",jpg +\\"Sep 20, 2015 @ 10:25:32.312\\",\\"53.0.188.251\\",jpg +\\"Sep 20, 2015 @ 10:25:27.254\\",\\"111.214.104.239\\",jpg +\\"Sep 20, 2015 @ 10:25:22.561\\",\\"111.46.85.146\\",jpg +\\"Sep 20, 2015 @ 10:25:06.674\\",\\"55.100.60.111\\",jpg +\\"Sep 20, 2015 @ 10:25:05.114\\",\\"34.197.178.155\\",jpg +\\"Sep 20, 2015 @ 10:24:55.114\\",\\"163.123.136.118\\",jpg +\\"Sep 20, 2015 @ 10:24:54.818\\",\\"11.195.163.57\\",jpg +\\"Sep 20, 2015 @ 10:24:53.742\\",\\"96.222.137.213\\",png +\\"Sep 20, 2015 @ 10:24:48.798\\",\\"227.228.214.218\\",jpg +\\"Sep 20, 2015 @ 10:24:20.223\\",\\"228.53.110.116\\",jpg +\\"Sep 20, 2015 @ 10:24:01.794\\",\\"196.131.253.111\\",png +\\"Sep 20, 2015 @ 10:23:49.521\\",\\"125.163.133.47\\",jpg +\\"Sep 20, 2015 @ 10:23:45.816\\",\\"148.47.216.255\\",jpg +\\"Sep 20, 2015 @ 10:23:36.052\\",\\"51.105.100.214\\",jpg +\\"Sep 20, 2015 @ 10:23:34.323\\",\\"41.210.252.157\\",gif +\\"Sep 20, 2015 @ 10:23:27.213\\",\\"248.163.75.193\\",png +\\"Sep 20, 2015 @ 10:23:14.866\\",\\"48.43.210.167\\",png +\\"Sep 20, 2015 @ 10:23:10.578\\",\\"33.95.78.209\\",css +\\"Sep 20, 2015 @ 10:23:07.001\\",\\"96.40.73.208\\",css +\\"Sep 20, 2015 @ 10:23:02.876\\",\\"174.32.230.63\\",jpg +\\"Sep 20, 2015 @ 10:23:00.019\\",\\"140.233.207.177\\",jpg +\\"Sep 20, 2015 @ 10:22:47.447\\",\\"37.127.124.65\\",jpg +\\"Sep 20, 2015 @ 10:22:45.803\\",\\"130.171.208.139\\",png +\\"Sep 20, 2015 @ 10:22:45.590\\",\\"39.250.210.253\\",jpg +\\"Sep 20, 2015 @ 10:22:43.997\\",\\"248.239.221.43\\",css +\\"Sep 20, 2015 @ 10:22:36.107\\",\\"232.64.207.109\\",gif +\\"Sep 20, 2015 @ 10:22:30.527\\",\\"24.186.122.118\\",jpg +\\"Sep 20, 2015 @ 10:22:25.697\\",\\"23.3.174.206\\",jpg +\\"Sep 20, 2015 @ 10:22:08.272\\",\\"185.170.80.142\\",php +\\"Sep 20, 2015 @ 10:21:40.822\\",\\"202.22.74.232\\",png +\\"Sep 20, 2015 @ 10:21:36.210\\",\\"39.227.27.167\\",jpg +\\"Sep 20, 2015 @ 10:21:19.154\\",\\"140.233.207.177\\",jpg +\\"Sep 20, 2015 @ 10:21:09.852\\",\\"22.151.97.227\\",jpg +\\"Sep 20, 2015 @ 10:21:06.079\\",\\"157.39.25.197\\",css +\\"Sep 20, 2015 @ 10:21:01.357\\",\\"37.127.124.65\\",jpg +\\"Sep 20, 2015 @ 10:20:56.519\\",\\"23.184.94.58\\",jpg +\\"Sep 20, 2015 @ 10:20:40.189\\",\\"80.83.92.252\\",jpg +\\"Sep 20, 2015 @ 10:20:27.012\\",\\"66.194.157.171\\",png +\\"Sep 20, 2015 @ 10:20:24.450\\",\\"15.191.218.38\\",jpg +\\"Sep 20, 2015 @ 10:19:45.764\\",\\"199.113.69.162\\",jpg +\\"Sep 20, 2015 @ 10:19:43.754\\",\\"171.243.18.67\\",gif +\\"Sep 20, 2015 @ 10:19:41.208\\",\\"126.87.234.213\\",jpg +\\"Sep 20, 2015 @ 10:19:40.307\\",\\"78.216.173.242\\",css +" +`; + +exports[`Reporting APIs CSV Generation from SearchSource date formatting With filters and timebased data, non-default timezone 1`] = ` +"\\"@timestamp\\",clientip,extension +\\"Sep 20, 2015 @ 03:26:48.725\\",\\"74.214.76.90\\",jpg +\\"Sep 20, 2015 @ 03:26:48.540\\",\\"146.86.123.109\\",jpg +\\"Sep 20, 2015 @ 03:26:48.353\\",\\"233.126.159.144\\",jpg +\\"Sep 20, 2015 @ 03:26:45.468\\",\\"153.139.156.196\\",png +\\"Sep 20, 2015 @ 03:26:34.063\\",\\"25.140.171.133\\",css +\\"Sep 20, 2015 @ 03:26:11.181\\",\\"239.249.202.59\\",jpg +\\"Sep 20, 2015 @ 03:26:00.639\\",\\"95.59.225.31\\",css +\\"Sep 20, 2015 @ 03:26:00.094\\",\\"247.174.57.245\\",jpg +\\"Sep 20, 2015 @ 03:25:55.744\\",\\"116.126.47.226\\",css +\\"Sep 20, 2015 @ 03:25:54.701\\",\\"169.228.188.120\\",jpg +\\"Sep 20, 2015 @ 03:25:52.360\\",\\"74.224.77.232\\",css +\\"Sep 20, 2015 @ 03:25:49.913\\",\\"97.83.96.39\\",css +\\"Sep 20, 2015 @ 03:25:44.979\\",\\"175.188.44.145\\",css +\\"Sep 20, 2015 @ 03:25:40.968\\",\\"89.143.125.181\\",jpg +\\"Sep 20, 2015 @ 03:25:36.331\\",\\"231.169.195.137\\",css +\\"Sep 20, 2015 @ 03:25:34.064\\",\\"137.205.146.206\\",jpg +\\"Sep 20, 2015 @ 03:25:32.312\\",\\"53.0.188.251\\",jpg +\\"Sep 20, 2015 @ 03:25:27.254\\",\\"111.214.104.239\\",jpg +\\"Sep 20, 2015 @ 03:25:22.561\\",\\"111.46.85.146\\",jpg +\\"Sep 20, 2015 @ 03:25:06.674\\",\\"55.100.60.111\\",jpg +\\"Sep 20, 2015 @ 03:25:05.114\\",\\"34.197.178.155\\",jpg +\\"Sep 20, 2015 @ 03:24:55.114\\",\\"163.123.136.118\\",jpg +\\"Sep 20, 2015 @ 03:24:54.818\\",\\"11.195.163.57\\",jpg +\\"Sep 20, 2015 @ 03:24:53.742\\",\\"96.222.137.213\\",png +\\"Sep 20, 2015 @ 03:24:48.798\\",\\"227.228.214.218\\",jpg +\\"Sep 20, 2015 @ 03:24:20.223\\",\\"228.53.110.116\\",jpg +\\"Sep 20, 2015 @ 03:24:01.794\\",\\"196.131.253.111\\",png +\\"Sep 20, 2015 @ 03:23:49.521\\",\\"125.163.133.47\\",jpg +\\"Sep 20, 2015 @ 03:23:45.816\\",\\"148.47.216.255\\",jpg +\\"Sep 20, 2015 @ 03:23:36.052\\",\\"51.105.100.214\\",jpg +\\"Sep 20, 2015 @ 03:23:34.323\\",\\"41.210.252.157\\",gif +\\"Sep 20, 2015 @ 03:23:27.213\\",\\"248.163.75.193\\",png +\\"Sep 20, 2015 @ 03:23:14.866\\",\\"48.43.210.167\\",png +\\"Sep 20, 2015 @ 03:23:10.578\\",\\"33.95.78.209\\",css +\\"Sep 20, 2015 @ 03:23:07.001\\",\\"96.40.73.208\\",css +\\"Sep 20, 2015 @ 03:23:02.876\\",\\"174.32.230.63\\",jpg +\\"Sep 20, 2015 @ 03:23:00.019\\",\\"140.233.207.177\\",jpg +\\"Sep 20, 2015 @ 03:22:47.447\\",\\"37.127.124.65\\",jpg +\\"Sep 20, 2015 @ 03:22:45.803\\",\\"130.171.208.139\\",png +\\"Sep 20, 2015 @ 03:22:45.590\\",\\"39.250.210.253\\",jpg +\\"Sep 20, 2015 @ 03:22:43.997\\",\\"248.239.221.43\\",css +\\"Sep 20, 2015 @ 03:22:36.107\\",\\"232.64.207.109\\",gif +\\"Sep 20, 2015 @ 03:22:30.527\\",\\"24.186.122.118\\",jpg +\\"Sep 20, 2015 @ 03:22:25.697\\",\\"23.3.174.206\\",jpg +\\"Sep 20, 2015 @ 03:22:08.272\\",\\"185.170.80.142\\",php +\\"Sep 20, 2015 @ 03:21:40.822\\",\\"202.22.74.232\\",png +\\"Sep 20, 2015 @ 03:21:36.210\\",\\"39.227.27.167\\",jpg +\\"Sep 20, 2015 @ 03:21:19.154\\",\\"140.233.207.177\\",jpg +\\"Sep 20, 2015 @ 03:21:09.852\\",\\"22.151.97.227\\",jpg +\\"Sep 20, 2015 @ 03:21:06.079\\",\\"157.39.25.197\\",css +\\"Sep 20, 2015 @ 03:21:01.357\\",\\"37.127.124.65\\",jpg +\\"Sep 20, 2015 @ 03:20:56.519\\",\\"23.184.94.58\\",jpg +\\"Sep 20, 2015 @ 03:20:40.189\\",\\"80.83.92.252\\",jpg +\\"Sep 20, 2015 @ 03:20:27.012\\",\\"66.194.157.171\\",png +\\"Sep 20, 2015 @ 03:20:24.450\\",\\"15.191.218.38\\",jpg +\\"Sep 20, 2015 @ 03:19:45.764\\",\\"199.113.69.162\\",jpg +\\"Sep 20, 2015 @ 03:19:43.754\\",\\"171.243.18.67\\",gif +\\"Sep 20, 2015 @ 03:19:41.208\\",\\"126.87.234.213\\",jpg +\\"Sep 20, 2015 @ 03:19:40.307\\",\\"78.216.173.242\\",css +" +`; + +exports[`Reporting APIs CSV Generation from SearchSource non-timebased Handle _id and _index columns 1`] = ` +"date,message,\\"_id\\",\\"_index\\" +\\"Jan 1, 2015 @ 12:10:30.123456789\\",\\"Hello 2\\",2,nanos +\\"Jan 1, 2015 @ 12:10:30.000000000\\",\\"Hello 1\\",1,nanos +" +`; + +exports[`Reporting APIs CSV Generation from SearchSource non-timebased With filters and non-timebased data 1`] = ` +"name,power +\\"Jonelle-Jane Marth\\",1 +\\"Suzie-May Rishel\\",1 +\\"Suzie-May Rishel\\",2 +\\"Rosana Casto\\",2 +\\"Stephen Cortez\\",4 +\\"Jonelle-Jane Marth\\",6 +\\"Jonelle-Jane Marth\\",7 +\\"Florinda Alejandro\\",10 +\\"Jonelle-Jane Marth\\",14 +\\"Suzie-May Rishel\\",19 +\\"Suzie-May Rishel\\",20 +\\"Florinda Alejandro\\",22 +" +`; + +exports[`Reporting APIs CSV Generation from SearchSource validation Searches large amount of data, stops at Max Size Reached 1`] = ` +"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"products.created_on\\",sku +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,19,716724,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0687606876, ZO0290502905, ZO0126701267, ZO0308503085\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes, Women's Clothing\\",EUR,45,591503,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0006400064, ZO0150601506\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0638206382, ZO0038800388\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0297602976, ZO0565605656\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0561405614, ZO0281602816\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,41,591636,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0385003850, ZO0408604086\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0505605056, ZO0513605136\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0276702767, ZO0291702917\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0046600466, ZO0050800508\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing, Men's Shoes\\",EUR,48,590970,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0455604556, ZO0680806808\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,46,591299,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0229002290, ZO0674406744\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0529905299, ZO0617006170\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,13,591175,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0299402994, ZO0433504335\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,21,591297,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0257502575, ZO0451704517\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,14,591149,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0584905849, ZO0578405784\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,27,591754,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0335803358, ZO0325903259\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Shoes\\",EUR,42,591803,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0645906459, ZO0324303243\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,46,592082,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0034400344, ZO0492904929\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes, Women's Accessories\\",EUR,27,591283,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0239302393, ZO0198501985\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing, Men's Shoes\\",EUR,4,591148,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0290302903, ZO0513705137\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Accessories, Men's Clothing\\",EUR,51,591417,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0464504645, ZO0621006210\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing, Men's Shoes\\",EUR,14,591562,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0544305443, ZO0108001080\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing, Women's Accessories\\",EUR,5,590996,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0638106381, ZO0096900969\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes\\",EUR,27,591317,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0366203662, ZO0139501395\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,38,591362,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0541805418, ZO0594105941\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes, Men's Clothing\\",EUR,30,591411,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0693506935, ZO0532405324\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing, Men's Shoes\\",EUR,38,722629,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0424204242, ZO0403504035, ZO0506705067, ZO0395603956\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,16,591041,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0418704187, ZO0557105571\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,6,591074,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0268602686, ZO0484704847\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,7,591349,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0474804748, ZO0560705607\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Accessories, Women's Clothing\\",EUR,44,591374,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0206002060, ZO0268302683\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591230,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0226902269, ZO0660106601\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes, Women's Clothing\\",EUR,17,591717,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0248002480, ZO0646706467\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Shoes\\",EUR,42,591768,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0005800058, ZO0133901339\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,21,591810,5,\\"Dec 31, 2016 @ 00:00:00.000, Dec 31, 2016 @ 00:00:00.000\\",\\"ZO0587405874, ZO0590305903\\" +" +`; diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/csv_saved_search.ts b/x-pack/test/reporting_api_integration/reporting_and_security/csv_saved_search.ts deleted file mode 100644 index bb70924f67b75..0000000000000 --- a/x-pack/test/reporting_api_integration/reporting_and_security/csv_saved_search.ts +++ /dev/null @@ -1,411 +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 supertest from 'supertest'; -import * as fixtures from '../fixtures'; -import { FtrProviderContext } from '../ftr_provider_context'; - -interface GenerateOpts { - timerange?: { - timezone: string; - min?: number | string | Date; - max?: number | string | Date; - }; - state: any; -} - -// eslint-disable-next-line import/no-default-export -export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const supertestSvc = getService('supertest'); - const reportingAPI = getService('reportingAPI'); - - const generateAPI = { - getCsvFromSavedSearch: async ( - id: string, - { timerange, state }: GenerateOpts, - isImmediate = true - ) => { - return await supertestSvc - .post(`/api/reporting/v1/generate/${isImmediate ? 'immediate/' : ''}csv/saved-object/${id}`) - .set('kbn-xsrf', 'xxx') - .send({ timerange, state }); - }, - }; - - describe('Generation from Saved Search ID', () => { - after(async () => { - await reportingAPI.deleteAllReports(); - }); - - describe('Saved Search Features', () => { - it('With filters and timebased data, explicit UTC format', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/logs'); - await esArchiver.load('logstash_functional'); - - const res = (await generateAPI.getCsvFromSavedSearch( - 'search:d7a79750-3edd-11e9-99cc-4d80163ee9e7', - { - timerange: { - timezone: 'UTC', - min: '2015-09-19T10:00:00.000Z', - max: '2015-09-21T10:00:00.000Z', - }, - state: {}, - } - )) as supertest.Response; - const { status: resStatus, text: resText, type: resType } = res; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_TIMEBASED_UTC); - - await esArchiver.unload('reporting/logs'); - await esArchiver.unload('logstash_functional'); - }); - - it('With filters and timebased data, default to UTC', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/logs'); - await esArchiver.load('logstash_functional'); - - const res = (await generateAPI.getCsvFromSavedSearch( - 'search:d7a79750-3edd-11e9-99cc-4d80163ee9e7', - { - // @ts-expect-error: timerange.timezone is missing from post params - timerange: { - min: '2015-09-19T10:00:00.000Z', - max: '2015-09-21T10:00:00.000Z', - }, - state: {}, - } - )) as supertest.Response; - const { status: resStatus, text: resText, type: resType } = res; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_TIMEBASED_UTC); - - await esArchiver.unload('reporting/logs'); - await esArchiver.unload('logstash_functional'); - }); - - it('With filters and timebased data, custom timezone', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/logs'); - await esArchiver.load('logstash_functional'); - - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - 'search:d7a79750-3edd-11e9-99cc-4d80163ee9e7', - { - timerange: { - timezone: 'America/Phoenix', - min: '2015-09-19T10:00:00.000Z', - max: '2015-09-21T10:00:00.000Z', - }, - state: {}, - } - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_TIMEBASED_CUSTOM); - - await esArchiver.unload('reporting/logs'); - await esArchiver.unload('logstash_functional'); - }); - - it('With filters and non-timebased data', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/sales'); - - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - 'search:71e3ee20-3f99-11e9-b8ee-6b9604f2f877', - { - state: {}, - } - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_TIMELESS); - - await esArchiver.unload('reporting/sales'); - }); - - it('With scripted fields and field formatters', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/scripted_small2'); - - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - 'search:a6d51430-ace2-11ea-815f-39e12f89a8c2', - { - timerange: { - timezone: 'UTC', - min: '1979-01-01T10:00:00Z', - max: '1981-01-01T10:00:00Z', - }, - state: {}, - } - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_SCRIPTED); - - await esArchiver.unload('reporting/scripted_small2'); - }); - - it('Formatted date_nanos data, UTC timezone', async () => { - await esArchiver.load('reporting/nanos'); - - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - 'search:e4035040-a295-11e9-a900-ef10e0ac769e', - { - state: {}, - } - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_NANOS); - - await esArchiver.unload('reporting/nanos'); - }); - - it('Formatted date_nanos data, custom time zone', async () => { - await esArchiver.load('reporting/nanos'); - - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - 'search:e4035040-a295-11e9-a900-ef10e0ac769e', - { - state: {}, - timerange: { timezone: 'America/New_York' }, - } - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_NANOS_CUSTOM); - - await esArchiver.unload('reporting/nanos'); - }); - }); - - describe('API Features', () => { - it('Return a 404', async () => { - const { body } = (await generateAPI.getCsvFromSavedSearch('search:gobbledygook', { - timerange: { timezone: 'UTC', min: 63097200000, max: 126255599999 }, - state: {}, - })) as supertest.Response; - const expectedBody = { - error: 'Not Found', - message: 'Saved object [search/gobbledygook] not found', - statusCode: 404, - }; - expect(body).to.eql(expectedBody); - }); - - it('Return 400 if time range param is needed but missing', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/logs'); - await esArchiver.load('logstash_functional'); - - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - 'search:d7a79750-3edd-11e9-99cc-4d80163ee9e7', - { state: {} } - )) as supertest.Response; - - expect(resStatus).to.eql(400); - expect(resType).to.eql('application/json'); - const { message: errorMessage } = JSON.parse(resText); - expect(errorMessage).to.eql( - 'Time range params are required for index pattern [logstash-*], using time field [@timestamp]' - ); - - await esArchiver.unload('reporting/logs'); - await esArchiver.unload('logstash_functional'); - }); - - it('Stops at Max Size Reached', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/hugedata'); - - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', - { - timerange: { - timezone: 'UTC', - min: '1960-01-01T10:00:00Z', - max: '1999-01-01T10:00:00Z', - }, - state: {}, - } - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_HUGE); - - await esArchiver.unload('reporting/hugedata'); - }); - }); - - describe('Merge user state into the query', () => { - it('for query', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/scripted_small2'); - - const params = { - searchId: 'search:a6d51430-ace2-11ea-815f-39e12f89a8c2', - postPayload: { - timerange: { timezone: 'UTC', min: '1979-01-01T10:00:00Z', max: '1981-01-01T10:00:00Z' }, // prettier-ignore - state: { query: { bool: { filter: [ { bool: { filter: [ { bool: { minimum_should_match: 1, should: [{ query_string: { fields: ['name'], query: 'Fel*' } }] } } ] } } ] } } }, // prettier-ignore - }, - isImmediate: true, - }; - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - params.searchId, - params.postPayload, - params.isImmediate - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_SCRIPTED_REQUERY); - - await esArchiver.unload('reporting/scripted_small2'); - }); - - it('for sort', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/hugedata'); - - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', - { - timerange: { - timezone: 'UTC', - min: '1979-01-01T10:00:00Z', - max: '1981-01-01T10:00:00Z', - }, - state: { sort: [{ name: { order: 'asc', unmapped_type: 'boolean' } }] }, - } - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_SCRIPTED_RESORTED); - - await esArchiver.unload('reporting/hugedata'); - }); - - it('for docvalue_fields', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/ecommerce'); - await esArchiver.load('reporting/ecommerce_kibana'); - - const params = { - searchId: 'search:6091ead0-1c6d-11ea-a100-8589bb9d7c6b', - postPayload: { - timerange: { - min: '2019-05-28T00:00:00Z', - max: '2019-06-26T00:00:00Z', - timezone: 'UTC', - }, - state: { - sort: [ - { order_date: { order: 'desc', unmapped_type: 'boolean' } }, - { order_id: { order: 'asc', unmapped_type: 'boolean' } }, - ], - docvalue_fields: [ - { field: 'customer_birth_date', format: 'date_time' }, - { field: 'order_date', format: 'date_time' }, - { field: 'products.created_on', format: 'date_time' }, - ], - query: { - bool: { - must: [], - filter: [ - { match_all: {} }, - { match_all: {} }, - { - range: { - order_date: { - gte: '2019-05-28T00:00:00.000Z', - lte: '2019-06-26T00:00:00.000Z', - format: 'strict_date_optional_time', - }, - }, - }, - ], - should: [], - must_not: [], - }, - }, - }, - }, - isImmediate: true, - }; - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - params.searchId, - params.postPayload, - params.isImmediate - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('text/csv'); - expect(resText).to.eql(fixtures.CSV_RESULT_DOCVALUE); - - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); - }); - }); - }); -} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts b/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts new file mode 100644 index 0000000000000..27c6a05f740bf --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts @@ -0,0 +1,512 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 supertest from 'supertest'; +import { JobParamsDownloadCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource_immediate/types'; +import { FtrProviderContext } from '../ftr_provider_context'; + +const getMockJobParams = (obj: Partial): JobParamsDownloadCSV => ({ + title: `Mock CSV Title`, + ...(obj as any), +}); + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); + const supertestSvc = getService('supertest'); + const reportingAPI = getService('reportingAPI'); + + const generateAPI = { + getCSVFromSearchSource: async (job: JobParamsDownloadCSV) => { + return await supertestSvc + .post(`/api/reporting/v1/generate/immediate/csv_searchsource`) + .set('kbn-xsrf', 'xxx') + .send(job); + }, + }; + + describe('CSV Generation from SearchSource', () => { + before(async () => { + await kibanaServer.uiSettings.update({ + 'csv:quoteValues': false, + 'dateFormat:tz': 'UTC', + defaultIndex: 'logstash-*', + }); + }); + after(async () => { + await reportingAPI.deleteAllReports(); + }); + + it('Exports CSV with almost all fields when using fieldsFromSource', async () => { + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + searchSource: { + query: { query: '', language: 'kuery' }, + index: '5193f870-d861-11e9-a311-0fa548c5f953', + sort: [{ order_date: 'desc' }], + fieldsFromSource: [ + '_id', + '_index', + '_score', + '_source', + '_type', + 'category', + 'category.keyword', + 'currency', + 'customer_birth_date', + 'customer_first_name', + 'customer_first_name.keyword', + 'customer_full_name', + 'customer_full_name.keyword', + 'customer_gender', + 'customer_id', + 'customer_last_name', + 'customer_last_name.keyword', + 'customer_phone', + 'day_of_week', + 'day_of_week_i', + 'email', + 'geoip.city_name', + 'geoip.continent_name', + 'geoip.country_iso_code', + 'geoip.location', + 'geoip.region_name', + 'manufacturer', + 'manufacturer.keyword', + 'order_date', + 'order_id', + 'products._id', + 'products._id.keyword', + 'products.base_price', + 'products.base_unit_price', + 'products.category', + 'products.category.keyword', + 'products.created_on', + 'products.discount_amount', + 'products.discount_percentage', + 'products.manufacturer', + 'products.manufacturer.keyword', + 'products.min_price', + 'products.price', + 'products.product_id', + 'products.product_name', + 'products.product_name.keyword', + 'products.quantity', + 'products.sku', + 'products.tax_amount', + 'products.taxful_price', + 'products.taxless_price', + 'products.unit_discount_amount', + 'sku', + 'taxful_total_price', + 'taxless_total_price', + 'total_quantity', + 'total_unique_products', + 'type', + 'user', + ], + filter: [], + parent: { + query: { language: 'kuery', query: '' }, + filter: [], + parent: { + filter: [ + { + meta: { index: '5193f870-d861-11e9-a311-0fa548c5f953', params: {} }, + range: { + order_date: { + gte: '2019-03-23T03:06:17.785Z', + lte: '2019-10-04T02:33:16.708Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + }, + browserTimezone: 'UTC', + title: 'testfooyu78yt90-', + }) + )) as supertest.Response; + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expectSnapshot(resText).toMatch(); + + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + }); + + it('Exports CSV with all fields when using defaults', async () => { + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + searchSource: { + query: { query: '', language: 'kuery' }, + index: '5193f870-d861-11e9-a311-0fa548c5f953', + sort: [{ order_date: 'desc' }], + fields: ['*'], + filter: [], + parent: { + query: { language: 'kuery', query: '' }, + filter: [], + parent: { + filter: [ + { + meta: { index: '5193f870-d861-11e9-a311-0fa548c5f953', params: {} }, + range: { + order_date: { + gte: '2019-03-23T03:06:17.785Z', + lte: '2019-10-04T02:33:16.708Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + }, + browserTimezone: 'UTC', + title: 'testfooyu78yt90-', + }) + )) as supertest.Response; + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expectSnapshot(resText).toMatch(); + + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + }); + + it('Logs the error explanation if the search query returns an error', async () => { + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana'); + + const { status: resStatus, text: resText } = (await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + searchSource: { + query: { query: '', language: 'kuery' }, + index: '5193f870-d861-11e9-a311-0fa548c5f953', + sort: [{ order_date: 'desc' }], + fields: ['order_date', 'products'], // products is a non-leaf field + filter: [], + parent: { + query: { language: 'kuery', query: '' }, + filter: [], + parent: { + filter: [ + { + meta: { index: '5193f870-d861-11e9-a311-0fa548c5f953', params: {} }, + range: { + order_date: { + gte: '2019-03-23T03:06:17.785Z', + lte: '2019-10-04T02:33:16.708Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + }, + browserTimezone: 'UTC', + title: 'testfooyu78yt90-', + }) + )) as supertest.Response; + expect(resStatus).to.eql(500); + expectSnapshot(resText).toMatch(); + + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + }); + + describe('date formatting', () => { + before(async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/logs'); + await esArchiver.load('logstash_functional'); + }); + after(async () => { + await esArchiver.unload('reporting/logs'); + await esArchiver.unload('logstash_functional'); + }); + + it('With filters and timebased data, default to UTC', async () => { + const res = (await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + searchSource: { + fields: ['@timestamp', 'clientip', 'extension'], + filter: [ + { + range: { + '@timestamp': { + gte: '2015-09-20T10:19:40.307Z', + lt: '2015-09-20T10:26:56.221Z', + }, + }, + }, + { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gte: '2015-01-12T07:00:55.654Z', + lte: '2016-01-29T21:08:10.881Z', + }, + }, + }, + ], + index: 'logstash-*', + query: { language: 'kuery', query: '' }, + sort: [{ '@timestamp': 'desc' }], + }, + }) + )) as supertest.Response; + const { status: resStatus, text: resText, type: resType } = res; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expectSnapshot(resText).toMatch(); + }); + + it('With filters and timebased data, non-default timezone', async () => { + const res = (await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + browserTimezone: 'America/Phoenix', + searchSource: { + fields: ['@timestamp', 'clientip', 'extension'], + filter: [ + { + range: { + '@timestamp': { + gte: '2015-09-20T10:19:40.307Z', + lt: '2015-09-20T10:26:56.221Z', + }, + }, + }, + { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gte: '2015-01-12T07:00:55.654Z', + lte: '2016-01-29T21:08:10.881Z', + }, + }, + }, + ], + index: 'logstash-*', + query: { language: 'kuery', query: '' }, + sort: [{ '@timestamp': 'desc' }], + }, + }) + )) as supertest.Response; + const { status: resStatus, text: resText, type: resType } = res; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expectSnapshot(resText).toMatch(); + }); + + it('Formatted date_nanos data, UTC timezone', async () => { + await esArchiver.load('reporting/nanos'); + + const res = await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + searchSource: { + query: { query: '', language: 'kuery' }, + version: true, + index: '907bc200-a294-11e9-a900-ef10e0ac769e', + sort: [{ date: 'desc' }], + fields: ['date', 'message'], + filter: [], + }, + }) + ); + const { status: resStatus, text: resText, type: resType } = res; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expectSnapshot(resText).toMatch(); + + await esArchiver.unload('reporting/nanos'); + }); + + it('Formatted date_nanos data, custom timezone (New York)', async () => { + await esArchiver.load('reporting/nanos'); + + const res = await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + browserTimezone: 'America/New_York', + searchSource: { + query: { query: '', language: 'kuery' }, + version: true, + index: '907bc200-a294-11e9-a900-ef10e0ac769e', + sort: [{ date: 'desc' }], + fields: ['date', 'message'], + filter: [], + }, + }) + ); + const { status: resStatus, text: resText, type: resType } = res; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expectSnapshot(resText).toMatch(); + + await esArchiver.unload('reporting/nanos'); + }); + }); + + describe('non-timebased', () => { + it('Handle _id and _index columns', async () => { + await esArchiver.load('reporting/nanos'); + + const res = await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + searchSource: { + query: { query: '', language: 'kuery' }, + version: true, + index: '907bc200-a294-11e9-a900-ef10e0ac769e', + sort: [{ date: 'desc' }], + fields: ['date', 'message', '_id', '_index'], + filter: [], + }, + }) + ); + const { status: resStatus, text: resText, type: resType } = res; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expectSnapshot(resText).toMatch(); + + await esArchiver.unload('reporting/nanos'); + }); + + it('With filters and non-timebased data', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/sales'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + searchSource: { + query: { query: '', language: 'kuery' }, + version: true, + index: 'timeless-sales', + sort: [{ power: 'asc' }], + fields: ['name', 'power'], + filter: [ + { + range: { power: { gte: 1, lt: null } }, + }, + ], + }, + }) + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expectSnapshot(resText).toMatch(); + + await esArchiver.unload('reporting/sales'); + }); + }); + + describe('validation', () => { + it('Return a 404', async () => { + const { body } = (await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + searchSource: { + index: 'gobbledygook', + }, + }) + )) as supertest.Response; + const expectedBody = { + error: 'Not Found', + message: 'Saved object [index-pattern/gobbledygook] not found', + statusCode: 404, + }; + expect(body).to.eql(expectedBody); + }); + + it(`Searches large amount of data, stops at Max Size Reached`, async () => { + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCSVFromSearchSource( + getMockJobParams({ + searchSource: { + version: true, + query: { query: '', language: 'kuery' }, + index: '5193f870-d861-11e9-a311-0fa548c5f953', + sort: [{ order_date: 'desc' }], + fields: [ + 'order_date', + 'category', + 'currency', + 'customer_id', + 'order_id', + 'day_of_week_i', + 'products.created_on', + 'sku', + ], + filter: [], + parent: { + query: { language: 'kuery', query: '' }, + filter: [], + parent: { + filter: [ + { + meta: { index: '5193f870-d861-11e9-a311-0fa548c5f953', params: {} }, + range: { + order_date: { + gte: '2019-03-23T03:06:17.785Z', + lte: '2019-10-04T02:33:16.708Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + }, + browserTimezone: 'UTC', + title: 'Ecommerce Data', + }) + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expectSnapshot(resText).toMatch(); + + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + }); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts index 2981ff81d66eb..b4e05e37d3fda 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts @@ -12,7 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('Reporting APIs', function () { this.tags('ciGroup2'); loadTestFile(require.resolve('./csv_job_params')); - loadTestFile(require.resolve('./csv_saved_search')); + loadTestFile(require.resolve('./csv_searchsource_immediate')); loadTestFile(require.resolve('./network_policy')); loadTestFile(require.resolve('./spaces')); loadTestFile(require.resolve('./usage'));