From 846647436e829c34e4f67edb9c4ce2df4abe7aab Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 1 Sep 2020 10:33:28 -0700 Subject: [PATCH 01/53] [yarn] remove typings-tester, use @ts-expect-error (#76341) Co-authored-by: spalger --- package.json | 2 - .../public/lib/aeroelastic/tsconfig.json | 21 --- .../typespec_tests.ts => typespec.test.ts} | 172 +++++++++--------- .../scripts/optimize_tsconfig/tsconfig.json | 3 +- x-pack/tsconfig.json | 3 +- yarn.lock | 9 +- 6 files changed, 94 insertions(+), 116 deletions(-) delete mode 100644 x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json rename x-pack/plugins/canvas/public/lib/aeroelastic/{__fixtures__/typescript/typespec_tests.ts => typespec.test.ts} (67%) diff --git a/package.json b/package.json index 5c95f538a00e1..3485ce5d7a7fc 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "test:ftr:server": "node scripts/functional_tests_server", "test:ftr:runner": "node scripts/functional_test_runner", "test:coverage": "grunt test:coverage", - "typespec": "typings-tester --config x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json x-pack/plugins/canvas/public/lib/aeroelastic/__fixtures__/typescript/typespec_tests.ts", "checkLicenses": "node scripts/check_licenses --dev", "build": "node scripts/build --all-platforms", "start": "node scripts/kibana --dev", @@ -473,7 +472,6 @@ "topojson-client": "3.0.0", "tree-kill": "^1.2.2", "typescript": "4.0.2", - "typings-tester": "^0.3.2", "ui-select": "0.19.8", "vega": "^5.13.0", "vega-lite": "^4.13.1", diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json b/x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json deleted file mode 100644 index 3b61e4b414626..0000000000000 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "../../../../../tsconfig", - "compilerOptions": { - "module": "commonjs", - "lib": ["es2018", "dom"], - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": false, - "strictPropertyInitialization": true, - "noImplicitThis": true, - "noImplicitReturns": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "baseUrl": ".", - "paths": { - "layout/*": ["aeroelastic/*"] - }, - "types": ["@kbn/x-pack/plugins/canvas/public/lib/aeroelastic"] - }, - "exclude": ["node_modules", "**/*.spec.ts", "node_modules/@types/mocha"] -} diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/__fixtures__/typescript/typespec_tests.ts b/x-pack/plugins/canvas/public/lib/aeroelastic/typespec.test.ts similarity index 67% rename from x-pack/plugins/canvas/public/lib/aeroelastic/__fixtures__/typescript/typespec_tests.ts rename to x-pack/plugins/canvas/public/lib/aeroelastic/typespec.test.ts index cb46a3d6be402..3b1fde12edcc4 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/__fixtures__/typescript/typespec_tests.ts +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/typespec.test.ts @@ -4,49 +4,47 @@ * you may not use this file except in compliance with the Elastic License. */ -import { select } from '../../select'; -import { Json, Selector, Vector2d, Vector3d, TransformMatrix2d, TransformMatrix3d } from '../..'; +import { select } from './select'; +import { Json, Selector, Vector2d, Vector3d, TransformMatrix2d, TransformMatrix3d } from './index'; import { mvMultiply as mult2d, ORIGIN as UNIT2D, UNITMATRIX as UNITMATRIX2D, add as add2d, -} from '../../matrix2d'; +} from './matrix2d'; import { mvMultiply as mult3d, ORIGIN as UNIT3D, NANMATRIX as NANMATRIX3D, add as add3d, -} from '../../matrix'; +} from './matrix'; -/* +// helper to mark variables as "used" so they don't trigger errors +const use = (...vars: any[]) => vars.includes(null); +/* Type checking isn't too useful if future commits can accidentally weaken the type constraints, because a TypeScript linter will not complain - everything that passed before will continue to pass. The coder will not have feedback that the original intent with the typing got compromised. To declare the intent via passing and failing type checks, test cases are needed, some of which designed to expect a TS pass, some of them to expect a TS complaint. It documents intent for peers too, as type specs are a tough read. - Run compile-time type specification tests in the `kibana` root with: - - yarn typespec - Test "cases" expecting to pass TS checks are not annotated, while ones we want TS to complain about are prepended with the comment - - // typings:expect-error + + // @ts-expect-error The test "suite" and "cases" are wrapped in IIFEs to prevent linters from complaining about the unused binding. It can be structured internally as desired. - */ -((): void => { - /** - * TYPE TEST SUITE - */ +describe('vector array creation', () => { + it('passes typechecking', () => { + let vec2d: Vector2d = UNIT2D; + let vec3d: Vector3d = UNIT3D; + + use(vec2d, vec3d); - (function vectorArrayCreationTests(vec2d: Vector2d, vec3d: Vector3d): void { // 2D vector OK vec2d = [0, 0, 0] as Vector2d; // OK vec2d = [-0, NaN, -Infinity] as Vector2d; // IEEE 754 values are OK @@ -57,30 +55,35 @@ import { // 2D vector not OK - // typings:expect-error + // @ts-expect-error vec2d = 3; // not even an array - // typings:expect-error + // @ts-expect-error vec2d = [] as Vector2d; // no elements - // typings:expect-error + // @ts-expect-error vec2d = [0, 0] as Vector2d; // too few elements - // typings:expect-error + // @ts-expect-error vec2d = [0, 0, 0, 0] as Vector2d; // too many elements // 3D vector not OK - // typings:expect-error + // @ts-expect-error vec3d = 3; // not even an array - // typings:expect-error + // @ts-expect-error vec3d = [] as Vector3d; // no elements - // typings:expect-error + // @ts-expect-error vec3d = [0, 0, 0] as Vector3d; // too few elements - // typings:expect-error + // @ts-expect-error vec3d = [0, 0, 0, 0, 0] as Vector3d; // too many elements + }); +}); + +describe('matrix array creation', () => { + it('passes typechecking', () => { + let mat2d: TransformMatrix2d = UNITMATRIX2D; + let mat3d: TransformMatrix3d = NANMATRIX3D; - return; // arrayCreationTests - })(UNIT2D, UNIT3D); + use(mat2d, mat3d); - (function matrixArrayCreationTests(mat2d: TransformMatrix2d, mat3d: TransformMatrix3d): void { // 2D matrix OK mat2d = [0, 1, 2, 3, 4, 5, 6, 7, 8] as TransformMatrix2d; // OK mat2d = [-0, NaN, -Infinity, 3, 4, 5, 6, 7, 8] as TransformMatrix2d; // IEEE 754 values are OK @@ -91,80 +94,87 @@ import { // 2D matrix not OK - // typings:expect-error + // @ts-expect-error mat2d = 3; // not even an array - // typings:expect-error + // @ts-expect-error mat2d = [] as TransformMatrix2d; // no elements - // typings:expect-error + // @ts-expect-error mat2d = [0, 1, 2, 3, 4, 5, 6, 7] as TransformMatrix2d; // too few elements - // typings:expect-error + // @ts-expect-error mat2d = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as TransformMatrix2d; // too many elements // 3D vector not OK - // typings:expect-error + // @ts-expect-error mat3d = 3; // not even an array - // typings:expect-error + // @ts-expect-error mat3d = [] as TransformMatrix3d; // no elements - // typings:expect-error + // @ts-expect-error mat3d = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] as TransformMatrix3d; // too few elements - // typings:expect-error + // @ts-expect-error mat3d = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] as TransformMatrix3d; // too many elements - // Matrix modification should NOT be OK - mat3d[3] = 100; // too bad the ReadOnly part appears not to be enforced so can't precede it with typings:expect-error + // @ts-expect-error + mat3d[3] = 100; // Matrix modification is NOT OK + }); +}); - return; // arrayCreationTests - })(UNITMATRIX2D, NANMATRIX3D); +describe('matrix addition', () => { + it('passes typecheck', () => { + const mat2d: TransformMatrix2d = UNITMATRIX2D; + const mat3d: TransformMatrix3d = NANMATRIX3D; - (function matrixMatrixAdditionTests(mat2d: TransformMatrix2d, mat3d: TransformMatrix3d): void { add2d(mat2d, mat2d); // OK add3d(mat3d, mat3d); // OK - // typings:expect-error + // @ts-expect-error add2d(mat2d, mat3d); // at least one arg doesn't comply - // typings:expect-error + // @ts-expect-error add2d(mat3d, mat2d); // at least one arg doesn't comply - // typings:expect-error + // @ts-expect-error add2d(mat3d, mat3d); // at least one arg doesn't comply - // typings:expect-error + // @ts-expect-error add3d(mat2d, mat3d); // at least one arg doesn't comply - // typings:expect-error + // @ts-expect-error add3d(mat3d, mat2d); // at least one arg doesn't comply - // typings:expect-error + // @ts-expect-error add3d(mat2d, mat2d); // at least one arg doesn't comply + }); +}); - return; // matrixMatrixAdditionTests - })(UNITMATRIX2D, NANMATRIX3D); +describe('matric vector multiplication', () => { + it('passes typecheck', () => { + const vec2d: Vector2d = UNIT2D; + const mat2d: TransformMatrix2d = UNITMATRIX2D; + const vec3d: Vector3d = UNIT3D; + const mat3d: TransformMatrix3d = NANMATRIX3D; - (function matrixVectorMultiplicationTests( - vec2d: Vector2d, - mat2d: TransformMatrix2d, - vec3d: Vector3d, - mat3d: TransformMatrix3d - ): void { mult2d(mat2d, vec2d); // OK mult3d(mat3d, vec3d); // OK - // typings:expect-error + // @ts-expect-error mult3d(mat2d, vec2d); // trying to use a 3d fun for 2d args - // typings:expect-error + // @ts-expect-error mult2d(mat3d, vec3d); // trying to use a 2d fun for 3d args - // typings:expect-error + // @ts-expect-error mult2d(mat3d, vec2d); // 1st arg is a mismatch - // typings:expect-error + // @ts-expect-error mult2d(mat2d, vec3d); // 2nd arg is a mismatch - // typings:expect-error + // @ts-expect-error mult3d(mat2d, vec3d); // 1st arg is a mismatch - // typings:expect-error + // @ts-expect-error mult3d(mat3d, vec2d); // 2nd arg is a mismatch + }); +}); - return; // matrixVectorTests - })(UNIT2D, UNITMATRIX2D, UNIT3D, NANMATRIX3D); +describe('json', () => { + it('passes typecheck', () => { + let plain: Json = null; + + use(plain); - (function jsonTests(plain: Json): void { // numbers are OK plain = 1; plain = NaN; @@ -182,37 +192,37 @@ import { plain = [0, null, false, NaN, 3.14, 'one more']; plain = { a: { b: 5, c: { d: [1, 'a', -Infinity, null], e: -1 }, f: 'b' }, g: false }; - // typings:expect-error + // @ts-expect-error plain = undefined; // it's undefined - // typings:expect-error + // @ts-expect-error plain = (a) => a; // it's a function - // typings:expect-error + // @ts-expect-error plain = [new Date()]; // it's a time - // typings:expect-error + // @ts-expect-error plain = { a: Symbol('haha') }; // symbol isn't permitted either - // typings:expect-error + // @ts-expect-error plain = window || void 0; - // typings:expect-error + // @ts-expect-error plain = { a: { b: 5, c: { d: [1, 'a', undefined, null] } } }; // going deep into the structure + }); +}); - return; // jsonTests - })(null); +describe('select', () => { + it('passes typecheck', () => { + let selector: Selector; - (function selectTests(selector: Selector): void { selector = select((a: Json) => a); // one arg selector = select((a: Json, b: Json): Json => `${a} and ${b}`); // more args selector = select(() => 1); // zero arg selector = select((...args: Json[]) => args); // variadic - // typings:expect-error + // @ts-expect-error selector = (a: Json) => a; // not a selector - // typings:expect-error + // @ts-expect-error selector = select(() => {}); // should yield a JSON value, but it returns void - // typings:expect-error + // @ts-expect-error selector = select((x: Json) => ({ a: x, b: undefined })); // should return a Json - return; // selectTests - })(select((a: Json) => a)); - - return; // test suite -})(); + use(selector); + }); +}); diff --git a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json b/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json index ea7a11b89dab2..ac56a6af31c72 100644 --- a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json +++ b/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json @@ -10,7 +10,6 @@ "exclude": [ "test/**/*", "**/__fixtures__/**/*", - "plugins/security_solution/cypress/**/*", - "**/typespec_tests.ts" + "plugins/security_solution/cypress/**/*" ] } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 35e1800c6fbd1..7c6210bb9ce19 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -14,8 +14,7 @@ "test/**/*", "plugins/security_solution/cypress/**/*", "plugins/apm/e2e/cypress/**/*", - "plugins/apm/scripts/**/*", - "**/typespec_tests.ts" + "plugins/apm/scripts/**/*" ], "compilerOptions": { "outDir": ".", diff --git a/yarn.lock b/yarn.lock index c0c2305609f58..3be2599c64201 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9035,7 +9035,7 @@ comma-separated-tokens@^1.0.1: resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== -commander@2, commander@2.19.0, commander@^2.11.0, commander@^2.12.2: +commander@2, commander@2.19.0, commander@^2.11.0: version "2.19.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg== @@ -28423,13 +28423,6 @@ typescript@4.0.2, typescript@^3.0.1, typescript@^3.0.3, typescript@^3.2.2, types resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2" integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ== -typings-tester@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/typings-tester/-/typings-tester-0.3.2.tgz#04cc499d15ab1d8b2d14dd48415a13d01333bc5b" - integrity sha512-HjGoAM2UoGhmSKKy23TYEKkxlphdJFdix5VvqWFLzH1BJVnnwG38tpC6SXPgqhfFGfHY77RlN1K8ts0dbWBQ7A== - dependencies: - commander "^2.12.2" - ua-parser-js@^0.7.18: version "0.7.21" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777" From ff7e7effeda65065339f737ac845ad89323a11c6 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 1 Sep 2020 10:42:28 -0700 Subject: [PATCH 02/53] [ML] Remove "Are you sure" from data frame analytics jobs (#76214) --- .../components/action_delete/delete_action_modal.tsx | 10 +--------- .../components/action_start/start_action_modal.tsx | 4 ++-- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx index 5ffa5e304b996..5db8446dec32f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx @@ -14,7 +14,6 @@ import { EuiFlexItem, EUI_MODAL_CONFIRM_BUTTON, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { DeleteAction } from './use_delete_action'; @@ -40,7 +39,7 @@ export const DeleteActionModal: FC = ({ = ({ defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} buttonColor="danger" > -

- -

- {userCanDeleteIndex && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx index fa559e807f5ea..2048d1144952d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx @@ -17,7 +17,7 @@ export const StartActionModal: FC = ({ closeModal, item, startAndCl = ({ closeModal, item, startAndCl

{i18n.translate('xpack.ml.dataframe.analyticsList.startModalBody', { defaultMessage: - 'A data frame analytics job will increase search and indexing load in your cluster. Please stop the analytics job if excessive load is experienced. Are you sure you want to start this analytics job?', + 'A data frame analytics job increases search and indexing load in your cluster. If excessive load occurs, stop the job.', })}

diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b70ab0bb25561..df78975d21b07 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11113,7 +11113,6 @@ "xpack.ml.dataframe.analyticsList.deleteAnalyticsWithIndexPatternSuccessMessage": "インデックスパターン{destinationIndex}を削除する要求が確認されました。", "xpack.ml.dataframe.analyticsList.deleteAnalyticsWithIndexSuccessMessage": "ディスティネーションインデックス{destinationIndex}を削除する要求が確認されました。", "xpack.ml.dataframe.analyticsList.deleteDestinationIndexTitle": "ディスティネーションインデックス{indexName}を削除", - "xpack.ml.dataframe.analyticsList.deleteModalBody": "この分析ジョブを削除してよろしいですか?", "xpack.ml.dataframe.analyticsList.deleteModalCancelButton": "キャンセル", "xpack.ml.dataframe.analyticsList.deleteModalDeleteButton": "削除", "xpack.ml.dataframe.analyticsList.deleteModalTitle": "{analyticsId}の削除", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 72010e00dc820..cfee565a1da93 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11116,7 +11116,6 @@ "xpack.ml.dataframe.analyticsList.deleteAnalyticsWithIndexPatternSuccessMessage": "删除索引模式 {destinationIndex} 的请求已确认。", "xpack.ml.dataframe.analyticsList.deleteAnalyticsWithIndexSuccessMessage": "删除目标索引 {destinationIndex} 的请求已确认。", "xpack.ml.dataframe.analyticsList.deleteDestinationIndexTitle": "删除目标索引 {indexName}", - "xpack.ml.dataframe.analyticsList.deleteModalBody": "是否确定要删除此分析作业?", "xpack.ml.dataframe.analyticsList.deleteModalCancelButton": "取消", "xpack.ml.dataframe.analyticsList.deleteModalDeleteButton": "删除", "xpack.ml.dataframe.analyticsList.deleteModalTitle": "删除 {analyticsId}", From 981691d378cf6bce738d8d30f568f2f407468e29 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 1 Sep 2020 19:42:41 +0200 Subject: [PATCH 03/53] Add setHeaderActionMenu API to AppMountParameters (#75422) * add `setHeaderActionMenu` to AppMountParameters * allow to remove the current menu by calling handler with undefined * update generated doc * updating snapshots * fix legacy tests * call renderApp with params * rename toMountPoint component file for consistency * add the MountPointPortal utility component * adapt TopNavMenu to add optional `setMenuMountPoint` prop * add kibanaReact as required bundle. * use innerHTML instead of textContent for portal tests * add error boundaries to portal component * improve renderLayout readability * duplicate wrapper in portal mode to avoid altering styles Co-authored-by: Elastic Machine --- ...a-plugin-core-public.appmountparameters.md | 1 + ....appmountparameters.setheaderactionmenu.md | 39 ++++ .../application_service.test.ts.snap | 1 + .../application/application_service.mock.ts | 2 + .../application/application_service.tsx | 42 +++- .../application_service.test.tsx | 187 ++++++++++++++++ .../integration_tests/router.test.tsx | 1 + src/core/public/application/types.ts | 40 ++++ .../application/ui/app_container.test.tsx | 4 + .../public/application/ui/app_container.tsx | 17 +- src/core/public/application/ui/app_router.tsx | 7 +- .../header/__snapshots__/header.test.tsx.snap | 36 +++ src/core/public/mocks.ts | 1 + src/core/public/public.api.md | 1 + src/plugins/dev_tools/public/application.tsx | 1 + src/plugins/kibana_react/public/index.ts | 2 +- src/plugins/kibana_react/public/util/index.ts | 3 +- .../public/util/mount_point_portal.test.tsx | 210 ++++++++++++++++++ .../public/util/mount_point_portal.tsx | 88 ++++++++ .../{react_mount.tsx => to_mount_point.tsx} | 0 src/plugins/navigation/kibana.json | 5 +- .../public/top_nav_menu/top_nav_menu.test.tsx | 60 ++++- .../public/top_nav_menu/top_nav_menu.tsx | 53 ++++- x-pack/plugins/ml/public/plugin.ts | 7 +- .../account_management_app.test.ts | 1 + .../access_agreement_app.test.ts | 1 + .../capture_url/capture_url_app.test.ts | 1 + .../logged_out/logged_out_app.test.ts | 1 + .../authentication/login/login_app.test.ts | 1 + .../authentication/logout/logout_app.test.ts | 1 + .../overwritten_session_app.test.ts | 1 + 31 files changed, 785 insertions(+), 30 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md create mode 100644 src/plugins/kibana_react/public/util/mount_point_portal.test.tsx create mode 100644 src/plugins/kibana_react/public/util/mount_point_portal.tsx rename src/plugins/kibana_react/public/util/{react_mount.tsx => to_mount_point.tsx} (100%) diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md index de79fc8281c45..f6c57603bedde 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md @@ -19,4 +19,5 @@ export interface AppMountParameters | [element](./kibana-plugin-core-public.appmountparameters.element.md) | HTMLElement | The container element to render the application into. | | [history](./kibana-plugin-core-public.appmountparameters.history.md) | ScopedHistory<HistoryLocationState> | A scoped history instance for your application. Should be used to wire up your applications Router. | | [onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md) | (handler: AppLeaveHandler) => void | A function that can be used to register a handler that will be called when the user is leaving the current application, allowing to prompt a confirmation message before actually changing the page.This will be called either when the user goes to another application, or when trying to close the tab or manually changing the url. | +| [setHeaderActionMenu](./kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md) | (menuMount: MountPoint | undefined) => void | A function that can be used to set the mount point used to populate the application action container in the chrome header.Calling the handler multiple time will erase the current content of the action menu with the mount from the latest call. Calling the handler with undefined will unmount the current mount point. Calling the handler after the application has been unmounted will have no effect. | diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md new file mode 100644 index 0000000000000..ca9cee64bb1f9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md @@ -0,0 +1,39 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) > [setHeaderActionMenu](./kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md) + +## AppMountParameters.setHeaderActionMenu property + +A function that can be used to set the mount point used to populate the application action container in the chrome header. + +Calling the handler multiple time will erase the current content of the action menu with the mount from the latest call. Calling the handler with `undefined` will unmount the current mount point. Calling the handler after the application has been unmounted will have no effect. + +Signature: + +```typescript +setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; +``` + +## Example + + +```ts +// application.tsx +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter, Route } from 'react-router-dom'; + +import { CoreStart, AppMountParameters } from 'src/core/public'; +import { MyPluginDepsStart } from './plugin'; + +export renderApp = ({ element, history, setHeaderActionMenu }: AppMountParameters) => { + const { renderApp } = await import('./application'); + const { renderActionMenu } = await import('./action_menu'); + setHeaderActionMenu((element) => { + return renderActionMenu(element); + }) + return renderApp({ element, history }); +} + +``` + diff --git a/src/core/public/application/__snapshots__/application_service.test.ts.snap b/src/core/public/application/__snapshots__/application_service.test.ts.snap index c63a22170c4f6..a6c9eb27e338a 100644 --- a/src/core/public/application/__snapshots__/application_service.test.ts.snap +++ b/src/core/public/application/__snapshots__/application_service.test.ts.snap @@ -80,6 +80,7 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = ` } } mounters={Map {}} + setAppActionMenu={[Function]} setAppLeaveHandler={[Function]} setIsMounting={[Function]} /> diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index 47a8a01d917eb..2bdf56ee34211 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -20,6 +20,7 @@ import { History } from 'history'; import { BehaviorSubject, Subject } from 'rxjs'; +import type { MountPoint } from '../types'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { ApplicationSetup, @@ -87,6 +88,7 @@ const createInternalStartContractMock = (): jest.Mocked>(new Map()), capabilities: capabilitiesServiceMock.createStartContract().capabilities, currentAppId$: currentAppId$.asObservable(), + currentActionMenu$: new BehaviorSubject(undefined), getComponent: jest.fn(), getUrlForApp: jest.fn(), navigateToApp: jest.fn().mockImplementation((appId) => currentAppId$.next(appId)), diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index d7f15decb255d..df0f74c1914e9 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -22,6 +22,7 @@ import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { map, shareReplay, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators'; import { createBrowserHistory, History } from 'history'; +import { MountPoint } from '../types'; import { InjectedMetadataSetup } from '../injected_metadata'; import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; @@ -90,6 +91,11 @@ interface AppUpdaterWrapper { updater: AppUpdater; } +interface AppInternalState { + leaveHandler?: AppLeaveHandler; + actionMenu?: MountPoint; +} + /** * Service that is responsible for registering new applications. * @internal @@ -98,8 +104,9 @@ export class ApplicationService { private readonly apps = new Map | LegacyApp>(); private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); - private readonly appLeaveHandlers = new Map(); + private readonly appInternalStates = new Map(); private currentAppId$ = new BehaviorSubject(undefined); + private currentActionMenu$ = new BehaviorSubject(undefined); private readonly statusUpdaters$ = new BehaviorSubject>(new Map()); private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); @@ -293,12 +300,14 @@ export class ApplicationService { if (path === undefined) { path = applications$.value.get(appId)?.defaultPath; } - this.appLeaveHandlers.delete(this.currentAppId$.value!); + this.appInternalStates.delete(this.currentAppId$.value!); this.navigate!(getAppUrl(availableMounters, appId, path), state, replace); this.currentAppId$.next(appId); } }; + this.currentAppId$.subscribe(() => this.refreshCurrentActionMenu()); + return { applications$: applications$.pipe( map((apps) => new Map([...apps.entries()].map(([id, app]) => [id, getAppInfo(app)]))), @@ -310,6 +319,10 @@ export class ApplicationService { distinctUntilChanged(), takeUntil(this.stop$) ), + currentActionMenu$: this.currentActionMenu$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), history: this.history, registerMountContext: this.mountContext.registerContext, getUrlForApp: ( @@ -338,6 +351,7 @@ export class ApplicationService { mounters={availableMounters} appStatuses$={applicationStatuses$} setAppLeaveHandler={this.setAppLeaveHandler} + setAppActionMenu={this.setAppActionMenu} setIsMounting={(isMounting) => httpLoadingCount$.next(isMounting ? 1 : 0)} /> ); @@ -346,7 +360,24 @@ export class ApplicationService { } private setAppLeaveHandler = (appId: string, handler: AppLeaveHandler) => { - this.appLeaveHandlers.set(appId, handler); + this.appInternalStates.set(appId, { + ...(this.appInternalStates.get(appId) ?? {}), + leaveHandler: handler, + }); + }; + + private setAppActionMenu = (appId: string, mount: MountPoint | undefined) => { + this.appInternalStates.set(appId, { + ...(this.appInternalStates.get(appId) ?? {}), + actionMenu: mount, + }); + this.refreshCurrentActionMenu(); + }; + + private refreshCurrentActionMenu = () => { + const appId = this.currentAppId$.getValue(); + const currentActionMenu = appId ? this.appInternalStates.get(appId)?.actionMenu : undefined; + this.currentActionMenu$.next(currentActionMenu); }; private async shouldNavigate(overlays: OverlayStart): Promise { @@ -354,7 +385,7 @@ export class ApplicationService { if (currentAppId === undefined) { return true; } - const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId)); + const action = getLeaveAction(this.appInternalStates.get(currentAppId)?.leaveHandler); if (isConfirmAction(action)) { const confirmed = await overlays.openConfirm(action.text, { title: action.title, @@ -372,7 +403,7 @@ export class ApplicationService { if (currentAppId === undefined) { return; } - const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId)); + const action = getLeaveAction(this.appInternalStates.get(currentAppId)?.leaveHandler); if (isConfirmAction(action)) { event.preventDefault(); // some browsers accept a string return value being the message displayed @@ -383,6 +414,7 @@ export class ApplicationService { public stop() { this.stop$.next(); this.currentAppId$.complete(); + this.currentActionMenu$.complete(); this.statusUpdaters$.complete(); this.subscriptions.forEach((sub) => sub.unsubscribe()); window.removeEventListener('beforeunload', this.onBeforeUnload); diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index b0419d276dfa1..9eafddd6a61fe 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -30,6 +30,8 @@ import { MockLifecycle } from '../test_types'; import { overlayServiceMock } from '../../overlays/overlay_service.mock'; import { AppMountParameters } from '../types'; import { ScopedHistory } from '../scoped_history'; +import { Observable } from 'rxjs'; +import { MountPoint } from 'kibana/public'; const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); @@ -309,4 +311,189 @@ describe('ApplicationService', () => { expect(history.entries[1].pathname).toEqual('/app/app1'); }); }); + + describe('registering action menus', () => { + const getValue = (obs: Observable): Promise => { + return obs.pipe(take(1)).toPromise(); + }; + + const mounter1: MountPoint = () => () => undefined; + const mounter2: MountPoint = () => () => undefined; + + it('updates the observable value when an application is mounted', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + expect(await getValue(currentActionMenu$)).toBeUndefined(); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + }); + + it('updates the observable value when switching application', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + return () => undefined; + }, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter2); + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + await act(async () => { + await navigateToApp('app2'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter2); + }); + + it('updates the observable value to undefined when switching to an application without action menu', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + return () => undefined; + }, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: async ({}: AppMountParameters) => { + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + await act(async () => { + await navigateToApp('app2'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBeUndefined(); + }); + + it('allow applications to call `setHeaderActionMenu` multiple times', async () => { + const { register } = service.setup(setupDeps); + + let resolveMount: () => void; + const promise = new Promise((resolve) => { + resolveMount = resolve; + }); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + promise.then(() => { + setHeaderActionMenu(mounter2); + }); + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + await act(async () => { + resolveMount(); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter2); + }); + + it('allow applications to unset the current menu', async () => { + const { register } = service.setup(setupDeps); + + let resolveMount: () => void; + const promise = new Promise((resolve) => { + resolveMount = resolve; + }); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + promise.then(() => { + setHeaderActionMenu(undefined); + }); + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + await act(async () => { + resolveMount(); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBeUndefined(); + }); + }); }); diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index f992e121437a9..6408b8123365e 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -59,6 +59,7 @@ describe('AppRouter', () => { mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} setAppLeaveHandler={noop} + setAppActionMenu={noop} setIsMounting={noop} /> ); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 0fe97431b1569..320416a8c2379 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -21,6 +21,7 @@ import { Observable } from 'rxjs'; import { History } from 'history'; import { RecursiveReadonly } from '@kbn/utility-types'; +import { MountPoint } from '../types'; import { Capabilities } from './capabilities'; import { ChromeStart } from '../chrome'; import { IContextProvider } from '../context'; @@ -495,6 +496,37 @@ export interface AppMountParameters { * ``` */ onAppLeave: (handler: AppLeaveHandler) => void; + + /** + * A function that can be used to set the mount point used to populate the application action container + * in the chrome header. + * + * Calling the handler multiple time will erase the current content of the action menu with the mount from the latest call. + * Calling the handler with `undefined` will unmount the current mount point. + * Calling the handler after the application has been unmounted will have no effect. + * + * @example + * + * ```ts + * // application.tsx + * import React from 'react'; + * import ReactDOM from 'react-dom'; + * import { BrowserRouter, Route } from 'react-router-dom'; + * + * import { CoreStart, AppMountParameters } from 'src/core/public'; + * import { MyPluginDepsStart } from './plugin'; + * + * export renderApp = ({ element, history, setHeaderActionMenu }: AppMountParameters) => { + * const { renderApp } = await import('./application'); + * const { renderActionMenu } = await import('./action_menu'); + * setHeaderActionMenu((element) => { + * return renderActionMenu(element); + * }) + * return renderApp({ element, history }); + * } + * ``` + */ + setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; } /** @@ -820,6 +852,14 @@ export interface InternalApplicationStart extends Omit; + /** * The global history instance, exposed only to Core. Undefined when rendering a legacy application. * @internal diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index a94313dd53abb..e26fe7e59fd04 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -29,6 +29,7 @@ import { ScopedHistory } from '../scoped_history'; describe('AppContainer', () => { const appId = 'someApp'; const setAppLeaveHandler = jest.fn(); + const setAppActionMenu = jest.fn(); const setIsMounting = jest.fn(); beforeEach(() => { @@ -76,6 +77,7 @@ describe('AppContainer', () => { appStatus={AppStatus.inaccessible} mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} + setAppActionMenu={setAppActionMenu} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -116,6 +118,7 @@ describe('AppContainer', () => { appStatus={AppStatus.accessible} mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} + setAppActionMenu={setAppActionMenu} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -158,6 +161,7 @@ describe('AppContainer', () => { appStatus={AppStatus.accessible} mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} + setAppActionMenu={setAppActionMenu} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 332c31c64b6ba..f668cf851da55 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -25,8 +25,9 @@ import React, { useState, MutableRefObject, } from 'react'; - import { EuiLoadingSpinner } from '@elastic/eui'; + +import type { MountPoint } from '../../types'; import { AppLeaveHandler, AppStatus, AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; import { ScopedHistory } from '../scoped_history'; @@ -39,6 +40,7 @@ interface Props { mounter?: Mounter; appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; + setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; createScopedHistory: (appUrl: string) => ScopedHistory; setIsMounting: (isMounting: boolean) => void; } @@ -48,6 +50,7 @@ export const AppContainer: FunctionComponent = ({ appId, appPath, setAppLeaveHandler, + setAppActionMenu, createScopedHistory, appStatus, setIsMounting, @@ -84,6 +87,7 @@ export const AppContainer: FunctionComponent = ({ history: createScopedHistory(appPath), element: elementRef.current!, onAppLeave: (handler) => setAppLeaveHandler(appId, handler), + setHeaderActionMenu: (menuMount) => setAppActionMenu(appId, menuMount), })) || null; } catch (e) { // TODO: add error UI @@ -98,7 +102,16 @@ export const AppContainer: FunctionComponent = ({ mount(); return unmount; - }, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath, setIsMounting]); + }, [ + appId, + appStatus, + mounter, + createScopedHistory, + setAppLeaveHandler, + setAppActionMenu, + appPath, + setIsMounting, + ]); return ( diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index f1f22237c32db..5021dd3ae765a 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -23,6 +23,7 @@ import { History } from 'history'; import { Observable } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; +import type { MountPoint } from '../../types'; import { AppLeaveHandler, AppStatus, Mounter } from '../types'; import { AppContainer } from './app_container'; import { ScopedHistory } from '../scoped_history'; @@ -32,6 +33,7 @@ interface Props { history: History; appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; + setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; setIsMounting: (isMounting: boolean) => void; } @@ -43,6 +45,7 @@ export const AppRouter: FunctionComponent = ({ history, mounters, setAppLeaveHandler, + setAppActionMenu, appStatuses$, setIsMounting, }) => { @@ -69,7 +72,7 @@ export const AppRouter: FunctionComponent = ({ appPath={path} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ appId, mounter, setAppLeaveHandler, setIsMounting }} + {...{ appId, mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} /> )} /> @@ -94,7 +97,7 @@ export const AppRouter: FunctionComponent = ({ appId={id} appStatus={appStatuses.get(id) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ mounter, setAppLeaveHandler, setIsMounting }} + {...{ mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} /> ); }} diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 3aabd2a1127dc..5ec7a4773967b 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -29,6 +29,15 @@ exports[`Header renders 1`] = ` "management": Object {}, "navLinks": Object {}, }, + "currentActionMenu$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "currentAppId$": Observable { "_isScalar": false, "source": Subject { @@ -641,6 +650,15 @@ exports[`Header renders 2`] = ` "management": Object {}, "navLinks": Object {}, }, + "currentActionMenu$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "currentAppId$": Observable { "_isScalar": false, "source": Subject { @@ -4854,6 +4872,15 @@ exports[`Header renders 3`] = ` "management": Object {}, "navLinks": Object {}, }, + "currentActionMenu$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "currentAppId$": Observable { "_isScalar": false, "source": Subject { @@ -9708,6 +9735,15 @@ exports[`Header renders 4`] = ` "management": Object {}, "navLinks": Object {}, }, + "currentActionMenu$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "currentAppId$": Observable { "_isScalar": false, "source": Subject { diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 2f7f6fae94436..aefcb830d40bf 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -166,6 +166,7 @@ function createAppMountParametersMock(appBasePath = '') { element: document.createElement('div'), history, onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), }; return params; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 6f25f46c76fb9..570732fa6e5d6 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -165,6 +165,7 @@ export interface AppMountParameters { element: HTMLElement; history: ScopedHistory; onAppLeave: (handler: AppLeaveHandler) => void; + setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; } // @public diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx index 46f09a8ebb879..d3a54627b0240 100644 --- a/src/plugins/dev_tools/public/application.tsx +++ b/src/plugins/dev_tools/public/application.tsx @@ -90,6 +90,7 @@ function DevToolsWrapper({ devTools, activeDevTool, updateRoute }: DevToolsWrapp element, appBasePath: '', onAppLeave: () => undefined, + setHeaderActionMenu: () => undefined, // TODO: adapt to use Core's ScopedHistory history: {} as any, }; diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 34140703fd8ae..9a9486da892e4 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -32,7 +32,7 @@ export * from './notifications'; export { Markdown, MarkdownSimple } from './markdown'; export { reactToUiComponent, uiToReactComponent } from './adapters'; export { useUrlTracker } from './use_url_tracker'; -export { toMountPoint } from './util'; +export { toMountPoint, MountPointPortal } from './util'; export { RedirectAppLinks } from './app_links'; /** dummy plugin, we just want kibanaReact to have its own bundle */ diff --git a/src/plugins/kibana_react/public/util/index.ts b/src/plugins/kibana_react/public/util/index.ts index 71a281dbdaad3..a6f3f87535f46 100644 --- a/src/plugins/kibana_react/public/util/index.ts +++ b/src/plugins/kibana_react/public/util/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export * from './react_mount'; +export { toMountPoint } from './to_mount_point'; +export { MountPointPortal } from './mount_point_portal'; diff --git a/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx new file mode 100644 index 0000000000000..c13b8eae26221 --- /dev/null +++ b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx @@ -0,0 +1,210 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { FC } from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { MountPoint, UnmountCallback } from 'kibana/public'; +import { MountPointPortal } from './mount_point_portal'; +import { act } from 'react-dom/test-utils'; + +describe('MountPointPortal', () => { + let portalTarget: HTMLElement; + let mountPoint: MountPoint; + let setMountPoint: jest.Mock<(mountPoint: MountPoint) => void>; + let dom: ReactWrapper; + + const refresh = () => { + new Promise(async (resolve) => { + if (dom) { + act(() => { + dom.update(); + }); + } + setImmediate(() => resolve(dom)); // flushes any pending promises + }); + }; + + beforeEach(() => { + portalTarget = document.createElement('div'); + document.body.append(portalTarget); + setMountPoint = jest.fn().mockImplementation((mp) => (mountPoint = mp)); + }); + + afterEach(() => { + if (portalTarget) { + portalTarget.remove(); + } + }); + + it('calls the provided `setMountPoint` during render', async () => { + dom = mount( + + portal content + + ); + + await refresh(); + + expect(setMountPoint).toHaveBeenCalledTimes(1); + }); + + it('renders the portal content when calling the mountPoint ', async () => { + dom = mount( + + portal content + + ); + + await refresh(); + + expect(mountPoint).toBeDefined(); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe('portal content'); + }); + + it('cleanup the portal content when the component is unmounted', async () => { + dom = mount( + + portal content + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe('portal content'); + + dom.unmount(); + + await refresh(); + + expect(portalTarget.innerHTML).toBe(''); + }); + + it('cleanup the portal content when unmounting the MountPoint from outside', async () => { + dom = mount( + + portal content + + ); + + let unmount: UnmountCallback; + act(() => { + unmount = mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe('portal content'); + + act(() => { + unmount(); + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe(''); + }); + + it('updates the content of the portal element when the content of MountPointPortal changes', async () => { + const Wrapper: FC<{ + setMount: (mountPoint: MountPoint) => void; + portalContent: string; + }> = ({ setMount, portalContent }) => { + return ( + +
{portalContent}
+
+ ); + }; + + dom = mount(); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe('
before
'); + + dom.setProps({ + portalContent: 'after', + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe('
after
'); + }); + + it('cleanup the previous portal content when setMountPoint changes', async () => { + dom = mount( + + portal content + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe('portal content'); + + const newSetMountPoint = jest.fn(); + + dom.setProps({ + setMountPoint: newSetMountPoint, + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe(''); + }); + + it('intercepts errors and display an error message', async () => { + const CrashTest = () => { + throw new Error('crash'); + }; + + dom = mount( + + + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe('

Error rendering portal content

'); + }); +}); diff --git a/src/plugins/kibana_react/public/util/mount_point_portal.tsx b/src/plugins/kibana_react/public/util/mount_point_portal.tsx new file mode 100644 index 0000000000000..b762fba88791e --- /dev/null +++ b/src/plugins/kibana_react/public/util/mount_point_portal.tsx @@ -0,0 +1,88 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useRef, useEffect, useState, Component } from 'react'; +import ReactDOM from 'react-dom'; +import { MountPoint } from 'kibana/public'; + +interface MountPointPortalProps { + setMountPoint: (mountPoint: MountPoint) => void; +} + +/** + * Utility component to portal a part of a react application into the provided `MountPoint`. + */ +export const MountPointPortal: React.FC = ({ children, setMountPoint }) => { + // state used to force re-renders when the element changes + const [shouldRender, setShouldRender] = useState(false); + const el = useRef(); + + useEffect(() => { + setMountPoint((element) => { + el.current = element; + setShouldRender(true); + return () => { + setShouldRender(false); + el.current = undefined; + }; + }); + + return () => { + setShouldRender(false); + el.current = undefined; + }; + }, [setMountPoint]); + + if (shouldRender && el.current) { + return ReactDOM.createPortal( + {children}, + el.current + ); + } else { + return null; + } +}; + +class MountPointPortalErrorBoundary extends Component<{}, { error?: any }> { + state = { + error: undefined, + }; + + static getDerivedStateFromError(error: any) { + return { error }; + } + + componentDidCatch() { + // nothing, will just rerender to display the error message + } + + render() { + if (this.state.error) { + return ( +

+ {i18n.translate('kibana-react.mountPointPortal.errorMessage', { + defaultMessage: 'Error rendering portal content', + })} +

+ ); + } + return this.props.children; + } +} diff --git a/src/plugins/kibana_react/public/util/react_mount.tsx b/src/plugins/kibana_react/public/util/to_mount_point.tsx similarity index 100% rename from src/plugins/kibana_react/public/util/react_mount.tsx rename to src/plugins/kibana_react/public/util/to_mount_point.tsx diff --git a/src/plugins/navigation/kibana.json b/src/plugins/navigation/kibana.json index 000d5acf2635f..85d2049a34be0 100644 --- a/src/plugins/navigation/kibana.json +++ b/src/plugins/navigation/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": ["data"] -} \ No newline at end of file + "requiredPlugins": ["data"], + "requiredBundles": ["kibanaReact"] +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 46384fb3f27d5..f21e5680e8f61 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -18,9 +18,12 @@ */ import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { MountPoint } from 'kibana/public'; import { TopNavMenu } from './top_nav_menu'; import { TopNavMenuData } from './top_nav_menu_data'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; const dataShim = { ui: { @@ -109,4 +112,59 @@ describe('TopNavMenu', () => { expect(component.find('.kbnTopNavMenu').length).toBe(1); expect(component.find('.myCoolClass').length).toBeTruthy(); }); + + describe('when setMenuMountPoint is provided', () => { + let portalTarget: HTMLElement; + let mountPoint: MountPoint; + let setMountPoint: jest.Mock<(mountPoint: MountPoint) => void>; + let dom: ReactWrapper; + + const refresh = () => { + new Promise(async (resolve) => { + if (dom) { + act(() => { + dom.update(); + }); + } + setImmediate(() => resolve(dom)); // flushes any pending promises + }); + }; + + beforeEach(() => { + portalTarget = document.createElement('div'); + document.body.append(portalTarget); + setMountPoint = jest.fn().mockImplementation((mp) => (mountPoint = mp)); + }); + + afterEach(() => { + if (portalTarget) { + portalTarget.remove(); + } + }); + + it('mounts the menu inside the provided mountPoint', async () => { + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(component.find(WRAPPER_SELECTOR).length).toBe(1); + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); + + // menu is rendered outside of the component + expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); + expect(portalTarget.getElementsByTagName('BUTTON').length).toBe(menuItems.length); + }); + }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 2cfca332effb0..a1a40b49cc8f0 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -18,13 +18,14 @@ */ import React, { ReactElement } from 'react'; - import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; - import classNames from 'classnames'; + +import { MountPoint } from '../../../../core/public'; +import { MountPointPortal } from '../../../kibana_react/public'; +import { StatefulSearchBarProps, DataPublicPluginStart } from '../../../data/public'; import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { StatefulSearchBarProps, DataPublicPluginStart } from '../../../data/public'; export type TopNavMenuProps = StatefulSearchBarProps & { config?: TopNavMenuData[]; @@ -35,6 +36,25 @@ export type TopNavMenuProps = StatefulSearchBarProps & { showFilterBar?: boolean; data?: DataPublicPluginStart; className?: string; + /** + * If provided, the menu part of the component will be rendered as a portal inside the given mount point. + * + * This is meant to be used with the `setHeaderActionMenu` core API. + * + * @example + * ```ts + * export renderApp = ({ element, history, setHeaderActionMenu }: AppMountParameters) => { + * const topNavConfig = ...; // TopNavMenuProps + * return ( + * + * + * + * + * ) + * } + * ``` + */ + setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; }; /* @@ -92,13 +112,26 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { } function renderLayout() { - const className = classNames('kbnTopNavMenu', props.className); - return ( - - {renderMenu(className)} - {renderSearchBar()} - - ); + const { setMenuMountPoint } = props; + const menuClassName = classNames('kbnTopNavMenu', props.className); + const wrapperClassName = 'kbnTopNavMenu__wrapper'; + if (setMenuMountPoint) { + return ( + <> + + {renderMenu(menuClassName)} + + {renderSearchBar()} + + ); + } else { + return ( + + {renderMenu(menuClassName)} + {renderSearchBar()} + + ); + } } return renderLayout(); diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 4f8ceb8effe98..214b393a0fda9 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -96,12 +96,7 @@ export class MlPlugin implements Plugin { uiActions: pluginsStart.uiActions, kibanaVersion, }, - { - element: params.element, - appBasePath: params.appBasePath, - onAppLeave: params.onAppLeave, - history: params.history, - } + params ); }, }); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts index 37b97a8472310..c41bd43872bee 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts @@ -54,6 +54,7 @@ describe('accountManagementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts index 0e262e9089842..eafad74d2f0d8 100644 --- a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts +++ b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts @@ -48,6 +48,7 @@ describe('accessAgreementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts index c5b9245414630..e6723085460f8 100644 --- a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts @@ -54,6 +54,7 @@ describe('captureURLApp', () => { element: document.createElement('div'), appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: (scopedHistoryMock.create() as unknown) as ScopedHistory, }); diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts index 15d55136b405d..86a5d21f1b233 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts @@ -46,6 +46,7 @@ describe('loggedOutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index a6e5a321ef6ec..5ae8afab9de23 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -51,6 +51,7 @@ describe('loginApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts index 46b1083a2ed14..b7bfdf492305e 100644 --- a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts @@ -52,6 +52,7 @@ describe('logoutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts index 0eed1382c270b..6e0e06dd3dc44 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts @@ -53,6 +53,7 @@ describe('overwrittenSessionApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); From d7869dee6e386a5576343ef2d5f2c46abf449a02 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Tue, 1 Sep 2020 14:17:52 -0400 Subject: [PATCH 04/53] [Maps] Add mvt support for ES doc sources (#75698) --- x-pack/package.json | 2 + x-pack/plugins/maps/common/constants.ts | 7 + .../data_request_descriptor_types.ts | 1 - .../maps/common/elasticsearch_geo_utils.d.ts | 10 + .../public/classes/fields/es_doc_field.ts | 8 + .../tiled_vector_layer.test.tsx | 63 ++--- .../tiled_vector_layer/tiled_vector_layer.tsx | 24 +- .../layers/vector_layer/vector_layer.js | 49 ++++ .../es_geo_grid_source.test.ts | 18 +- .../__snapshots__/scaling_form.test.tsx.snap | 152 +++++++++++- .../update_source_editor.test.js.snap | 6 + .../es_search_source/create_source_editor.js | 5 + .../es_documents_layer_wizard.tsx | 11 +- .../es_search_source/es_search_source.d.ts | 15 +- .../es_search_source/es_search_source.js | 81 ++++++- .../es_search_source/es_search_source.test.ts | 155 ++++++++++++ .../es_search_source/scaling_form.test.tsx | 52 ++-- .../sources/es_search_source/scaling_form.tsx | 72 +++++- .../es_search_source/update_source_editor.js | 10 + .../classes/sources/es_source/es_source.js | 2 +- .../sources/vector_source/vector_source.d.ts | 7 +- .../categorical_field_meta_popover.tsx | 2 + .../field_meta/ordinal_field_meta_popover.tsx | 2 + .../dynamic_color_property.test.tsx.snap | 26 ++ .../dynamic_color_property.test.tsx | 36 +++ .../properties/dynamic_style_property.tsx | 4 + .../classes/util/mb_filter_expressions.ts | 40 +++- .../map/mb/tooltip_control/tooltip_control.js | 8 +- .../connected_components/map/mb/view.js | 14 +- .../plugins/maps/public/index_pattern_util.ts | 10 + .../mvt/__tests__/json/0_0_0_search.json | 1 + .../maps/server/mvt/__tests__/pbf/0_0_0.pbf | Bin 0 -> 155 bytes .../server/mvt/__tests__/tile_searches.ts | 28 +++ .../plugins/maps/server/mvt/get_tile.test.ts | 63 +++++ x-pack/plugins/maps/server/mvt/get_tile.ts | 226 ++++++++++++++++++ x-pack/plugins/maps/server/mvt/mvt_routes.ts | 73 ++++++ x-pack/plugins/maps/server/mvt/util.ts | 72 ++++++ x-pack/plugins/maps/server/routes.js | 28 ++- .../api_integration/apis/maps/get_tile.js | 29 +++ .../test/api_integration/apis/maps/index.js | 1 + x-pack/test/functional/apps/maps/index.js | 1 + x-pack/test/functional/apps/maps/joins.js | 28 ++- .../functional/apps/maps/mapbox_styles.js | 36 +-- .../test/functional/apps/maps/mvt_scaling.js | 75 ++++++ .../es_archives/maps/kibana/data.json | 34 +++ 45 files changed, 1444 insertions(+), 143 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts create mode 100644 x-pack/plugins/maps/server/mvt/__tests__/json/0_0_0_search.json create mode 100644 x-pack/plugins/maps/server/mvt/__tests__/pbf/0_0_0.pbf create mode 100644 x-pack/plugins/maps/server/mvt/__tests__/tile_searches.ts create mode 100644 x-pack/plugins/maps/server/mvt/get_tile.test.ts create mode 100644 x-pack/plugins/maps/server/mvt/get_tile.ts create mode 100644 x-pack/plugins/maps/server/mvt/mvt_routes.ts create mode 100644 x-pack/plugins/maps/server/mvt/util.ts create mode 100644 x-pack/test/api_integration/apis/maps/get_tile.js create mode 100644 x-pack/test/functional/apps/maps/mvt_scaling.js diff --git a/x-pack/package.json b/x-pack/package.json index f25fe7e3418ae..0e9aef37aa253 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -313,6 +313,7 @@ "file-type": "^10.9.0", "font-awesome": "4.7.0", "fp-ts": "^2.3.1", + "geojson-vt": "^3.2.1", "get-port": "^5.0.0", "getos": "^3.1.0", "git-url-parse": "11.1.2", @@ -384,6 +385,7 @@ "ui-select": "0.19.8", "uuid": "3.3.2", "vscode-languageserver": "^5.2.1", + "vt-pbf": "^3.1.1", "webpack": "^4.41.5", "wellknown": "^0.5.0", "xml2js": "^0.4.22", diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 363122ac62212..a4f20caedfc9b 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -33,6 +33,12 @@ export const MAP_PATH = 'map'; export const GIS_API_PATH = `api/${APP_ID}`; export const INDEX_SETTINGS_API_PATH = `${GIS_API_PATH}/indexSettings`; export const FONTS_API_PATH = `${GIS_API_PATH}/fonts`; +export const API_ROOT_PATH = `/${GIS_API_PATH}`; + +export const MVT_GETTILE_API_PATH = 'mvt/getTile'; +export const MVT_SOURCE_LAYER_NAME = 'source_layer'; +export const KBN_TOO_MANY_FEATURES_PROPERTY = '__kbn_too_many_features__'; +export const KBN_TOO_MANY_FEATURES_IMAGE_ID = '__kbn_too_many_features_image_id__'; const MAP_BASE_URL = `/${MAPS_APP_PATH}/${MAP_PATH}`; export function getNewMapPath() { @@ -220,6 +226,7 @@ export enum SCALING_TYPES { LIMIT = 'LIMIT', CLUSTERS = 'CLUSTERS', TOP_HITS = 'TOP_HITS', + MVT = 'MVT', } export const RGBA_0000 = 'rgba(0,0,0,0)'; diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index cd7d2d5d0f461..f3521cca2e456 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -18,7 +18,6 @@ export type MapFilters = { refreshTimerLastTriggeredAt?: string; timeFilters: TimeRange; zoom: number; - geogridPrecision?: number; }; type ESSearchSourceSyncMeta = { diff --git a/x-pack/plugins/maps/common/elasticsearch_geo_utils.d.ts b/x-pack/plugins/maps/common/elasticsearch_geo_utils.d.ts index 44250360e9d00..e57efca94d95e 100644 --- a/x-pack/plugins/maps/common/elasticsearch_geo_utils.d.ts +++ b/x-pack/plugins/maps/common/elasticsearch_geo_utils.d.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { FeatureCollection, GeoJsonProperties } from 'geojson'; import { MapExtent } from './descriptor_types'; +import { ES_GEO_FIELD_TYPE } from './constants'; export function scaleBounds(bounds: MapExtent, scaleFactor: number): MapExtent; @@ -13,3 +15,11 @@ export function turfBboxToBounds(turfBbox: unknown): MapExtent; export function clampToLatBounds(lat: number): number; export function clampToLonBounds(lon: number): number; + +export function hitsToGeoJson( + hits: Array>, + flattenHit: (elasticSearchHit: Record) => GeoJsonProperties, + geoFieldName: string, + geoFieldType: ES_GEO_FIELD_TYPE, + epochMillisFields: string[] +): FeatureCollection; diff --git a/x-pack/plugins/maps/public/classes/fields/es_doc_field.ts b/x-pack/plugins/maps/public/classes/fields/es_doc_field.ts index 9faa33fae5a43..543dbf6d87039 100644 --- a/x-pack/plugins/maps/public/classes/fields/es_doc_field.ts +++ b/x-pack/plugins/maps/public/classes/fields/es_doc_field.ts @@ -15,18 +15,22 @@ import { IVectorSource } from '../sources/vector_source'; export class ESDocField extends AbstractField implements IField { private readonly _source: IESSource; + private readonly _canReadFromGeoJson: boolean; constructor({ fieldName, source, origin, + canReadFromGeoJson = true, }: { fieldName: string; source: IESSource; origin: FIELD_ORIGIN; + canReadFromGeoJson?: boolean; }) { super({ fieldName, origin }); this._source = source; + this._canReadFromGeoJson = canReadFromGeoJson; } canValueBeFormatted(): boolean { @@ -60,6 +64,10 @@ export class ESDocField extends AbstractField implements IField { return true; } + canReadFromGeoJson(): boolean { + return this._canReadFromGeoJson; + } + async getOrdinalFieldMetaRequest(): Promise { const indexPatternField = await this._getIndexPatternField(); diff --git a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx index faae26cac08e7..822b78aa0deff 100644 --- a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx @@ -128,32 +128,41 @@ describe('syncData', () => { sinon.assert.notCalled(syncContext2.stopLoading); }); - it('Should resync when changes to source params', async () => { - const layer1: TiledVectorLayer = createLayer({}, {}); - const syncContext1 = new MockSyncContext({ dataFilters: {} }); - - await layer1.syncData(syncContext1); - - const dataRequestDescriptor: DataRequestDescriptor = { - data: defaultConfig, - dataId: 'source', - }; - const layer2: TiledVectorLayer = createLayer( - { - __dataRequests: [dataRequestDescriptor], - }, - { layerName: 'barfoo' } - ); - const syncContext2 = new MockSyncContext({ dataFilters: {} }); - await layer2.syncData(syncContext2); - - // @ts-expect-error - sinon.assert.calledOnce(syncContext2.startLoading); - // @ts-expect-error - sinon.assert.calledOnce(syncContext2.stopLoading); - - // @ts-expect-error - const call = syncContext2.stopLoading.getCall(0); - expect(call.args[2]).toEqual({ ...defaultConfig, layerName: 'barfoo' }); + describe('Should resync when changes to source params: ', () => { + [ + { layerName: 'barfoo' }, + { urlTemplate: 'https://sub.example.com/{z}/{x}/{y}.pbf' }, + { minSourceZoom: 1 }, + { maxSourceZoom: 12 }, + ].forEach((changes) => { + it(`change in ${Object.keys(changes).join(',')}`, async () => { + const layer1: TiledVectorLayer = createLayer({}, {}); + const syncContext1 = new MockSyncContext({ dataFilters: {} }); + + await layer1.syncData(syncContext1); + + const dataRequestDescriptor: DataRequestDescriptor = { + data: defaultConfig, + dataId: 'source', + }; + const layer2: TiledVectorLayer = createLayer( + { + __dataRequests: [dataRequestDescriptor], + }, + changes + ); + const syncContext2 = new MockSyncContext({ dataFilters: {} }); + await layer2.syncData(syncContext2); + + // @ts-expect-error + sinon.assert.calledOnce(syncContext2.startLoading); + // @ts-expect-error + sinon.assert.calledOnce(syncContext2.stopLoading); + + // @ts-expect-error + const call = syncContext2.stopLoading.getCall(0); + expect(call.args[2]).toEqual({ ...defaultConfig, ...changes }); + }); + }); }); }); diff --git a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx index c9ae1c805fa30..70bf8ea3883b7 100644 --- a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx @@ -63,21 +63,24 @@ export class TiledVectorLayer extends VectorLayer { ); const prevDataRequest = this.getSourceDataRequest(); + const templateWithMeta = await this._source.getUrlTemplateWithMeta(searchFilters); if (prevDataRequest) { const data: MVTSingleLayerVectorSourceConfig = prevDataRequest.getData() as MVTSingleLayerVectorSourceConfig; - const canSkipBecauseNoChanges = - data.layerName === this._source.getLayerName() && - data.minSourceZoom === this._source.getMinZoom() && - data.maxSourceZoom === this._source.getMaxZoom(); - - if (canSkipBecauseNoChanges) { - return null; + if (data) { + const canSkipBecauseNoChanges = + data.layerName === this._source.getLayerName() && + data.minSourceZoom === this._source.getMinZoom() && + data.maxSourceZoom === this._source.getMaxZoom() && + data.urlTemplate === templateWithMeta.urlTemplate; + + if (canSkipBecauseNoChanges) { + return null; + } } } startLoading(SOURCE_DATA_REQUEST_ID, requestToken, searchFilters); try { - const templateWithMeta = await this._source.getUrlTemplateWithMeta(); stopLoading(SOURCE_DATA_REQUEST_ID, requestToken, templateWithMeta, {}); } catch (error) { onLoadError(SOURCE_DATA_REQUEST_ID, requestToken, error.message); @@ -160,6 +163,11 @@ export class TiledVectorLayer extends VectorLayer { return false; } + if (!mbTileSource.tiles) { + // Expected source is not compatible, so remove. + return true; + } + const isSourceDifferent = mbTileSource.tiles[0] !== tiledSourceMeta.urlTemplate || mbTileSource.minzoom !== tiledSourceMeta.minSourceZoom || diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js index 2ba7f750e9b40..c49d0044e6ad6 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js @@ -15,9 +15,11 @@ import { SOURCE_BOUNDS_DATA_REQUEST_ID, FEATURE_VISIBLE_PROPERTY_NAME, EMPTY_FEATURE_COLLECTION, + KBN_TOO_MANY_FEATURES_PROPERTY, LAYER_TYPE, FIELD_ORIGIN, LAYER_STYLE_TYPE, + KBN_TOO_MANY_FEATURES_IMAGE_ID, } from '../../../../common/constants'; import _ from 'lodash'; import { JoinTooltipProperty } from '../../tooltips/join_tooltip_property'; @@ -777,6 +779,8 @@ export class VectorLayer extends AbstractLayer { const sourceId = this.getId(); const fillLayerId = this._getMbPolygonLayerId(); const lineLayerId = this._getMbLineLayerId(); + const tooManyFeaturesLayerId = this._getMbTooManyFeaturesLayerId(); + const hasJoins = this.hasJoins(); if (!mbMap.getLayer(fillLayerId)) { const mbLayer = { @@ -802,6 +806,30 @@ export class VectorLayer extends AbstractLayer { } mbMap.addLayer(mbLayer); } + if (!mbMap.getLayer(tooManyFeaturesLayerId)) { + const mbLayer = { + id: tooManyFeaturesLayerId, + type: 'fill', + source: sourceId, + paint: {}, + }; + if (mvtSourceLayer) { + mbLayer['source-layer'] = mvtSourceLayer; + } + mbMap.addLayer(mbLayer); + mbMap.setFilter(tooManyFeaturesLayerId, [ + '==', + ['get', KBN_TOO_MANY_FEATURES_PROPERTY], + true, + ]); + mbMap.setPaintProperty( + tooManyFeaturesLayerId, + 'fill-pattern', + KBN_TOO_MANY_FEATURES_IMAGE_ID + ); + mbMap.setPaintProperty(tooManyFeaturesLayerId, 'fill-opacity', this.getAlpha()); + } + this.getCurrentStyle().setMBPaintProperties({ alpha: this.getAlpha(), mbMap, @@ -822,6 +850,9 @@ export class VectorLayer extends AbstractLayer { if (lineFilterExpr !== mbMap.getFilter(lineLayerId)) { mbMap.setFilter(lineLayerId, lineFilterExpr); } + + this.syncVisibilityWithMb(mbMap, tooManyFeaturesLayerId); + mbMap.setLayerZoomRange(tooManyFeaturesLayerId, this.getMinZoom(), this.getMaxZoom()); } _syncStylePropertiesWithMb(mbMap) { @@ -836,6 +867,19 @@ export class VectorLayer extends AbstractLayer { type: 'geojson', data: EMPTY_FEATURE_COLLECTION, }); + } else if (mbSource.type !== 'geojson') { + // Recreate source when existing source is not geojson. This can occur when layer changes from tile layer to vector layer. + this.getMbLayerIds().forEach((mbLayerId) => { + if (mbMap.getLayer(mbLayerId)) { + mbMap.removeLayer(mbLayerId); + } + }); + + mbMap.removeSource(this._getMbSourceId()); + mbMap.addSource(this._getMbSourceId(), { + type: 'geojson', + data: EMPTY_FEATURE_COLLECTION, + }); } } @@ -865,6 +909,10 @@ export class VectorLayer extends AbstractLayer { return this.makeMbLayerId('fill'); } + _getMbTooManyFeaturesLayerId() { + return this.makeMbLayerId('toomanyfeatures'); + } + getMbLayerIds() { return [ this._getMbPointLayerId(), @@ -872,6 +920,7 @@ export class VectorLayer extends AbstractLayer { this._getMbSymbolLayerId(), this._getMbLineLayerId(), this._getMbPolygonLayerId(), + this._getMbTooManyFeaturesLayerId(), ]; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index 43bfb74bf54b6..2e0ba7cf3efee 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { MapExtent, MapFilters } from '../../../../common/descriptor_types'; +import { MapExtent, VectorSourceRequestMeta } from '../../../../common/descriptor_types'; jest.mock('../../../kibana_services'); @@ -19,6 +19,7 @@ import { SearchSource } from '../../../../../../../src/plugins/data/public/searc export class MockSearchSource { setField = jest.fn(); + setParent() {} } describe('ESGeoGridSource', () => { @@ -104,6 +105,9 @@ describe('ESGeoGridSource', () => { async create() { return mockSearchSource as SearchSource; }, + createEmpty() { + return mockSearchSource as SearchSource; + }, }, }; @@ -120,7 +124,7 @@ describe('ESGeoGridSource', () => { maxLat: 80, }; - const mapFilters: MapFilters = { + const mapFilters: VectorSourceRequestMeta = { geogridPrecision: 4, filters: [], timeFilters: { @@ -128,8 +132,16 @@ describe('ESGeoGridSource', () => { to: '15m', mode: 'relative', }, - // extent, + extent, + applyGlobalQuery: true, + fieldNames: [], buffer: extent, + sourceQuery: { + query: '', + language: 'KQL', + queryLastTriggeredAt: '2019-04-25T20:53:22.331Z', + }, + sourceMeta: null, zoom: 0, }; diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap index 8ebb389472f74..dd62be11c679d 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should disable clusters option when clustering is not supported 1`] = ` +exports[`scaling form should disable clusters option when clustering is not supported 1`] = ` + + + + Use vector tiles for faster display of large datasets. + + } + delay="regular" + position="left" + > + + + + + + + + +`; + +exports[`scaling form should disable mvt option when mvt is not supported 1`] = ` + + +
+ +
+
+ + +
+ + + + + +
`; -exports[`should render 1`] = ` +exports[`scaling form should render 1`] = ` + + + + Use vector tiles for faster display of large datasets. + + } + delay="regular" + position="left" + > + + `; -exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` +exports[`scaling form should render top hits form when scaling type is TOP_HITS 1`] = ` + + + + Use vector tiles for faster display of large datasets. + + } + delay="regular" + position="left" + > + + @@ -159,6 +162,8 @@ export class CreateSourceEditor extends Component { this.state.indexPattern, this.state.geoFieldName )} + supportsMvt={mvtSupported} + mvtDisabledReason={mvtSupported ? null : getMvtDisabledReason()} clusteringDisabledReason={ this.state.indexPattern ? getGeoTileAggNotSupportedReason( diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx index 1ec6d2a1ff671..249b9a2454d7d 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx @@ -14,13 +14,18 @@ import { ESSearchSource, sourceTitle } from './es_search_source'; import { BlendedVectorLayer } from '../../layers/blended_vector_layer/blended_vector_layer'; import { VectorLayer } from '../../layers/vector_layer/vector_layer'; import { LAYER_WIZARD_CATEGORY, SCALING_TYPES } from '../../../../common/constants'; +import { TiledVectorLayer } from '../../layers/tiled_vector_layer/tiled_vector_layer'; export function createDefaultLayerDescriptor(sourceConfig: unknown, mapColors: string[]) { const sourceDescriptor = ESSearchSource.createDescriptor(sourceConfig); - return sourceDescriptor.scalingType === SCALING_TYPES.CLUSTERS - ? BlendedVectorLayer.createDescriptor({ sourceDescriptor }, mapColors) - : VectorLayer.createDescriptor({ sourceDescriptor }, mapColors); + if (sourceDescriptor.scalingType === SCALING_TYPES.CLUSTERS) { + return BlendedVectorLayer.createDescriptor({ sourceDescriptor }, mapColors); + } else if (sourceDescriptor.scalingType === SCALING_TYPES.MVT) { + return TiledVectorLayer.createDescriptor({ sourceDescriptor }, mapColors); + } else { + return VectorLayer.createDescriptor({ sourceDescriptor }, mapColors); + } } export const esDocumentsLayerWizardConfig: LayerWizard = { diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.d.ts b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.d.ts index 23e3c759d73c3..67d68dc065b00 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.d.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.d.ts @@ -5,11 +5,22 @@ */ import { AbstractESSource } from '../es_source'; -import { ESSearchSourceDescriptor } from '../../../../common/descriptor_types'; +import { ESSearchSourceDescriptor, MapFilters } from '../../../../common/descriptor_types'; +import { ITiledSingleLayerVectorSource } from '../vector_source'; -export class ESSearchSource extends AbstractESSource { +export class ESSearchSource extends AbstractESSource implements ITiledSingleLayerVectorSource { static createDescriptor(sourceConfig: unknown): ESSearchSourceDescriptor; constructor(sourceDescriptor: Partial, inspectorAdapters: unknown); getFieldNames(): string[]; + + getUrlTemplateWithMeta( + searchFilters: MapFilters + ): Promise<{ + layerName: string; + urlTemplate: string; + minSourceZoom: number; + maxSourceZoom: number; + }>; + getLayerName(): string; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js index 6d61c4a7455b2..7ac2738eaeb51 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js @@ -6,9 +6,10 @@ import _ from 'lodash'; import React from 'react'; +import rison from 'rison-node'; import { AbstractESSource } from '../es_source'; -import { getSearchService } from '../../../kibana_services'; +import { getSearchService, getHttp } from '../../../kibana_services'; import { hitsToGeoJson } from '../../../../common/elasticsearch_geo_utils'; import { UpdateSourceEditor } from './update_source_editor'; import { @@ -18,6 +19,9 @@ import { SORT_ORDER, SCALING_TYPES, VECTOR_SHAPE_TYPE, + MVT_SOURCE_LAYER_NAME, + GIS_API_PATH, + MVT_GETTILE_API_PATH, } from '../../../../common/constants'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; @@ -96,6 +100,7 @@ export class ESSearchSource extends AbstractESSource { return new ESDocField({ fieldName, source: this, + canReadFromGeoJson: this._descriptor.scalingType !== SCALING_TYPES.MVT, }); } @@ -448,9 +453,13 @@ export class ESSearchSource extends AbstractESSource { } isFilterByMapBounds() { - return this._descriptor.scalingType === SCALING_TYPES.CLUSTER - ? true - : this._descriptor.filterByMapBounds; + if (this._descriptor.scalingType === SCALING_TYPES.CLUSTER) { + return true; + } else if (this._descriptor.scalingType === SCALING_TYPES.MVT) { + return false; + } else { + return this._descriptor.filterByMapBounds; + } } async getLeftJoinFields() { @@ -553,11 +562,65 @@ export class ESSearchSource extends AbstractESSource { } getJoinsDisabledReason() { - return this._descriptor.scalingType === SCALING_TYPES.CLUSTERS - ? i18n.translate('xpack.maps.source.esSearch.joinsDisabledReason', { - defaultMessage: 'Joins are not supported when scaling by clusters', - }) - : null; + let reason; + if (this._descriptor.scalingType === SCALING_TYPES.CLUSTERS) { + reason = i18n.translate('xpack.maps.source.esSearch.joinsDisabledReason', { + defaultMessage: 'Joins are not supported when scaling by clusters', + }); + } else if (this._descriptor.scalingType === SCALING_TYPES.MVT) { + reason = i18n.translate('xpack.maps.source.esSearch.joinsDisabledReasonMvt', { + defaultMessage: 'Joins are not supported when scaling by mvt vector tiles', + }); + } else { + reason = null; + } + return reason; + } + + getLayerName() { + return MVT_SOURCE_LAYER_NAME; + } + + async getUrlTemplateWithMeta(searchFilters) { + const indexPattern = await this.getIndexPattern(); + const indexSettings = await loadIndexSettings(indexPattern.title); + + const { docValueFields, sourceOnlyFields } = getDocValueAndSourceFields( + indexPattern, + searchFilters.fieldNames + ); + + const initialSearchContext = { docvalue_fields: docValueFields }; // Request fields in docvalue_fields insted of _source + + const searchSource = await this.makeSearchSource( + searchFilters, + indexSettings.maxResultWindow, + initialSearchContext + ); + searchSource.setField('fields', searchFilters.fieldNames); // Setting "fields" filters out unused scripted fields + if (sourceOnlyFields.length === 0) { + searchSource.setField('source', false); // do not need anything from _source + } else { + searchSource.setField('source', sourceOnlyFields); + } + if (this._hasSort()) { + searchSource.setField('sort', this._buildEsSort()); + } + + const dsl = await searchSource.getSearchRequestBody(); + const risonDsl = rison.encode(dsl); + + const mvtUrlServicePath = getHttp().basePath.prepend( + `/${GIS_API_PATH}/${MVT_GETTILE_API_PATH}` + ); + + const urlTemplate = `${mvtUrlServicePath}?x={x}&y={y}&z={z}&geometryFieldName=${this._descriptor.geoField}&index=${indexPattern.title}&requestBody=${risonDsl}`; + return { + layerName: this.getLayerName(), + minSourceZoom: this.getMinZoom(), + maxSourceZoom: this.getMaxZoom(), + urlTemplate: urlTemplate, + }; } } diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts new file mode 100644 index 0000000000000..3223d0c94178f --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ES_GEO_FIELD_TYPE, SCALING_TYPES } from '../../../../common/constants'; + +jest.mock('../../../kibana_services'); +jest.mock('./load_index_settings'); + +import { getIndexPatternService, getSearchService, getHttp } from '../../../kibana_services'; +import { SearchSource } from '../../../../../../../src/plugins/data/public/search/search_source'; + +// @ts-expect-error +import { loadIndexSettings } from './load_index_settings'; + +import { ESSearchSource } from './es_search_source'; +import { VectorSourceRequestMeta } from '../../../../common/descriptor_types'; + +describe('ESSearchSource', () => { + it('constructor', () => { + const esSearchSource = new ESSearchSource({}, null); + expect(esSearchSource instanceof ESSearchSource).toBe(true); + }); + + describe('ITiledSingleLayerVectorSource', () => { + it('mb-source params', () => { + const esSearchSource = new ESSearchSource({}, null); + expect(esSearchSource.getMinZoom()).toBe(0); + expect(esSearchSource.getMaxZoom()).toBe(24); + expect(esSearchSource.getLayerName()).toBe('source_layer'); + }); + + describe('getUrlTemplateWithMeta', () => { + const geoFieldName = 'bar'; + const mockIndexPatternService = { + get() { + return { + title: 'foobar-title-*', + fields: { + getByName() { + return { + name: geoFieldName, + type: ES_GEO_FIELD_TYPE.GEO_SHAPE, + }; + }, + }, + }; + }, + }; + + beforeEach(async () => { + const mockSearchSource = { + setField: jest.fn(), + getSearchRequestBody() { + return { foobar: 'ES_DSL_PLACEHOLDER', params: this.setField.mock.calls }; + }, + setParent() {}, + }; + const mockSearchService = { + searchSource: { + async create() { + return (mockSearchSource as unknown) as SearchSource; + }, + createEmpty() { + return (mockSearchSource as unknown) as SearchSource; + }, + }, + }; + + // @ts-expect-error + getIndexPatternService.mockReturnValue(mockIndexPatternService); + // @ts-expect-error + getSearchService.mockReturnValue(mockSearchService); + loadIndexSettings.mockReturnValue({ + maxResultWindow: 1000, + }); + // @ts-expect-error + getHttp.mockReturnValue({ + basePath: { + prepend(path: string) { + return `rootdir${path};`; + }, + }, + }); + }); + + const searchFilters: VectorSourceRequestMeta = { + filters: [], + zoom: 0, + fieldNames: ['tooltipField', 'styleField'], + timeFilters: { + from: 'now', + to: '15m', + mode: 'relative', + }, + sourceQuery: { + query: 'tooltipField: foobar', + language: 'KQL', + queryLastTriggeredAt: '2019-04-25T20:53:22.331Z', + }, + sourceMeta: null, + applyGlobalQuery: true, + }; + + it('Should only include required props', async () => { + const esSearchSource = new ESSearchSource( + { geoField: geoFieldName, indexPatternId: 'ipId' }, + null + ); + const urlTemplateWithMeta = await esSearchSource.getUrlTemplateWithMeta(searchFilters); + expect(urlTemplateWithMeta.urlTemplate).toBe( + `rootdir/api/maps/mvt/getTile;?x={x}&y={y}&z={z}&geometryFieldName=bar&index=foobar-title-*&requestBody=(foobar:ES_DSL_PLACEHOLDER,params:('0':('0':index,'1':(fields:(),title:'foobar-title-*')),'1':('0':size,'1':1000),'2':('0':filter,'1':!()),'3':('0':query),'4':('0':index,'1':(fields:(),title:'foobar-title-*')),'5':('0':query,'1':(language:KQL,query:'tooltipField: foobar',queryLastTriggeredAt:'2019-04-25T20:53:22.331Z')),'6':('0':fields,'1':!(tooltipField,styleField)),'7':('0':source,'1':!(tooltipField,styleField))))` + ); + }); + }); + }); + + describe('isFilterByMapBounds', () => { + it('default', () => { + const esSearchSource = new ESSearchSource({}, null); + expect(esSearchSource.isFilterByMapBounds()).toBe(true); + }); + it('mvt', () => { + const esSearchSource = new ESSearchSource({ scalingType: SCALING_TYPES.MVT }, null); + expect(esSearchSource.isFilterByMapBounds()).toBe(false); + }); + }); + + describe('getJoinsDisabledReason', () => { + it('default', () => { + const esSearchSource = new ESSearchSource({}, null); + expect(esSearchSource.getJoinsDisabledReason()).toBe(null); + }); + it('mvt', () => { + const esSearchSource = new ESSearchSource({ scalingType: SCALING_TYPES.MVT }, null); + expect(esSearchSource.getJoinsDisabledReason()).toBe( + 'Joins are not supported when scaling by mvt vector tiles' + ); + }); + }); + + describe('getFields', () => { + it('default', () => { + const esSearchSource = new ESSearchSource({}, null); + const docField = esSearchSource.createField({ fieldName: 'prop1' }); + expect(docField.canReadFromGeoJson()).toBe(true); + }); + it('mvt', () => { + const esSearchSource = new ESSearchSource({ scalingType: SCALING_TYPES.MVT }, null); + const docField = esSearchSource.createField({ fieldName: 'prop1' }); + expect(docField.canReadFromGeoJson()).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.test.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.test.tsx index 6e56c179b4ead..f57335db14c62 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.test.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.test.tsx @@ -27,28 +27,46 @@ const defaultProps = { termFields: [], topHitsSplitField: null, topHitsSize: 1, + supportsMvt: true, + mvtDisabledReason: null, }; -test('should render', async () => { - const component = shallow(); +describe('scaling form', () => { + test('should render', async () => { + const component = shallow(); - expect(component).toMatchSnapshot(); -}); + expect(component).toMatchSnapshot(); + }); -test('should disable clusters option when clustering is not supported', async () => { - const component = shallow( - - ); + test('should disable clusters option when clustering is not supported', async () => { + const component = shallow( + + ); - expect(component).toMatchSnapshot(); -}); + expect(component).toMatchSnapshot(); + }); + + test('should render top hits form when scaling type is TOP_HITS', async () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); -test('should render top hits form when scaling type is TOP_HITS', async () => { - const component = shallow(); + test('should disable mvt option when mvt is not supported', async () => { + const component = shallow( + + ); - expect(component).toMatchSnapshot(); + expect(component).toMatchSnapshot(); + }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.tsx index 816db6a98d593..cc2d4d059a3a8 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.tsx @@ -4,16 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, Component } from 'react'; +import React, { Component, Fragment } from 'react'; import { EuiFormRow, + EuiHorizontalRule, + EuiRadio, + EuiSpacer, EuiSwitch, EuiSwitchEvent, EuiTitle, - EuiSpacer, - EuiHorizontalRule, - EuiRadio, EuiToolTip, + EuiBetaBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -24,8 +25,8 @@ import { ValidatedRange } from '../../../components/validated_range'; import { DEFAULT_MAX_INNER_RESULT_WINDOW, DEFAULT_MAX_RESULT_WINDOW, - SCALING_TYPES, LAYER_TYPE, + SCALING_TYPES, } from '../../../../common/constants'; // @ts-ignore import { loadIndexSettings } from './load_index_settings'; @@ -38,7 +39,9 @@ interface Props { onChange: (args: OnSourceChangeArgs) => void; scalingType: SCALING_TYPES; supportsClustering: boolean; + supportsMvt: boolean; clusteringDisabledReason?: string | null; + mvtDisabledReason?: string | null; termFields: IFieldType[]; topHitsSplitField: string | null; topHitsSize: number; @@ -80,8 +83,15 @@ export class ScalingForm extends Component { } _onScalingTypeChange = (optionId: string): void => { - const layerType = - optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; + let layerType; + if (optionId === SCALING_TYPES.CLUSTERS) { + layerType = LAYER_TYPE.BLENDED_VECTOR; + } else if (optionId === SCALING_TYPES.MVT) { + layerType = LAYER_TYPE.TILED_VECTOR; + } else { + layerType = LAYER_TYPE.VECTOR; + } + this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); }; @@ -177,9 +187,47 @@ export class ScalingForm extends Component { ); } + _renderMVTRadio() { + const labelText = i18n.translate('xpack.maps.source.esSearch.useMVTVectorTiles', { + defaultMessage: 'Use vector tiles', + }); + const mvtRadio = ( + this._onScalingTypeChange(SCALING_TYPES.MVT)} + disabled={!this.props.supportsMvt} + /> + ); + + const enabledInfo = ( + <> + + + {i18n.translate('xpack.maps.source.esSearch.mvtDescription', { + defaultMessage: 'Use vector tiles for faster display of large datasets.', + })} + + ); + + return !this.props.supportsMvt ? ( + + {mvtRadio} + + ) : ( + + {mvtRadio} + + ); + } + render() { let filterByBoundsSwitch; - if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { + if ( + this.props.scalingType === SCALING_TYPES.TOP_HITS || + this.props.scalingType === SCALING_TYPES.LIMIT + ) { filterByBoundsSwitch = ( { ); } - let scalingForm = null; + let topHitsOptionsForm = null; if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { - scalingForm = ( + topHitsOptionsForm = ( {this._renderTopHitsForm()} @@ -234,12 +282,12 @@ export class ScalingForm extends Component { onChange={() => this._onScalingTypeChange(SCALING_TYPES.TOP_HITS)} /> {this._renderClusteringRadio()} + {this._renderMVTRadio()} {filterByBoundsSwitch} - - {scalingForm} + {topHitsOptionsForm}
); } diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/update_source_editor.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/update_source_editor.js index 0701dbbaecdd5..c123c307c4895 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/update_source_editor.js +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/update_source_editor.js @@ -17,6 +17,8 @@ import { getTermsFields, getSourceFields, supportsGeoTileAgg, + supportsMvt, + getMvtDisabledReason, } from '../../../index_pattern_util'; import { SORT_ORDER } from '../../../../common/constants'; import { ESDocField } from '../../fields/es_doc_field'; @@ -42,6 +44,9 @@ export class UpdateSourceEditor extends Component { termFields: null, sortFields: null, supportsClustering: false, + supportsMvt: false, + mvtDisabledReason: null, + clusteringDisabledReason: null, }; componentDidMount() { @@ -94,9 +99,12 @@ export class UpdateSourceEditor extends Component { }); }); + const mvtSupported = supportsMvt(indexPattern, geoField.name); this.setState({ supportsClustering: supportsGeoTileAgg(geoField), + supportsMvt: mvtSupported, clusteringDisabledReason: getGeoTileAggNotSupportedReason(geoField), + mvtDisabledReason: mvtSupported ? null : getMvtDisabledReason(), sourceFields: sourceFields, termFields: getTermsFields(indexPattern.fields), //todo change term fields to use fields sortFields: indexPattern.fields.filter( @@ -207,7 +215,9 @@ export class UpdateSourceEditor extends Component { onChange={this.props.onChange} scalingType={this.props.scalingType} supportsClustering={this.state.supportsClustering} + supportsMvt={this.state.supportsMvt} clusteringDisabledReason={this.state.clusteringDisabledReason} + mvtDisabledReason={this.state.mvtDisabledReason} termFields={this.state.termFields} topHitsSplitField={this.props.topHitsSplitField} topHitsSize={this.props.topHitsSize} diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js index 8cc2aa018979b..56b830e9ff098 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js @@ -184,7 +184,7 @@ export class AbstractESSource extends AbstractVectorSource { const minLon = esBounds.top_left.lon; const maxLon = esBounds.bottom_right.lon; return { - minLon: minLon > maxLon ? minLon - 360 : minLon, + minLon: minLon > maxLon ? minLon - 360 : minLon, //fixes an ES bbox to straddle dateline maxLon, minLat: esBounds.bottom_right.lat, maxLat: esBounds.top_left.lat, diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts index 271505010f36a..fd9c179275444 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts @@ -14,6 +14,7 @@ import { MapExtent, MapFilters, MapQuery, + VectorSourceRequestMeta, VectorSourceSyncMeta, } from '../../../../common/descriptor_types'; import { VECTOR_SHAPE_TYPE } from '../../../../common/constants'; @@ -64,7 +65,7 @@ export class AbstractVectorSource extends AbstractSource implements IVectorSourc ): MapExtent | null; getGeoJsonWithMeta( layerName: string, - searchFilters: MapFilters, + searchFilters: VectorSourceRequestMeta, registerCancelCallback: (callback: () => void) => void ): Promise; @@ -79,7 +80,9 @@ export class AbstractVectorSource extends AbstractSource implements IVectorSourc } export interface ITiledSingleLayerVectorSource extends IVectorSource { - getUrlTemplateWithMeta(): Promise<{ + getUrlTemplateWithMeta( + searchFilters: VectorSourceRequestMeta + ): Promise<{ layerName: string; urlTemplate: string; minSourceZoom: number; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/field_meta/categorical_field_meta_popover.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/field_meta/categorical_field_meta_popover.tsx index e49c15c68b8db..2a544b94d760a 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/field_meta/categorical_field_meta_popover.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/field_meta/categorical_field_meta_popover.tsx @@ -14,6 +14,7 @@ import { FieldMetaOptions } from '../../../../../../common/descriptor_types'; type Props = { fieldMetaOptions: FieldMetaOptions; onChange: (fieldMetaOptions: FieldMetaOptions) => void; + switchDisabled: boolean; }; export function CategoricalFieldMetaPopover(props: Props) { @@ -34,6 +35,7 @@ export function CategoricalFieldMetaPopover(props: Props) { checked={props.fieldMetaOptions.isEnabled} onChange={onIsEnabledChange} compressed + disabled={props.switchDisabled} /> diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/field_meta/ordinal_field_meta_popover.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/field_meta/ordinal_field_meta_popover.tsx index 9086c4df31596..09be9d72af970 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/field_meta/ordinal_field_meta_popover.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/field_meta/ordinal_field_meta_popover.tsx @@ -40,6 +40,7 @@ type Props = { fieldMetaOptions: FieldMetaOptions; styleName: VECTOR_STYLES; onChange: (fieldMetaOptions: FieldMetaOptions) => void; + switchDisabled: boolean; }; export function OrdinalFieldMetaPopover(props: Props) { @@ -66,6 +67,7 @@ export function OrdinalFieldMetaPopover(props: Props) { checked={props.fieldMetaOptions.isEnabled} onChange={onIsEnabledChange} compressed + disabled={props.switchDisabled} /> diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.tsx.snap index c722e86512e52..34d2d7fb0cbbf 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.tsx.snap @@ -325,3 +325,29 @@ exports[`ordinal Should render ordinal legend as bands 1`] = `
`; + +exports[`renderFieldMetaPopover Should disable toggle when field is not backed by geojson source 1`] = ` + +`; + +exports[`renderFieldMetaPopover Should enable toggle when field is backed by geojson-source 1`] = ` + +`; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.tsx index c3610cbc78e15..de8f3b5c09175 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.tsx @@ -577,3 +577,39 @@ test('Should read out ordinal type correctly', async () => { expect(ordinalColorStyle2.isOrdinal()).toEqual(true); expect(ordinalColorStyle2.isCategorical()).toEqual(false); }); + +describe('renderFieldMetaPopover', () => { + test('Should enable toggle when field is backed by geojson-source', () => { + const colorStyle = makeProperty( + { + color: 'Blues', + type: undefined, + fieldMetaOptions, + }, + undefined, + mockField + ); + + const legendRow = colorStyle.renderFieldMetaPopover(() => {}); + expect(legendRow).toMatchSnapshot(); + }); + + test('Should disable toggle when field is not backed by geojson source', () => { + const nonGeoJsonField = Object.create(mockField); + nonGeoJsonField.canReadFromGeoJson = () => { + return false; + }; + const colorStyle = makeProperty( + { + color: 'Blues', + type: undefined, + fieldMetaOptions, + }, + undefined, + nonGeoJsonField + ); + + const legendRow = colorStyle.renderFieldMetaPopover(() => {}); + expect(legendRow).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx index b16755e69f92d..f6ab052497723 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx @@ -328,16 +328,20 @@ export class DynamicStyleProperty return null; } + const switchDisabled = !!this._field && !this._field.canReadFromGeoJson(); + return this.isCategorical() ? ( ) : ( ); } diff --git a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts index 8da6fa2318de9..0da6f632eb4a8 100644 --- a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts +++ b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts @@ -4,32 +4,48 @@ * you may not use this file except in compliance with the Elastic License. */ -import { GEO_JSON_TYPE, FEATURE_VISIBLE_PROPERTY_NAME } from '../../../common/constants'; +import { + GEO_JSON_TYPE, + FEATURE_VISIBLE_PROPERTY_NAME, + KBN_TOO_MANY_FEATURES_PROPERTY, +} from '../../../common/constants'; + +export const EXCLUDE_TOO_MANY_FEATURES_BOX = ['!=', ['get', KBN_TOO_MANY_FEATURES_PROPERTY], true]; const VISIBILITY_FILTER_CLAUSE = ['all', ['==', ['get', FEATURE_VISIBLE_PROPERTY_NAME], true]]; +const TOO_MANY_FEATURES_FILTER = ['all', EXCLUDE_TOO_MANY_FEATURES_BOX]; const CLOSED_SHAPE_MB_FILTER = [ - 'any', - ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], - ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], + ...TOO_MANY_FEATURES_FILTER, + [ + 'any', + ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], + ], ]; const VISIBLE_CLOSED_SHAPE_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, CLOSED_SHAPE_MB_FILTER]; const ALL_SHAPE_MB_FILTER = [ - 'any', - ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], - ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], - ['==', ['geometry-type'], GEO_JSON_TYPE.LINE_STRING], - ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_LINE_STRING], + ...TOO_MANY_FEATURES_FILTER, + [ + 'any', + ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], + ['==', ['geometry-type'], GEO_JSON_TYPE.LINE_STRING], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_LINE_STRING], + ], ]; const VISIBLE_ALL_SHAPE_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, ALL_SHAPE_MB_FILTER]; const POINT_MB_FILTER = [ - 'any', - ['==', ['geometry-type'], GEO_JSON_TYPE.POINT], - ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POINT], + ...TOO_MANY_FEATURES_FILTER, + [ + 'any', + ['==', ['geometry-type'], GEO_JSON_TYPE.POINT], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POINT], + ], ]; const VISIBLE_POINT_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, POINT_MB_FILTER]; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js index 87d6f8e1d8e71..edfeb3c76b104 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js +++ b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js @@ -8,6 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { FEATURE_ID_PROPERTY_NAME, LON_INDEX } from '../../../../../common/constants'; import { TooltipPopover } from './tooltip_popover'; +import { EXCLUDE_TOO_MANY_FEATURES_BOX } from '../../../../classes/util/mb_filter_expressions'; function justifyAnchorLocation(mbLngLat, targetFeature) { let popupAnchorLocation = [mbLngLat.lng, mbLngLat.lat]; // default popup location to mouse location @@ -79,7 +80,7 @@ export class TooltipControl extends React.Component { // - As empty object literal // To avoid ambiguity, normalize properties to empty object literal. const mbProperties = mbFeature.properties ? mbFeature.properties : {}; - //This keeps track of first properties (assuming these will be identical for features in different tiles + //This keeps track of first properties (assuming these will be identical for features in different tiles) uniqueFeatures.push({ id: featureId, layerId: layerId, @@ -175,7 +176,10 @@ export class TooltipControl extends React.Component { y: mbLngLatPoint.y + PADDING, }, ]; - return this.props.mbMap.queryRenderedFeatures(mbBbox, { layers: mbLayerIds }); + return this.props.mbMap.queryRenderedFeatures(mbBbox, { + layers: mbLayerIds, + filter: EXCLUDE_TOO_MANY_FEATURES_BOX, + }); } render() { diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/view.js b/x-pack/plugins/maps/public/connected_components/map/mb/view.js index 22c374aceedd5..eede1edf40cc4 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/view.js +++ b/x-pack/plugins/maps/public/connected_components/map/mb/view.js @@ -10,7 +10,11 @@ import { ResizeChecker } from '../../../../../../../src/plugins/kibana_utils/pub import { removeOrphanedSourcesAndLayers, addSpritesheetToMap } from './utils'; import { syncLayerOrder } from './sort_layers'; import { getGlyphUrl, isRetina } from '../../../meta'; -import { DECIMAL_DEGREES_PRECISION, ZOOM_PRECISION } from '../../../../common/constants'; +import { + DECIMAL_DEGREES_PRECISION, + KBN_TOO_MANY_FEATURES_IMAGE_ID, + ZOOM_PRECISION, +} from '../../../../common/constants'; import mapboxgl from 'mapbox-gl/dist/mapbox-gl-csp'; import mbWorkerUrl from '!!file-loader!mapbox-gl/dist/mapbox-gl-csp-worker'; import mbRtlPlugin from '!!file-loader!@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js'; @@ -143,6 +147,14 @@ export class MBMap extends React.Component { mbMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-left'); } + const tooManyFeaturesImageSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA7DgAAOw4BzLahgwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAARLSURBVHic7ZnPbxRVAMe/7735sWO3293ZlUItJsivCxEE0oTYRgu1FqTQoFSwKTYx8SAH/wHjj4vRozGGi56sMcW2UfqTEuOhppE0KJc2GIuKQFDY7qzdtrudX88D3YTUdFuQN8+k87ltZt7uZz958/bNLAGwBWsYKltANmEA2QKyCQPIFpBNGEC2gGzCALIFZBMGkC0gmzCAbAHZhAFkC8gmDCBbQDZhANkCslnzARQZH6oDpNs0D5UDSUIInePcOpPLfdfnODNBuwQWIAWwNOABwHZN0x8npE6hNLJ4DPWRyFSf40wE5VOEQPBjcR0g3YlE4ybGmtK+/1NzJtOZA/xSYwZMs3nG962T2ez3It2AANaA/kSidYuivOQBs5WM1fUnk6f0u+GXJUqIuUtVXx00zRbRfkIDfBqL7a1WlIYbjvNtTTr99jXXHVpH6dMjK0R4cXq6c9rzxjcx9sKX8XitSEdhAToMI7VP10/97fsTh7PZrgWAN1lW72KE2vOm2b5chDTgtWQyn93x/bEEIetEOQIC14CxVOr1CkKefH929t0v8vn0vcdGEoljGxXl4C3PGz2YyXy+AHARDqtByAxoUdWKBKV70r4/vvTLA0CjZfX+5nkDGxirKzUTgkBIgNaysh3gnF627R+XO+dQJvP1ddcdrmSsbtA020pF+CAW21qrqmUiXIUEqGRsIwD0FQq/lzqv0bJ6rrvucBVjzwyb5ivLRTiiaW+8VV7eIEBVTAANiIIQd9RxZlc6t9Gyem647vn1jD07ZJonl4sQASoevqmgABzwwHnJzc69PGdZ3X+47sgGxuqHTPPE0ggeVtg5/QeEBMhxPg1Aa1DV2GrHPG9ZXy1G2D+wNALn9jyQEeHKAJgP+033Kgrdqij7AFwZtu3bqx3XWShMHtV1o1pRGo4YxiNd+fyEB2DKdX/4aG5u0hbwcylkBryTy/3scT6zW9Nq7ndso2Wdvea6Q1WUHuiPx1/WAXLBcWZXun94UMRcAoD/p+ddTFK6u8MwUvc7vsmyem+67oVqVT0wkEgcF+FYRNhW+L25uX6f84XThtHxIBudE5bVY/t++jFVrU/dvVSFICzAqG3PX/S8rihj2/61qK1AOUB7ksl2jdLUL7Z9rvgcQQRCFsEi5wqFmw26XnhCUQ63GcZmCly95Lrzpca0G0byk3j8tEnpU1c975tmyxoU5QcE8EAEAM5WVOzfoarHAeC2749dcpzxMwsLv07Ztg0AOzVNf03Ttu/S9T2PMlbjc25fdpyutmx2TLRbIAEA4M1otKo1EjmaoHQn4ZwBgA/kAVAK6MXXdzxv/ONcrq/HcbJBeAUWoEizqsaORaPbKglZrxMSZZyrM76f/ovzWx/m85PFWREUgQf4v7Hm/xcIA8gWkE0YQLaAbMIAsgVkEwaQLSCbMIBsAdmEAWQLyCYMIFtANmEA2QKyCQPIFpDNmg/wD3OFdEybUvJjAAAAAElFTkSuQmCC'; + const tooManyFeaturesImage = new Image(); + tooManyFeaturesImage.onload = () => { + mbMap.addImage(KBN_TOO_MANY_FEATURES_IMAGE_ID, tooManyFeaturesImage); + }; + tooManyFeaturesImage.src = tooManyFeaturesImageSrc; + let emptyImage; mbMap.on('styleimagemissing', (e) => { if (emptyImage) { diff --git a/x-pack/plugins/maps/public/index_pattern_util.ts b/x-pack/plugins/maps/public/index_pattern_util.ts index 4b4bfb41990b9..bd2a14619ac41 100644 --- a/x-pack/plugins/maps/public/index_pattern_util.ts +++ b/x-pack/plugins/maps/public/index_pattern_util.ts @@ -81,6 +81,16 @@ export function supportsGeoTileAgg(field?: IFieldType): boolean { ); } +export function supportsMvt(indexPattern: IndexPattern, geoFieldName: string): boolean { + const field = indexPattern.fields.getByName(geoFieldName); + return !!field && field.type === ES_GEO_FIELD_TYPE.GEO_SHAPE; +} + +export function getMvtDisabledReason() { + return i18n.translate('xpack.maps.mbt.disabled', { + defaultMessage: 'Display as vector tiles is only supported for geo_shape field-types.', + }); +} // Returns filtered fields list containing only fields that exist in _source. export function getSourceFields(fields: IFieldType[]): IFieldType[] { return fields.filter((field) => { diff --git a/x-pack/plugins/maps/server/mvt/__tests__/json/0_0_0_search.json b/x-pack/plugins/maps/server/mvt/__tests__/json/0_0_0_search.json new file mode 100644 index 0000000000000..0fc99ffd811f7 --- /dev/null +++ b/x-pack/plugins/maps/server/mvt/__tests__/json/0_0_0_search.json @@ -0,0 +1 @@ +{"took":0,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":1,"relation":"eq"},"max_score":0,"hits":[{"_index":"poly","_id":"G7PRMXQBgyyZ-h5iYibj","_score":0,"_source":{"coordinates":{"coordinates":[[[-106.171875,36.59788913307022],[-50.625,-22.91792293614603],[4.921875,42.8115217450979],[-33.046875,63.54855223203644],[-66.796875,63.860035895395306],[-106.171875,36.59788913307022]]],"type":"polygon"}}}]}} diff --git a/x-pack/plugins/maps/server/mvt/__tests__/pbf/0_0_0.pbf b/x-pack/plugins/maps/server/mvt/__tests__/pbf/0_0_0.pbf new file mode 100644 index 0000000000000000000000000000000000000000..9a9296e2ece3f94ac726f6a83f2958ba2e22074b GIT binary patch literal 155 zcmb1|!C1k>#Z#PLT9lj`pOaXbTBOmSAfzP3#=yYH$iyVUtR%)cfww_Yse%1Hdjp%m z1GW`x?>R5<@Jq49XXd4(R!A|&XQoIA$H!+U<;BORr6!h?7Nr7(;^URrxL6AEb1Id@ lxJ2B|1A=@b0-e$;E2DHXOfw@hld_a#xuikzR@fx13;_42EcXBa literal 0 HcmV?d00001 diff --git a/x-pack/plugins/maps/server/mvt/__tests__/tile_searches.ts b/x-pack/plugins/maps/server/mvt/__tests__/tile_searches.ts new file mode 100644 index 0000000000000..317d6434cf81e --- /dev/null +++ b/x-pack/plugins/maps/server/mvt/__tests__/tile_searches.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as path from 'path'; +import * as fs from 'fs'; + +const search000path = path.resolve(__dirname, './json/0_0_0_search.json'); +const search000raw = fs.readFileSync(search000path); +const search000json = JSON.parse((search000raw as unknown) as string); + +export const TILE_SEARCHES = { + '0.0.0': { + countResponse: { + count: 1, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + }, + searchResponse: search000json, + }, + '1.1.0': {}, +}; diff --git a/x-pack/plugins/maps/server/mvt/get_tile.test.ts b/x-pack/plugins/maps/server/mvt/get_tile.test.ts new file mode 100644 index 0000000000000..b9c928d594539 --- /dev/null +++ b/x-pack/plugins/maps/server/mvt/get_tile.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getTile } from './get_tile'; +import { TILE_SEARCHES } from './__tests__/tile_searches'; +import { Logger } from 'src/core/server'; +import * as path from 'path'; +import * as fs from 'fs'; + +describe('getTile', () => { + const mockCallElasticsearch = jest.fn(); + + const requestBody = { + _source: { excludes: [] }, + docvalue_fields: [], + query: { bool: { filter: [{ match_all: {} }], must: [], must_not: [], should: [] } }, + script_fields: {}, + size: 10000, + stored_fields: ['*'], + }; + const geometryFieldName = 'coordinates'; + + beforeEach(() => { + mockCallElasticsearch.mockReset(); + }); + + test('0.0.0 - under limit', async () => { + mockCallElasticsearch.mockImplementation((type) => { + if (type === 'count') { + return TILE_SEARCHES['0.0.0'].countResponse; + } else if (type === 'search') { + return TILE_SEARCHES['0.0.0'].searchResponse; + } else { + throw new Error(`${type} not recognized`); + } + }); + + const tile = await getTile({ + x: 0, + y: 0, + z: 0, + index: 'world_countries', + requestBody, + geometryFieldName, + logger: ({ + info: () => {}, + } as unknown) as Logger, + callElasticsearch: mockCallElasticsearch, + }); + + if (tile === null) { + throw new Error('Tile should be created'); + } + + const expectedPath = path.resolve(__dirname, './__tests__/pbf/0_0_0.pbf'); + const expectedBin = fs.readFileSync(expectedPath, 'binary'); + const expectedTile = Buffer.from(expectedBin, 'binary'); + expect(expectedTile.equals(tile)).toBe(true); + }); +}); diff --git a/x-pack/plugins/maps/server/mvt/get_tile.ts b/x-pack/plugins/maps/server/mvt/get_tile.ts new file mode 100644 index 0000000000000..9621f7f174a30 --- /dev/null +++ b/x-pack/plugins/maps/server/mvt/get_tile.ts @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-expect-error +import geojsonvt from 'geojson-vt'; +// @ts-expect-error +import vtpbf from 'vt-pbf'; +import { Logger } from 'src/core/server'; +import { Feature, FeatureCollection, Polygon } from 'geojson'; +import { + ES_GEO_FIELD_TYPE, + FEATURE_ID_PROPERTY_NAME, + KBN_TOO_MANY_FEATURES_PROPERTY, + MVT_SOURCE_LAYER_NAME, +} from '../../common/constants'; + +import { hitsToGeoJson } from '../../common/elasticsearch_geo_utils'; +import { flattenHit } from './util'; + +interface ESBounds { + top_left: { + lon: number; + lat: number; + }; + bottom_right: { + lon: number; + lat: number; + }; +} + +export async function getTile({ + logger, + callElasticsearch, + index, + geometryFieldName, + x, + y, + z, + requestBody = {}, +}: { + x: number; + y: number; + z: number; + geometryFieldName: string; + index: string; + callElasticsearch: (type: string, ...args: any[]) => Promise; + logger: Logger; + requestBody: any; +}): Promise { + const geojsonBbox = tileToGeoJsonPolygon(x, y, z); + + let resultFeatures: Feature[]; + try { + let result; + try { + const geoShapeFilter = { + geo_shape: { + [geometryFieldName]: { + shape: geojsonBbox, + relation: 'INTERSECTS', + }, + }, + }; + requestBody.query.bool.filter.push(geoShapeFilter); + + const esSearchQuery = { + index, + body: requestBody, + }; + + const esCountQuery = { + index, + body: { + query: requestBody.query, + }, + }; + + const countResult = await callElasticsearch('count', esCountQuery); + + // @ts-expect-error + if (countResult.count > requestBody.size) { + // Generate "too many features"-bounds + const bboxAggName = 'data_bounds'; + const bboxQuery = { + index, + body: { + size: 0, + query: requestBody.query, + aggs: { + [bboxAggName]: { + geo_bounds: { + field: geometryFieldName, + }, + }, + }, + }, + }; + + const bboxResult = await callElasticsearch('search', bboxQuery); + + // @ts-expect-error + const bboxForData = esBboxToGeoJsonPolygon(bboxResult.aggregations[bboxAggName].bounds); + + resultFeatures = [ + { + type: 'Feature', + properties: { + [KBN_TOO_MANY_FEATURES_PROPERTY]: true, + }, + geometry: bboxForData, + }, + ]; + } else { + // Perform actual search + result = await callElasticsearch('search', esSearchQuery); + + // Todo: pass in epochMillies-fields + const featureCollection = hitsToGeoJson( + // @ts-expect-error + result.hits.hits, + (hit: Record) => { + return flattenHit(geometryFieldName, hit); + }, + geometryFieldName, + ES_GEO_FIELD_TYPE.GEO_SHAPE, + [] + ); + + resultFeatures = featureCollection.features; + + // Correct system-fields. + for (let i = 0; i < resultFeatures.length; i++) { + const props = resultFeatures[i].properties; + if (props !== null) { + props[FEATURE_ID_PROPERTY_NAME] = resultFeatures[i].id; + } + } + } + } catch (e) { + logger.warn(e.message); + throw e; + } + + const featureCollection: FeatureCollection = { + features: resultFeatures, + type: 'FeatureCollection', + }; + + const tileIndex = geojsonvt(featureCollection, { + maxZoom: 24, // max zoom to preserve detail on; can't be higher than 24 + tolerance: 3, // simplification tolerance (higher means simpler) + extent: 4096, // tile extent (both width and height) + buffer: 64, // tile buffer on each side + debug: 0, // logging level (0 to disable, 1 or 2) + lineMetrics: false, // whether to enable line metrics tracking for LineString/MultiLineString features + promoteId: null, // name of a feature property to promote to feature.id. Cannot be used with `generateId` + generateId: false, // whether to generate feature ids. Cannot be used with `promoteId` + indexMaxZoom: 5, // max zoom in the initial tile index + indexMaxPoints: 100000, // max number of points per tile in the index + }); + const tile = tileIndex.getTile(z, x, y); + + if (tile) { + const pbf = vtpbf.fromGeojsonVt({ [MVT_SOURCE_LAYER_NAME]: tile }, { version: 2 }); + return Buffer.from(pbf); + } else { + return null; + } + } catch (e) { + logger.warn(`Cannot generate tile for ${z}/${x}/${y}: ${e.message}`); + return null; + } +} + +function tileToGeoJsonPolygon(x: number, y: number, z: number): Polygon { + const wLon = tile2long(x, z); + const sLat = tile2lat(y + 1, z); + const eLon = tile2long(x + 1, z); + const nLat = tile2lat(y, z); + + return { + type: 'Polygon', + coordinates: [ + [ + [wLon, sLat], + [wLon, nLat], + [eLon, nLat], + [eLon, sLat], + [wLon, sLat], + ], + ], + }; +} + +function tile2long(x: number, z: number): number { + return (x / Math.pow(2, z)) * 360 - 180; +} + +function tile2lat(y: number, z: number): number { + const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z); + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); +} + +function esBboxToGeoJsonPolygon(esBounds: ESBounds): Polygon { + let minLon = esBounds.top_left.lon; + const maxLon = esBounds.bottom_right.lon; + minLon = minLon > maxLon ? minLon - 360 : minLon; // fixes an ES bbox to straddle dateline + const minLat = esBounds.bottom_right.lat; + const maxLat = esBounds.top_left.lat; + + return { + type: 'Polygon', + coordinates: [ + [ + [minLon, minLat], + [minLon, maxLat], + [maxLon, maxLat], + [maxLon, minLat], + [minLon, minLat], + ], + ], + }; +} diff --git a/x-pack/plugins/maps/server/mvt/mvt_routes.ts b/x-pack/plugins/maps/server/mvt/mvt_routes.ts new file mode 100644 index 0000000000000..32c14a355ba2a --- /dev/null +++ b/x-pack/plugins/maps/server/mvt/mvt_routes.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import rison from 'rison-node'; +import { schema } from '@kbn/config-schema'; +import { Logger } from 'src/core/server'; +import { IRouter } from 'src/core/server'; +import { MVT_GETTILE_API_PATH, API_ROOT_PATH } from '../../common/constants'; +import { getTile } from './get_tile'; + +const CACHE_TIMEOUT = 0; // Todo. determine good value. Unsure about full-implications (e.g. wrt. time-based data). + +export function initMVTRoutes({ router, logger }: { logger: Logger; router: IRouter }) { + router.get( + { + path: `${API_ROOT_PATH}/${MVT_GETTILE_API_PATH}`, + validate: { + query: schema.object({ + x: schema.number(), + y: schema.number(), + z: schema.number(), + geometryFieldName: schema.string(), + requestBody: schema.string(), + index: schema.string(), + }), + }, + }, + async (context, request, response) => { + const { query } = request; + + const callElasticsearch = async (type: string, ...args: any[]): Promise => { + return await context.core.elasticsearch.legacy.client.callAsCurrentUser(type, ...args); + }; + + const requestBodyDSL = rison.decode(query.requestBody); + + const tile = await getTile({ + logger, + callElasticsearch, + geometryFieldName: query.geometryFieldName, + x: query.x, + y: query.y, + z: query.z, + index: query.index, + requestBody: requestBodyDSL, + }); + + if (tile) { + return response.ok({ + body: tile, + headers: { + 'content-disposition': 'inline', + 'content-length': `${tile.length}`, + 'Content-Type': 'application/x-protobuf', + 'Cache-Control': `max-age=${CACHE_TIMEOUT}`, + }, + }); + } else { + return response.ok({ + headers: { + 'content-disposition': 'inline', + 'content-length': '0', + 'Content-Type': 'application/x-protobuf', + 'Cache-Control': `max-age=${CACHE_TIMEOUT}`, + }, + }); + } + } + ); +} diff --git a/x-pack/plugins/maps/server/mvt/util.ts b/x-pack/plugins/maps/server/mvt/util.ts new file mode 100644 index 0000000000000..eb85468dd770d --- /dev/null +++ b/x-pack/plugins/maps/server/mvt/util.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; + * you may not use this file except in compliance with the Elastic License. + */ + +// This implementation: +// - does not include meta-fields +// - does not validate the schema against the index-pattern (e.g. nested fields) +// In the context of .mvt this is sufficient: +// - only fields from the response are packed in the tile (more efficient) +// - query-dsl submitted from the client, which was generated by the IndexPattern +// todo: Ideally, this should adapt/reuse from https://github.com/elastic/kibana/blob/52b42a81faa9dd5c102b9fbb9a645748c3623121/src/plugins/data/common/index_patterns/index_patterns/flatten_hit.ts#L26 +import { GeoJsonProperties } from 'geojson'; + +export function flattenHit(geometryField: string, hit: Record): GeoJsonProperties { + const flat: GeoJsonProperties = {}; + if (hit) { + flattenSource(flat, '', hit._source as Record, geometryField); + if (hit.fields) { + flattenFields(flat, hit.fields as Array>); + } + + // Attach meta fields + flat._index = hit._index; + flat._id = hit._id; + } + return flat; +} + +function flattenSource( + accum: GeoJsonProperties, + path: string, + properties: Record = {}, + geometryField: string +): GeoJsonProperties { + accum = accum || {}; + for (const key in properties) { + if (properties.hasOwnProperty(key)) { + const newKey = path ? path + '.' + key : key; + let value; + if (geometryField === newKey) { + value = properties[key]; // do not deep-copy the geometry + } else if (properties[key] !== null && typeof value === 'object' && !Array.isArray(value)) { + value = flattenSource( + accum, + newKey, + properties[key] as Record, + geometryField + ); + } else { + value = properties[key]; + } + accum[newKey] = value; + } + } + return accum; +} + +function flattenFields(accum: GeoJsonProperties = {}, fields: Array>) { + accum = accum || {}; + for (const key in fields) { + if (fields.hasOwnProperty(key)) { + const value = fields[key]; + if (Array.isArray(value)) { + accum[key] = value[0]; + } else { + accum[key] = value; + } + } + } +} diff --git a/x-pack/plugins/maps/server/routes.js b/x-pack/plugins/maps/server/routes.js index 1876c0de19c56..6b19103b59722 100644 --- a/x-pack/plugins/maps/server/routes.js +++ b/x-pack/plugins/maps/server/routes.js @@ -22,6 +22,7 @@ import { EMS_SPRITES_PATH, INDEX_SETTINGS_API_PATH, FONTS_API_PATH, + API_ROOT_PATH, } from '../common/constants'; import { EMSClient } from '@elastic/ems-client'; import fetch from 'node-fetch'; @@ -30,8 +31,7 @@ import { getIndexPatternSettings } from './lib/get_index_pattern_settings'; import { schema } from '@kbn/config-schema'; import fs from 'fs'; import path from 'path'; - -const ROOT = `/${GIS_API_PATH}`; +import { initMVTRoutes } from './mvt/mvt_routes'; export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { let emsClient; @@ -69,7 +69,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_FILES_API_PATH}/${EMS_FILES_DEFAULT_JSON_PATH}`, + path: `${API_ROOT_PATH}/${EMS_FILES_API_PATH}/${EMS_FILES_DEFAULT_JSON_PATH}`, validate: { query: schema.object({ id: schema.maybe(schema.string()), @@ -109,7 +109,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_RASTER_TILE_PATH}`, + path: `${API_ROOT_PATH}/${EMS_TILES_API_PATH}/${EMS_TILES_RASTER_TILE_PATH}`, validate: false, }, async (context, request, response) => { @@ -145,7 +145,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_CATALOGUE_PATH}`, + path: `${API_ROOT_PATH}/${EMS_CATALOGUE_PATH}`, validate: false, }, async (context, request, { ok, badRequest }) => { @@ -181,7 +181,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_FILES_CATALOGUE_PATH}/{emsVersion}/manifest`, + path: `${API_ROOT_PATH}/${EMS_FILES_CATALOGUE_PATH}/{emsVersion}/manifest`, validate: false, }, async (context, request, { ok, badRequest }) => { @@ -213,7 +213,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_TILES_CATALOGUE_PATH}/{emsVersion}/manifest`, + path: `${API_ROOT_PATH}/${EMS_TILES_CATALOGUE_PATH}/{emsVersion}/manifest`, validate: false, }, async (context, request, { ok, badRequest }) => { @@ -257,7 +257,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_RASTER_STYLE_PATH}`, + path: `${API_ROOT_PATH}/${EMS_TILES_API_PATH}/${EMS_TILES_RASTER_STYLE_PATH}`, validate: { query: schema.object({ id: schema.maybe(schema.string()), @@ -293,7 +293,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_STYLE_PATH}`, + path: `${API_ROOT_PATH}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_STYLE_PATH}`, validate: { query: schema.object({ id: schema.string(), @@ -341,7 +341,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_SOURCE_PATH}`, + path: `${API_ROOT_PATH}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_SOURCE_PATH}`, validate: { query: schema.object({ id: schema.string(), @@ -379,7 +379,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_TILE_PATH}`, + path: `${API_ROOT_PATH}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_TILE_PATH}`, validate: { query: schema.object({ id: schema.string(), @@ -417,7 +417,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_GLYPHS_PATH}/{fontstack}/{range}`, + path: `${API_ROOT_PATH}/${EMS_TILES_API_PATH}/${EMS_GLYPHS_PATH}/{fontstack}/{range}`, validate: { params: schema.object({ fontstack: schema.string(), @@ -439,7 +439,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { router.get( { - path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_SPRITES_PATH}/{id}/sprite{scaling?}.{extension}`, + path: `${API_ROOT_PATH}/${EMS_TILES_API_PATH}/${EMS_SPRITES_PATH}/{id}/sprite{scaling?}.{extension}`, validate: { query: schema.object({ elastic_tile_service_tos: schema.maybe(schema.string()), @@ -591,4 +591,6 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return response.badRequest(`Cannot connect to EMS`); } } + + initMVTRoutes({ router, logger }); } diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js new file mode 100644 index 0000000000000..7219fc858e059 --- /dev/null +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ getService }) { + const supertest = getService('supertest'); + + describe('getTile', () => { + it('should validate params', async () => { + await supertest + .get( + `/api/maps/mvt/getTile?x=15&y=11&z=5&geometryFieldName=coordinates&index=logstash*&requestBody=(_source:(includes:!(coordinates)),docvalue_fields:!(),query:(bool:(filter:!((match_all:())),must:!(),must_not:!(),should:!())),script_fields:(),size:10000,stored_fields:!(coordinates))` + ) + .set('kbn-xsrf', 'kibana') + .expect(200); + }); + + it('should not validate when required params are missing', async () => { + await supertest + .get( + `/api/maps/mvt/getTile?&index=logstash*&requestBody=(_source:(includes:!(coordinates)),docvalue_fields:!(),query:(bool:(filter:!((match_all:())),must:!(),must_not:!(),should:!())),script_fields:(),size:10000,stored_fields:!(coordinates))` + ) + .set('kbn-xsrf', 'kibana') + .expect(400); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/maps/index.js b/x-pack/test/api_integration/apis/maps/index.js index f9dff19229645..6c213380dd64e 100644 --- a/x-pack/test/api_integration/apis/maps/index.js +++ b/x-pack/test/api_integration/apis/maps/index.js @@ -16,6 +16,7 @@ export default function ({ loadTestFile, getService }) { loadTestFile(require.resolve('./fonts_api')); loadTestFile(require.resolve('./index_settings')); loadTestFile(require.resolve('./migrations')); + loadTestFile(require.resolve('./get_tile')); }); }); } diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index 4bbe38367d0a2..ef8b4ad4c0f19 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -46,6 +46,7 @@ export default function ({ loadTestFile, getService }) { loadTestFile(require.resolve('./es_geo_grid_source')); loadTestFile(require.resolve('./es_pew_pew_source')); loadTestFile(require.resolve('./joins')); + loadTestFile(require.resolve('./mvt_scaling')); loadTestFile(require.resolve('./add_layer_panel')); loadTestFile(require.resolve('./import_geojson')); loadTestFile(require.resolve('./layer_errors')); diff --git a/x-pack/test/functional/apps/maps/joins.js b/x-pack/test/functional/apps/maps/joins.js index e447996a08dfe..1139ae204aefd 100644 --- a/x-pack/test/functional/apps/maps/joins.js +++ b/x-pack/test/functional/apps/maps/joins.js @@ -5,7 +5,6 @@ */ import expect from '@kbn/expect'; -import { set } from '@elastic/safer-lodash-set'; import { MAPBOX_STYLES } from './mapbox_styles'; @@ -21,6 +20,7 @@ const VECTOR_SOURCE_ID = 'n1t6f'; const CIRCLE_STYLE_LAYER_INDEX = 0; const FILL_STYLE_LAYER_INDEX = 2; const LINE_STYLE_LAYER_INDEX = 3; +const TOO_MANY_FEATURES_LAYER_INDEX = 4; export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); @@ -87,28 +87,32 @@ export default function ({ getPageObjects, getService }) { }); }); - it('should style fills, points and lines independently', async () => { + it('should style fills, points, lines, and bounding-boxes independently', async () => { const mapboxStyle = await PageObjects.maps.getMapboxStyle(); const layersForVectorSource = mapboxStyle.layers.filter((mbLayer) => { return mbLayer.id.startsWith(VECTOR_SOURCE_ID); }); - // Color is dynamically obtained from eui source lib - const dynamicColor = - layersForVectorSource[CIRCLE_STYLE_LAYER_INDEX].paint['circle-stroke-color']; - //circle layer for points - expect(layersForVectorSource[CIRCLE_STYLE_LAYER_INDEX]).to.eql( - set(MAPBOX_STYLES.POINT_LAYER, 'paint.circle-stroke-color', dynamicColor) - ); + expect(layersForVectorSource[CIRCLE_STYLE_LAYER_INDEX]).to.eql(MAPBOX_STYLES.POINT_LAYER); //fill layer expect(layersForVectorSource[FILL_STYLE_LAYER_INDEX]).to.eql(MAPBOX_STYLES.FILL_LAYER); //line layer for borders - expect(layersForVectorSource[LINE_STYLE_LAYER_INDEX]).to.eql( - set(MAPBOX_STYLES.LINE_LAYER, 'paint.line-color', dynamicColor) - ); + expect(layersForVectorSource[LINE_STYLE_LAYER_INDEX]).to.eql(MAPBOX_STYLES.LINE_LAYER); + + //Too many features layer (this is a static style config) + expect(layersForVectorSource[TOO_MANY_FEATURES_LAYER_INDEX]).to.eql({ + id: 'n1t6f_toomanyfeatures', + type: 'fill', + source: 'n1t6f', + minzoom: 0, + maxzoom: 24, + filter: ['==', ['get', '__kbn_too_many_features__'], true], + layout: { visibility: 'visible' }, + paint: { 'fill-pattern': '__kbn_too_many_features_image_id__', 'fill-opacity': 0.75 }, + }); }); it('should flag only the joined features as visible', async () => { diff --git a/x-pack/test/functional/apps/maps/mapbox_styles.js b/x-pack/test/functional/apps/maps/mapbox_styles.js index 744eb4ac74bf6..78720fa1689ec 100644 --- a/x-pack/test/functional/apps/maps/mapbox_styles.js +++ b/x-pack/test/functional/apps/maps/mapbox_styles.js @@ -14,7 +14,11 @@ export const MAPBOX_STYLES = { filter: [ 'all', ['==', ['get', '__kbn_isvisibleduetojoin__'], true], - ['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']], + [ + 'all', + ['!=', ['get', '__kbn_too_many_features__'], true], + ['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']], + ], ], layout: { visibility: 'visible' }, paint: { @@ -84,7 +88,11 @@ export const MAPBOX_STYLES = { filter: [ 'all', ['==', ['get', '__kbn_isvisibleduetojoin__'], true], - ['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']], + [ + 'all', + ['!=', ['get', '__kbn_too_many_features__'], true], + ['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']], + ], ], layout: { visibility: 'visible' }, paint: { @@ -151,20 +159,18 @@ export const MAPBOX_STYLES = { 'all', ['==', ['get', '__kbn_isvisibleduetojoin__'], true], [ - 'any', - ['==', ['geometry-type'], 'Polygon'], - ['==', ['geometry-type'], 'MultiPolygon'], - ['==', ['geometry-type'], 'LineString'], - ['==', ['geometry-type'], 'MultiLineString'], + 'all', + ['!=', ['get', '__kbn_too_many_features__'], true], + [ + 'any', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['geometry-type'], 'MultiPolygon'], + ['==', ['geometry-type'], 'LineString'], + ['==', ['geometry-type'], 'MultiLineString'], + ], ], ], - layout: { - visibility: 'visible', - }, - paint: { - /* 'line-color': '' */ // Obtained dynamically - 'line-opacity': 0.75, - 'line-width': 1, - }, + layout: { visibility: 'visible' }, + paint: { 'line-color': '#41937c', 'line-opacity': 0.75, 'line-width': 1 }, }, }; diff --git a/x-pack/test/functional/apps/maps/mvt_scaling.js b/x-pack/test/functional/apps/maps/mvt_scaling.js new file mode 100644 index 0000000000000..e50b72658fb43 --- /dev/null +++ b/x-pack/test/functional/apps/maps/mvt_scaling.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +const VECTOR_SOURCE_ID = 'caffa63a-ebfb-466d-8ff6-d797975b88ab'; + +export default function ({ getPageObjects, getService }) { + const PageObjects = getPageObjects(['maps']); + const inspector = getService('inspector'); + + describe('mvt geoshape layer', () => { + before(async () => { + await PageObjects.maps.loadSavedMap('geo_shape_mvt'); + }); + + after(async () => { + await inspector.close(); + }); + + it('should render with mvt-source', async () => { + const mapboxStyle = await PageObjects.maps.getMapboxStyle(); + + //Source should be correct + expect(mapboxStyle.sources[VECTOR_SOURCE_ID].tiles[0]).to.equal( + '/api/maps/mvt/getTile?x={x}&y={y}&z={z}&geometryFieldName=geometry&index=geo_shapes*&requestBody=(_source:(includes:!(geometry,prop1)),docvalue_fields:!(prop1),query:(bool:(filter:!((match_all:())),must:!(),must_not:!(),should:!())),script_fields:(),size:10000,stored_fields:!(geometry,prop1))' + ); + + //Should correctly load meta for style-rule (sigma is set to 1, opacity to 1) + const fillLayer = mapboxStyle.layers.find((layer) => layer.id === VECTOR_SOURCE_ID + '_fill'); + expect(fillLayer.paint).to.eql({ + 'fill-color': [ + 'interpolate', + ['linear'], + [ + 'coalesce', + [ + 'case', + ['==', ['get', 'prop1'], null], + 0.3819660112501051, + [ + 'max', + ['min', ['to-number', ['get', 'prop1']], 3.618033988749895], + 1.381966011250105, + ], + ], + 0.3819660112501051, + ], + 0.3819660112501051, + 'rgba(0,0,0,0)', + 1.381966011250105, + '#ecf1f7', + 1.6614745084375788, + '#d9e3ef', + 1.9409830056250525, + '#c5d5e7', + 2.2204915028125263, + '#b2c7df', + 2.5, + '#9eb9d8', + 2.7795084971874737, + '#8bacd0', + 3.0590169943749475, + '#769fc8', + 3.338525491562421, + '#6092c0', + ], + 'fill-opacity': 1, + }); + }); + }); +} diff --git a/x-pack/test/functional/es_archives/maps/kibana/data.json b/x-pack/test/functional/es_archives/maps/kibana/data.json index 0f1fd3c09d706..f756d73484198 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -1008,6 +1008,40 @@ } } +{ + "type": "doc", + "value": { + "id": "map:bff99716-e3dc-11ea-87d0-0242ac130003", + "index": ".kibana", + "source": { + "map" : { + "description":"shapes with mvt scaling", + "layerListJSON":"[{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"isAutoSelect\":true},\"id\":\"76b9fc1d-1e8a-4d2f-9f9e-6ba2b19f24bb\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"style\":{\"type\":\"TILE\"},\"type\":\"VECTOR_TILE\"},{\"sourceDescriptor\":{\"geoField\":\"geometry\",\"filterByMapBounds\":true,\"scalingType\":\"MVT\",\"topHitsSize\":1,\"id\":\"97f8555e-8db0-4bd8-8b18-22e32f468667\",\"type\":\"ES_SEARCH\",\"tooltipProperties\":[],\"sortField\":\"\",\"sortOrder\":\"desc\",\"indexPatternRefName\":\"layer_1_source_index_pattern\"},\"id\":\"caffa63a-ebfb-466d-8ff6-d797975b88ab\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}},\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"color\":\"Blues\",\"colorCategory\":\"palette_0\",\"field\":{\"name\":\"prop1\",\"origin\":\"source\"},\"fieldMetaOptions\":{\"isEnabled\":true,\"sigma\":1},\"type\":\"ORDINAL\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#41937c\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":6}},\"iconOrientation\":{\"type\":\"STATIC\",\"options\":{\"orientation\":0}},\"labelText\":{\"type\":\"STATIC\",\"options\":{\"value\":\"\"}},\"labelColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#000000\"}},\"labelSize\":{\"type\":\"STATIC\",\"options\":{\"size\":14}},\"labelBorderColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"labelBorderSize\":{\"options\":{\"size\":\"SMALL\"}}},\"isTimeAware\":true},\"type\":\"TILED_VECTOR\",\"joins\":[]}]", + "mapStateJSON":"{\"zoom\":3.75,\"center\":{\"lon\":80.01106,\"lat\":3.65009},\"timeFilters\":{\"from\":\"now-15m\",\"to\":\"now\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":0},\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filters\":[],\"settings\":{\"autoFitToDataBounds\":false,\"initialLocation\":\"LAST_SAVED_LOCATION\",\"fixedLocation\":{\"lat\":0,\"lon\":0,\"zoom\":2},\"browserLocation\":{\"zoom\":2},\"maxZoom\":24,\"minZoom\":0,\"showSpatialFilters\":true,\"spatialFiltersAlpa\":0.3,\"spatialFiltersFillColor\":\"#DA8B45\",\"spatialFiltersLineColor\":\"#DA8B45\"}}", + "title":"geo_shape_mvt", + "uiStateJSON":"{\"isLayerTOCOpen\":true,\"openTOCDetails\":[]}" + }, + "type" : "map", + "references" : [ + { + "id":"561253e0-f731-11e8-8487-11b9dd924f96", + "name":"layer_1_source_index_pattern", + "type":"index-pattern" + } + ], + "migrationVersion" : { + "map" : "7.9.0" + }, + "updated_at" : "2020-08-10T18:27:39.805Z" + } + } +} + + + + + + { "type": "doc", "value": { From 1ae2a145fbf58fe07ecac030df727447f1377f4e Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Tue, 1 Sep 2020 14:34:45 -0400 Subject: [PATCH 05/53] [Dashboard First] Library Notification (#76122) Added a notification to show when an embeddable is saved to the Visualize Library --- .../public/application/actions/index.ts | 5 + .../library_notification_action.test.tsx | 100 ++++++++++++++++++ .../actions/library_notification_action.tsx | 89 ++++++++++++++++ src/plugins/dashboard/public/plugin.tsx | 17 ++- .../lib/panel/panel_header/panel_header.tsx | 5 +- 5 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx create mode 100644 src/plugins/dashboard/public/application/actions/library_notification_action.tsx diff --git a/src/plugins/dashboard/public/application/actions/index.ts b/src/plugins/dashboard/public/application/actions/index.ts index 4343a3409b696..cd32c2025456f 100644 --- a/src/plugins/dashboard/public/application/actions/index.ts +++ b/src/plugins/dashboard/public/application/actions/index.ts @@ -42,3 +42,8 @@ export { UnlinkFromLibraryActionContext, ACTION_UNLINK_FROM_LIBRARY, } from './unlink_from_library_action'; +export { + LibraryNotificationActionContext, + LibraryNotificationAction, + ACTION_LIBRARY_NOTIFICATION, +} from './library_notification_action'; diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx b/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx new file mode 100644 index 0000000000000..385f6f14ba94c --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx @@ -0,0 +1,100 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { isErrorEmbeddable, ReferenceOrValueEmbeddable } from '../../embeddable_plugin'; +import { DashboardContainer } from '../embeddable'; +import { getSampleDashboardInput } from '../test_helpers'; +import { + CONTACT_CARD_EMBEDDABLE, + ContactCardEmbeddableFactory, + ContactCardEmbeddable, + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, +} from '../../embeddable_plugin_test_samples'; +import { coreMock } from '../../../../../core/public/mocks'; +import { CoreStart } from 'kibana/public'; +import { LibraryNotificationAction } from '.'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { ViewMode } from '../../../../embeddable/public'; + +const { setup, doStart } = embeddablePluginMock.createInstance(); +setup.registerEmbeddableFactory( + CONTACT_CARD_EMBEDDABLE, + new ContactCardEmbeddableFactory((() => null) as any, {} as any) +); +const start = doStart(); + +let container: DashboardContainer; +let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable; +let coreStart: CoreStart; +beforeEach(async () => { + coreStart = coreMock.createStart(); + + const containerOptions = { + ExitFullScreenButton: () => null, + SavedObjectFinder: () => null, + application: {} as any, + embeddable: start, + inspector: {} as any, + notifications: {} as any, + overlays: coreStart.overlays, + savedObjectMetaData: {} as any, + uiActions: {} as any, + }; + + container = new DashboardContainer(getSampleDashboardInput(), containerOptions); + + const contactCardEmbeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Kibanana', + }); + + if (isErrorEmbeddable(contactCardEmbeddable)) { + throw new Error('Failed to create embeddable'); + } + embeddable = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + ContactCardEmbeddableInput + >(contactCardEmbeddable, { + mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: contactCardEmbeddable.id }, + mockedByValueInput: { firstName: 'Kibanana', id: contactCardEmbeddable.id }, + }); + embeddable.updateInput({ viewMode: ViewMode.EDIT }); +}); + +test('Notification is shown when embeddable on dashboard has reference type input', async () => { + const action = new LibraryNotificationAction(); + embeddable.updateInput(await embeddable.getInputAsRefType()); + expect(await action.isCompatible({ embeddable })).toBe(true); +}); + +test('Notification is not shown when embeddable input is by value', async () => { + const action = new LibraryNotificationAction(); + embeddable.updateInput(await embeddable.getInputAsValueType()); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); + +test('Notification is not shown when view mode is set to view', async () => { + const action = new LibraryNotificationAction(); + embeddable.updateInput(await embeddable.getInputAsRefType()); + embeddable.updateInput({ viewMode: ViewMode.VIEW }); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.tsx b/src/plugins/dashboard/public/application/actions/library_notification_action.tsx new file mode 100644 index 0000000000000..974b55275ccc1 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/library_notification_action.tsx @@ -0,0 +1,89 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; +import { IEmbeddable, ViewMode, isReferenceOrValueEmbeddable } from '../../embeddable_plugin'; +import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; +import { reactToUiComponent } from '../../../../kibana_react/public'; + +export const ACTION_LIBRARY_NOTIFICATION = 'ACTION_LIBRARY_NOTIFICATION'; + +export interface LibraryNotificationActionContext { + embeddable: IEmbeddable; +} + +export class LibraryNotificationAction implements ActionByType { + public readonly id = ACTION_LIBRARY_NOTIFICATION; + public readonly type = ACTION_LIBRARY_NOTIFICATION; + public readonly order = 1; + + private displayName = i18n.translate('dashboard.panel.LibraryNotification', { + defaultMessage: 'Library', + }); + + private icon = 'folderCheck'; + + public readonly MenuItem = reactToUiComponent(() => ( + + {this.displayName} + + )); + + public getDisplayName({ embeddable }: LibraryNotificationActionContext) { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return this.displayName; + } + + public getIconType({ embeddable }: LibraryNotificationActionContext) { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return this.icon; + } + + public getDisplayNameTooltip = ({ embeddable }: LibraryNotificationActionContext) => { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return i18n.translate('dashboard.panel.libraryNotification.toolTip', { + defaultMessage: + 'This panel is linked to a Library item. Editing the panel might affect other dashboards.', + }); + }; + + public isCompatible = async ({ embeddable }: LibraryNotificationActionContext) => { + return ( + embeddable.getInput()?.viewMode !== ViewMode.VIEW && + isReferenceOrValueEmbeddable(embeddable) && + embeddable.inputIsRefType(embeddable.getInput()) + ); + }; + + public execute = async () => {}; +} diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 8b9b92faf9031..3df52f4e7a205 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -40,6 +40,7 @@ import { EmbeddableStart, SavedObjectEmbeddableInput, EmbeddableInput, + PANEL_NOTIFICATION_TRIGGER, } from '../../embeddable/public'; import { DataPublicPluginSetup, DataPublicPluginStart, esFilters } from '../../data/public'; import { SharePluginSetup, SharePluginStart, UrlGeneratorContract } from '../../share/public'; @@ -83,6 +84,12 @@ import { ACTION_UNLINK_FROM_LIBRARY, UnlinkFromLibraryActionContext, UnlinkFromLibraryAction, + ACTION_ADD_TO_LIBRARY, + AddToLibraryActionContext, + AddToLibraryAction, + ACTION_LIBRARY_NOTIFICATION, + LibraryNotificationActionContext, + LibraryNotificationAction, } from './application'; import { createDashboardUrlGenerator, @@ -95,11 +102,6 @@ import { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; import { UrlGeneratorState } from '../../share/public'; import { AttributeService } from '.'; -import { - AddToLibraryAction, - ACTION_ADD_TO_LIBRARY, - AddToLibraryActionContext, -} from './application/actions/add_to_library_action'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -162,6 +164,7 @@ declare module '../../../plugins/ui_actions/public' { [ACTION_CLONE_PANEL]: ClonePanelActionContext; [ACTION_ADD_TO_LIBRARY]: AddToLibraryActionContext; [ACTION_UNLINK_FROM_LIBRARY]: UnlinkFromLibraryActionContext; + [ACTION_LIBRARY_NOTIFICATION]: LibraryNotificationActionContext; } } @@ -437,6 +440,10 @@ export class DashboardPlugin const unlinkFromLibraryAction = new UnlinkFromLibraryAction(); uiActions.registerAction(unlinkFromLibraryAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkFromLibraryAction.id); + + const libraryNotificationAction = new LibraryNotificationAction(); + uiActions.registerAction(libraryNotificationAction); + uiActions.attachAction(PANEL_NOTIFICATION_TRIGGER, libraryNotificationAction.id); } const savedDashboardLoader = createSavedDashboardLoader({ diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 5d7daaa7217ed..f3c4cae720193 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -31,6 +31,7 @@ import { Action } from 'src/plugins/ui_actions/public'; import { PanelOptionsMenu } from './panel_options_menu'; import { IEmbeddable } from '../../embeddables'; import { EmbeddableContext, panelBadgeTrigger, panelNotificationTrigger } from '../../triggers'; +import { uiToReactComponent } from '../../../../../kibana_react/public'; export interface PanelHeaderProps { title?: string; @@ -65,7 +66,9 @@ function renderNotifications( return notifications.map((notification) => { const context = { embeddable }; - let badge = ( + let badge = notification.MenuItem ? ( + React.createElement(uiToReactComponent(notification.MenuItem)) + ) : ( Date: Tue, 1 Sep 2020 21:30:45 +0200 Subject: [PATCH 06/53] [Detections Engine] Add Alert actions to the Timeline (#73228) --- .../security_solution/public/app/app.tsx | 6 +- .../public/app/home/index.tsx | 7 +- .../components/alerts_viewer/alerts_table.tsx | 5 +- .../alerts_viewer/histogram_configs.ts | 4 +- .../common/components/alerts_viewer/index.tsx | 4 +- .../events_viewer/events_viewer.test.tsx | 42 -- .../events_viewer/events_viewer.tsx | 7 +- .../common/components/events_viewer/index.tsx | 7 +- .../exceptions/add_exception_modal/index.tsx | 6 +- .../components/matrix_histogram/types.ts | 2 +- .../common/components/url_state/helpers.ts | 3 +- .../histogram_configs.ts | 4 +- .../public/common/mock/timeline_results.ts | 4 +- .../components/alerts_table/actions.test.tsx | 14 +- .../components/alerts_table/actions.tsx | 16 +- .../alerts_table/default_config.tsx | 229 +-------- .../components/alerts_table/index.test.tsx | 2 - .../components/alerts_table/index.tsx | 204 +------- .../timeline_actions/alert_context_menu.tsx | 484 ++++++++++++++++++ .../investigate_in_timeline_action.tsx | 85 +++ .../components/alerts_table/translations.ts | 7 + .../components/alerts_table/types.ts | 1 - .../detections/components/user_info/index.tsx | 2 +- .../detection_engine.test.tsx | 4 +- .../detection_engine/detection_engine.tsx | 22 +- .../pages/detection_engine/index.test.tsx | 4 +- .../pages/detection_engine/index.tsx | 43 +- .../rules/create/index.test.tsx | 4 +- .../detection_engine/rules/create/index.tsx | 18 +- .../rules/details/index.test.tsx | 4 +- .../detection_engine/rules/details/index.tsx | 22 +- .../rules/edit/index.test.tsx | 4 +- .../detection_engine/rules/edit/index.tsx | 18 +- .../detection_engine/rules/index.test.tsx | 4 +- .../pages/detection_engine/rules/index.tsx | 20 +- .../public/detections/routes.tsx | 13 +- .../public/graphql/introspection.json | 8 + .../security_solution/public/graphql/types.ts | 4 + .../authentications_query_tab_body.tsx | 4 +- .../navigation/events_query_tab_body.tsx | 16 +- .../pages/navigation/dns_query_tab_body.tsx | 6 +- .../components/alerts_by_category/index.tsx | 4 +- .../components/events_by_dataset/index.tsx | 4 +- .../fields_browser/field_browser.tsx | 10 +- .../components/fields_browser/header.tsx | 10 +- .../components/fields_browser/index.tsx | 2 - .../components/manage_timeline/index.test.tsx | 39 +- .../components/manage_timeline/index.tsx | 56 +- .../components/open_timeline/helpers.test.ts | 26 +- .../components/open_timeline/helpers.ts | 13 +- .../components/open_timeline/index.tsx | 5 +- .../body/actions/action_icon_item.tsx | 55 ++ .../body/actions/add_note_icon_item.tsx | 59 +++ .../timeline/body/actions/index.test.tsx | 228 --------- .../timeline/body/actions/index.tsx | 227 +++----- .../body/actions/pin_event_action.tsx | 58 +++ .../timeline/body/column_headers/index.tsx | 1 - .../body/events/event_column_view.test.tsx | 117 +++++ .../body/events/event_column_view.tsx | 246 ++++----- .../components/timeline/body/events/index.tsx | 6 +- .../timeline/body/events/stateful_event.tsx | 14 +- .../timeline/body/{helpers.ts => helpers.tsx} | 69 +-- .../components/timeline/body/index.test.tsx | 3 +- .../components/timeline/body/index.tsx | 35 +- .../timeline/body/stateful_body.tsx | 10 +- .../timeline/properties/helpers.tsx | 35 +- .../properties/new_template_timeline.tsx | 4 +- .../components/timeline/timeline.tsx | 11 +- .../timelines/containers/index.gql_query.ts | 1 + .../public/timelines/pages/timelines_page.tsx | 4 +- .../timeline/epic_local_storage.test.tsx | 4 +- .../server/graphql/ecs/schema.gql.ts | 1 + .../security_solution/server/graphql/types.ts | 9 + .../server/lib/ecs_fields/index.ts | 1 + .../routes/__mocks__/import_timelines.ts | 20 +- 75 files changed, 1374 insertions(+), 1376 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/pin_event_action.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx rename x-pack/plugins/security_solution/public/timelines/components/timeline/body/{helpers.ts => helpers.tsx} (67%) diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index b5e952b0ffa8e..b4e9ba3dd7a71 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -15,6 +15,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { ManageUserInfo } from '../detections/components/user_info'; import { DEFAULT_DARK_MODE, APP_NAME } from '../../common/constants'; import { ErrorToastDispatcher } from '../common/components/error_toast_dispatcher'; import { MlCapabilitiesProvider } from '../common/components/ml/permissions/ml_capabilities_provider'; @@ -28,6 +29,7 @@ import { ManageGlobalTimeline } from '../timelines/components/manage_timeline'; import { StartServices } from '../types'; import { PageRouter } from './routes'; import { ManageSource } from '../common/containers/sourcerer'; + interface StartAppComponent extends AppFrontendLibs { children: React.ReactNode; history: History; @@ -57,7 +59,9 @@ const StartAppComponent: FC = ({ children, apolloClient, hist - {children} + + {children} + diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index 7c287646ba7ac..b48ae4e6e2d75 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -7,6 +7,7 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; +import { TimelineId } from '../../../common/types/timeline'; import { DragDropContextWrapper } from '../../common/components/drag_and_drop/drag_drop_context_wrapper'; import { Flyout } from '../../timelines/components/flyout'; import { HeaderGlobal } from '../../common/components/header_global'; @@ -17,6 +18,7 @@ import { useWithSource } from '../../common/containers/source'; import { useShowTimeline } from '../../common/utils/timeline/use_show_timeline'; import { navTabs } from './home_navigations'; import { useSignalIndex } from '../../detections/containers/detection_engine/alerts/use_signal_index'; +import { useUserInfo } from '../../detections/components/user_info'; const SecuritySolutionAppWrapper = styled.div` display: flex; @@ -52,6 +54,9 @@ const HomePageComponent: React.FC = ({ children }) => { const [showTimeline] = useShowTimeline(); const { browserFields, indexPattern, indicesExist } = useWithSource('default', indexToAdd); + // side effect: this will attempt to create the signals index if it doesn't exist + useUserInfo(); + return ( @@ -62,7 +67,7 @@ const HomePageComponent: React.FC = ({ children }) => { {indicesExist && showTimeline && ( <> - + )} diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx index 841a1ef09ede6..00879ace040b9 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx @@ -5,14 +5,12 @@ */ import React, { useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { TimelineIdLiteral } from '../../../../common/types/timeline'; import { StatefulEventsViewer } from '../events_viewer'; import { alertsDefaultModel } from './default_headers'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; -import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import * as i18n from './translations'; import { useKibana } from '../../lib/kibana'; @@ -68,7 +66,6 @@ const AlertsTableComponent: React.FC = ({ startDate, pageFilters = [], }) => { - const dispatch = useDispatch(); const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]); const { filterManager } = useKibana().services.data.query; const { initializeTimeline } = useManageTimeline(); @@ -80,12 +77,12 @@ const AlertsTableComponent: React.FC = ({ filterManager, defaultModel: alertsDefaultModel, footerText: i18n.TOTAL_COUNT_OF_ALERTS, - timelineRowActions: () => [getInvestigateInResolverAction({ dispatch, timelineId })], title: i18n.ALERTS_TABLE_TITLE, unit: i18n.UNIT, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return ( o.text === DEFAULT_STACK_BY) ?? alertsStackByOptions[1], errorMessage: i18n.ERROR_FETCHING_ALERTS_DATA, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx index 633135d63ac33..de9a8b32f1f90 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx @@ -15,7 +15,7 @@ import * as i18n from './translations'; import { useUiSetting$ } from '../../lib/kibana'; import { MatrixHistogramContainer } from '../matrix_histogram'; import { histogramConfigs } from './histogram_configs'; -import { MatrixHisrogramConfigs } from '../matrix_histogram/types'; +import { MatrixHistogramConfigs } from '../matrix_histogram/types'; const ID = 'alertsOverTimeQuery'; export const AlertsView = ({ @@ -38,7 +38,7 @@ export const AlertsView = ({ [] ); const { globalFullScreen } = useFullScreen(); - const alertsHistogramConfigs: MatrixHisrogramConfigs = useMemo( + const alertsHistogramConfigs: MatrixHistogramConfigs = useMemo( () => ({ ...histogramConfigs, subtitle: getSubtitle, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 6f77d15913d07..833688ae57993 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -50,10 +50,6 @@ const utilityBar = (refetch: inputsModel.Refetch, totalCount: number) => (
); -const exceptionsModal = (refetch: inputsModel.Refetch) => ( -
-); - const eventsViewerDefaultProps = { browserFields: {}, columns: [], @@ -464,42 +460,4 @@ describe('EventsViewer', () => { }); }); }); - - describe('exceptions modal', () => { - test('it renders exception modal if "exceptionsModal" callback exists', async () => { - const wrapper = mount( - - - - - - ); - - await waitFor(() => { - wrapper.update(); - - expect(wrapper.find(`[data-test-subj="mock-exceptions-modal"]`).exists()).toBeTruthy(); - }); - }); - - test('it does not render exception modal if "exceptionModal" callback does not exist', async () => { - const wrapper = mount( - - - - - - ); - - await waitFor(() => { - wrapper.update(); - - expect(wrapper.find(`[data-test-subj="mock-exceptions-modal"]`).exists()).toBeFalsy(); - }); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index ebda64efabf65..3d193856a8ae4 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -109,7 +109,6 @@ interface Props { utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; // If truthy, the graph viewer (Resolver) is showing graphEventId: string | undefined; - exceptionsModal?: (refetch: inputsModel.Refetch) => React.ReactNode; } const EventsViewerComponent: React.FC = ({ @@ -135,7 +134,6 @@ const EventsViewerComponent: React.FC = ({ toggleColumn, utilityBar, graphEventId, - exceptionsModal, }) => { const { globalFullScreen } = useFullScreen(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; @@ -261,7 +259,6 @@ const EventsViewerComponent: React.FC = ({ )} - {exceptionsModal && exceptionsModal(refetch)} {utilityBar && !resolverIsShowing(graphEventId) && ( {utilityBar?.(refetch, totalCountMinusDeleted)} )} @@ -280,6 +277,7 @@ const EventsViewerComponent: React.FC = ({ docValueFields={docValueFields} id={id} isEventViewer={true} + refetch={refetch} sort={sort} toggleColumn={toggleColumn} /> @@ -338,6 +336,5 @@ export const EventsViewer = React.memo( prevProps.start === nextProps.start && prevProps.sort === nextProps.sort && prevProps.utilityBar === nextProps.utilityBar && - prevProps.graphEventId === nextProps.graphEventId && - prevProps.exceptionsModal === nextProps.exceptionsModal + prevProps.graphEventId === nextProps.graphEventId ); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index ec56a3a1bd8d3..e4520dab4626a 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -43,7 +43,6 @@ export interface OwnProps { headerFilterGroup?: React.ReactNode; pageFilters?: Filter[]; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; - exceptionsModal?: (refetch: inputsModel.Refetch) => React.ReactNode; } type Props = OwnProps & PropsFromRedux; @@ -75,7 +74,6 @@ const StatefulEventsViewerComponent: React.FC = ({ utilityBar, // If truthy, the graph viewer (Resolver) is showing graphEventId, - exceptionsModal, }) => { const [ { docValueFields, browserFields, indexPatterns, isLoading: isLoadingIndexPattern }, @@ -158,7 +156,6 @@ const StatefulEventsViewerComponent: React.FC = ({ toggleColumn={toggleColumn} utilityBar={utilityBar} graphEventId={graphEventId} - exceptionsModal={exceptionsModal} /> @@ -223,7 +220,6 @@ type PropsFromRedux = ConnectedProps; export const StatefulEventsViewer = connector( React.memo( StatefulEventsViewerComponent, - // eslint-disable-next-line complexity (prevProps, nextProps) => prevProps.id === nextProps.id && deepEqual(prevProps.columns, nextProps.columns) && @@ -244,7 +240,6 @@ export const StatefulEventsViewer = connector( prevProps.showCheckboxes === nextProps.showCheckboxes && prevProps.start === nextProps.start && prevProps.utilityBar === nextProps.utilityBar && - prevProps.graphEventId === nextProps.graphEventId && - prevProps.exceptionsModal === nextProps.exceptionsModal + prevProps.graphEventId === nextProps.graphEventId ) ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 21f82c6ab4c98..c46eb1b6b59cc 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -63,7 +63,7 @@ export interface AddExceptionModalBaseProps { export interface AddExceptionModalProps extends AddExceptionModalBaseProps { onCancel: () => void; - onConfirm: (didCloseAlert: boolean) => void; + onConfirm: (didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; onRuleChange?: () => void; alertStatus?: Status; } @@ -137,8 +137,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ); const onSuccess = useCallback(() => { addSuccess(i18n.ADD_EXCEPTION_SUCCESS); - onConfirm(shouldCloseAlert); - }, [addSuccess, onConfirm, shouldCloseAlert]); + onConfirm(shouldCloseAlert, shouldBulkCloseAlert); + }, [addSuccess, onConfirm, shouldBulkCloseAlert, shouldCloseAlert]); const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts index a859b0dd39231..d471b5ae9bed1 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts @@ -25,7 +25,7 @@ export interface MatrixHistogramOption { export type GetSubTitle = (count: number) => string; export type GetTitle = (matrixHistogramOption: MatrixHistogramOption) => string; -export interface MatrixHisrogramConfigs { +export interface MatrixHistogramConfigs { defaultStackByOption: MatrixHistogramOption; errorMessage: string; hideHistogramIfEmpty?: boolean; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index 5e40cd00fa69e..6052913b4183b 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -12,6 +12,7 @@ import * as H from 'history'; import { Query, Filter } from '../../../../../../../src/plugins/data/public'; import { url } from '../../../../../../../src/plugins/kibana_utils/public'; +import { TimelineId } from '../../../../common/types/timeline'; import { SecurityPageName } from '../../../app/types'; import { inputsSelectors, State } from '../../store'; import { UrlInputsModel } from '../../store/inputs/model'; @@ -122,7 +123,7 @@ export const makeMapStateToProps = () => { const { linkTo: globalLinkTo, timerange: globalTimerange } = inputState.global; const { linkTo: timelineLinkTo, timerange: timelineTimerange } = inputState.timeline; - const flyoutTimeline = getTimeline(state, 'timeline-1'); + const flyoutTimeline = getTimeline(state, TimelineId.active); const timeline = flyoutTimeline != null ? { diff --git a/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/histogram_configs.ts b/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/histogram_configs.ts index b32919f4868dc..6a05f97da2fef 100644 --- a/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/histogram_configs.ts +++ b/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/histogram_configs.ts @@ -6,7 +6,7 @@ import * as i18n from './translations'; import { MatrixHistogramOption, - MatrixHisrogramConfigs, + MatrixHistogramConfigs, } from '../../../components/matrix_histogram/types'; import { HistogramType } from '../../../../graphql/types'; @@ -19,7 +19,7 @@ export const anomaliesStackByOptions: MatrixHistogramOption[] = [ const DEFAULT_STACK_BY = i18n.ANOMALIES_STACK_BY_JOB_ID; -export const histogramConfigs: MatrixHisrogramConfigs = { +export const histogramConfigs: MatrixHistogramConfigs = { defaultStackByOption: anomaliesStackByOptions.find((o) => o.text === DEFAULT_STACK_BY) ?? anomaliesStackByOptions[0], errorMessage: i18n.ERROR_FETCHING_ANOMALIES_DATA, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index ab9f12a67fe89..26013915315af 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -5,7 +5,7 @@ */ import { FilterStateStore } from '../../../../../../src/plugins/data/common/es_query/filters/meta_filter'; -import { TimelineType, TimelineStatus } from '../../../common/types/timeline'; +import { TimelineId, TimelineType, TimelineStatus } from '../../../common/types/timeline'; import { OpenTimelineResult } from '../../timelines/components/open_timeline/types'; import { @@ -2227,7 +2227,7 @@ export const defaultTimelineProps: CreateTimelineProps = { filters: [], highlightedDropAndProviderId: '', historyIds: [], - id: 'timeline-1', + id: TimelineId.active, isFavorite: false, isLive: false, isLoading: false, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index e8015f601cb18..3f95fd36b6010 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -18,7 +18,7 @@ import { } from '../../../common/mock/'; import { CreateTimeline, UpdateTimelineLoading } from './types'; import { Ecs } from '../../../graphql/types'; -import { TimelineType, TimelineStatus } from '../../../../common/types/timeline'; +import { TimelineId, TimelineType, TimelineStatus } from '../../../../common/types/timeline'; jest.mock('apollo-client'); @@ -67,7 +67,10 @@ describe('alert actions', () => { }); expect(updateTimelineIsLoading).toHaveBeenCalledTimes(1); - expect(updateTimelineIsLoading).toHaveBeenCalledWith({ id: 'timeline-1', isLoading: true }); + expect(updateTimelineIsLoading).toHaveBeenCalledWith({ + id: TimelineId.active, + isLoading: true, + }); }); test('it invokes createTimeline with designated timeline template if "timelineTemplate" exists', async () => { @@ -313,9 +316,12 @@ describe('alert actions', () => { updateTimelineIsLoading, }); - expect(updateTimelineIsLoading).toHaveBeenCalledWith({ id: 'timeline-1', isLoading: true }); expect(updateTimelineIsLoading).toHaveBeenCalledWith({ - id: 'timeline-1', + id: TimelineId.active, + isLoading: true, + }); + expect(updateTimelineIsLoading).toHaveBeenCalledWith({ + id: TimelineId.active, isLoading: false, }); expect(createTimeline).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index 34c0537a6d7d2..3545bfd91e553 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -10,6 +10,7 @@ import dateMath from '@elastic/datemath'; import { get, getOr, isEmpty, find } from 'lodash/fp'; import moment from 'moment'; +import { TimelineId } from '../../../../common/types/timeline'; import { updateAlertStatus } from '../../containers/detection_engine/alerts/api'; import { SendAlertToTimelineActionProps, UpdateAlertStatusActionProps } from './types'; import { @@ -67,7 +68,6 @@ export const getFilterAndRuleBounds = ( export const updateAlertStatusAction = async ({ query, alertIds, - status, selectedStatus, setEventsLoading, setEventsDeleted, @@ -126,7 +126,7 @@ export const getThresholdAggregationDataProvider = ( return [ { and: [], - id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-${aggregationFieldId}-${dataProviderValue}`, + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-${aggregationFieldId}-${dataProviderValue}`, name: ecsData.signal?.rule?.threshold.field, enabled: true, excluded: false, @@ -155,7 +155,7 @@ export const sendAlertToTimelineAction = async ({ if (timelineId !== '' && apolloClient != null) { try { - updateTimelineIsLoading({ id: 'timeline-1', isLoading: true }); + updateTimelineIsLoading({ id: TimelineId.active, isLoading: true }); const [responseTimeline, eventDataResp] = await Promise.all([ apolloClient.query({ query: oneTimelineQuery, @@ -236,7 +236,7 @@ export const sendAlertToTimelineAction = async ({ } } catch { openAlertInBasicTimeline = true; - updateTimelineIsLoading({ id: 'timeline-1', isLoading: false }); + updateTimelineIsLoading({ id: TimelineId.active, isLoading: false }); } } @@ -253,7 +253,7 @@ export const sendAlertToTimelineAction = async ({ dataProviders: [ { and: [], - id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-alert-id-${ecsData._id}`, + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${ecsData._id}`, name: ecsData._id, enabled: true, excluded: false, @@ -266,7 +266,7 @@ export const sendAlertToTimelineAction = async ({ }, ...getThresholdAggregationDataProvider(ecsData, nonEcsData), ], - id: 'timeline-1', + id: TimelineId.active, dateRange: { start: from, end: to, @@ -304,7 +304,7 @@ export const sendAlertToTimelineAction = async ({ dataProviders: [ { and: [], - id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-alert-id-${ecsData._id}`, + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${ecsData._id}`, name: ecsData._id, enabled: true, excluded: false, @@ -316,7 +316,7 @@ export const sendAlertToTimelineAction = async ({ }, }, ], - id: 'timeline-1', + id: TimelineId.active, dateRange: { start: from, end: to, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index ca17d331c67e5..eebabc59d9324 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -4,44 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import ApolloClient from 'apollo-client'; -import { Dispatch } from 'redux'; - -import { EuiText } from '@elastic/eui'; -import { RuleType } from '../../../../common/detection_engine/types'; -import { isMlRule } from '../../../../common/machine_learning/helpers'; import { RowRendererId } from '../../../../common/types/timeline'; -import { DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Filter } from '../../../../../../../src/plugins/data/common/es_query'; -import { - TimelineRowAction, - TimelineRowActionOnClick, -} from '../../../timelines/components/timeline/body/actions'; + import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH, } from '../../../timelines/components/timeline/body/constants'; -import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../timelines/components/timeline/helpers'; import { ColumnHeaderOptions, SubsetTimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; -import { FILTER_OPEN, FILTER_CLOSED, FILTER_IN_PROGRESS } from './alerts_filter_group'; -import { sendAlertToTimelineAction, updateAlertStatusAction } from './actions'; import * as i18n from './translations'; -import { - CreateTimeline, - SetEventsDeletedProps, - SetEventsLoadingProps, - UpdateTimelineLoading, -} from './types'; -import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; -import { AddExceptionModalBaseProps } from '../../../common/components/exceptions/add_exception_modal'; -import { getMappedNonEcsValue } from '../../../common/components/exceptions/helpers'; -import { isThresholdRule } from '../../../../common/detection_engine/utils'; export const buildAlertStatusFilter = (status: Status): Filter[] => [ { @@ -189,13 +164,16 @@ export const alertsDefaultModel: SubsetTimelineModel = { export const requiredFieldsForActions = [ '@timestamp', + 'signal.status', 'signal.original_time', 'signal.rule.filters', 'signal.rule.from', 'signal.rule.language', 'signal.rule.query', + 'signal.rule.name', 'signal.rule.to', 'signal.rule.id', + 'signal.rule.index', 'signal.rule.type', 'signal.original_event.kind', 'signal.original_event.module', @@ -208,202 +186,3 @@ export const requiredFieldsForActions = [ 'host.os.family', 'event.code', ]; - -interface AlertActionArgs { - apolloClient?: ApolloClient<{}>; - canUserCRUD: boolean; - createTimeline: CreateTimeline; - dispatch: Dispatch; - ecsRowData: Ecs; - nonEcsRowData: TimelineNonEcsData[]; - hasIndexWrite: boolean; - onAlertStatusUpdateFailure: (status: Status, error: Error) => void; - onAlertStatusUpdateSuccess: (count: number, status: Status) => void; - setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; - setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; - status: Status; - timelineId: string; - updateTimelineIsLoading: UpdateTimelineLoading; - openAddExceptionModal: ({ - exceptionListType, - alertData, - ruleName, - ruleId, - }: AddExceptionModalBaseProps) => void; -} - -export const getAlertActions = ({ - apolloClient, - canUserCRUD, - createTimeline, - dispatch, - ecsRowData, - nonEcsRowData, - hasIndexWrite, - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - status, - timelineId, - updateTimelineIsLoading, - openAddExceptionModal, -}: AlertActionArgs): TimelineRowAction[] => { - const openAlertActionComponent: TimelineRowAction = { - ariaLabel: 'Open alert', - content: {i18n.ACTION_OPEN_ALERT}, - dataTestSubj: 'open-alert-status', - displayType: 'contextMenu', - id: FILTER_OPEN, - isActionDisabled: () => !canUserCRUD || !hasIndexWrite, - onClick: ({ eventId }: TimelineRowActionOnClick) => - updateAlertStatusAction({ - alertIds: [eventId], - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - status, - selectedStatus: FILTER_OPEN, - }), - width: DEFAULT_ICON_BUTTON_WIDTH, - }; - - const closeAlertActionComponent: TimelineRowAction = { - ariaLabel: 'Close alert', - content: {i18n.ACTION_CLOSE_ALERT}, - dataTestSubj: 'close-alert-status', - displayType: 'contextMenu', - id: FILTER_CLOSED, - isActionDisabled: () => !canUserCRUD || !hasIndexWrite, - onClick: ({ eventId }: TimelineRowActionOnClick) => - updateAlertStatusAction({ - alertIds: [eventId], - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - status, - selectedStatus: FILTER_CLOSED, - }), - width: DEFAULT_ICON_BUTTON_WIDTH, - }; - - const inProgressAlertActionComponent: TimelineRowAction = { - ariaLabel: 'Mark alert in progress', - content: {i18n.ACTION_IN_PROGRESS_ALERT}, - dataTestSubj: 'in-progress-alert-status', - displayType: 'contextMenu', - id: FILTER_IN_PROGRESS, - isActionDisabled: () => !canUserCRUD || !hasIndexWrite, - onClick: ({ eventId }: TimelineRowActionOnClick) => - updateAlertStatusAction({ - alertIds: [eventId], - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - status, - selectedStatus: FILTER_IN_PROGRESS, - }), - width: DEFAULT_ICON_BUTTON_WIDTH, - }; - - const isEndpointAlert = () => { - const [module] = getMappedNonEcsValue({ - data: nonEcsRowData, - fieldName: 'signal.original_event.module', - }); - const [kind] = getMappedNonEcsValue({ - data: nonEcsRowData, - fieldName: 'signal.original_event.kind', - }); - return module === 'endpoint' && kind === 'alert'; - }; - - const exceptionsAreAllowed = () => { - const ruleTypes = getMappedNonEcsValue({ - data: nonEcsRowData, - fieldName: 'signal.rule.type', - }); - const [ruleType] = ruleTypes as RuleType[]; - return !isMlRule(ruleType) && !isThresholdRule(ruleType); - }; - - return [ - { - ...getInvestigateInResolverAction({ dispatch, timelineId }), - }, - { - ariaLabel: 'Send alert to timeline', - content: i18n.ACTION_INVESTIGATE_IN_TIMELINE, - dataTestSubj: 'send-alert-to-timeline', - displayType: 'icon', - iconType: 'timeline', - id: 'sendAlertToTimeline', - onClick: ({ ecsData, data }: TimelineRowActionOnClick) => - sendAlertToTimelineAction({ - apolloClient, - createTimeline, - ecsData, - nonEcsData: data, - updateTimelineIsLoading, - }), - width: DEFAULT_ICON_BUTTON_WIDTH, - }, - // Context menu items - ...(FILTER_OPEN !== status ? [openAlertActionComponent] : []), - ...(FILTER_CLOSED !== status ? [closeAlertActionComponent] : []), - ...(FILTER_IN_PROGRESS !== status ? [inProgressAlertActionComponent] : []), - { - onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { - const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); - const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); - const ruleIndices = getMappedNonEcsValue({ data, fieldName: 'signal.rule.index' }); - if (ruleId !== undefined) { - openAddExceptionModal({ - ruleName: ruleName ?? '', - ruleId, - ruleIndices: ruleIndices.length > 0 ? ruleIndices : DEFAULT_INDEX_PATTERN, - exceptionListType: 'endpoint', - alertData: { - ecsData, - nonEcsData: data, - }, - }); - } - }, - id: 'addEndpointException', - isActionDisabled: () => !canUserCRUD || !hasIndexWrite || !isEndpointAlert(), - dataTestSubj: 'add-endpoint-exception-menu-item', - ariaLabel: 'Add Endpoint Exception', - content: {i18n.ACTION_ADD_ENDPOINT_EXCEPTION}, - displayType: 'contextMenu', - }, - { - onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { - const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); - const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); - const ruleIndices = getMappedNonEcsValue({ data, fieldName: 'signal.rule.index' }); - if (ruleId !== undefined) { - openAddExceptionModal({ - ruleName: ruleName ?? '', - ruleId, - ruleIndices: ruleIndices.length > 0 ? ruleIndices : DEFAULT_INDEX_PATTERN, - exceptionListType: 'detection', - alertData: { - ecsData, - nonEcsData: data, - }, - }); - } - }, - id: 'addException', - isActionDisabled: () => !canUserCRUD || !hasIndexWrite || !exceptionsAreAllowed(), - dataTestSubj: 'add-exception-menu-item', - ariaLabel: 'Add Exception', - content: {i18n.ACTION_ADD_EXCEPTION}, - displayType: 'contextMenu', - }, - ]; -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx index d5688d84e9759..be24957602037 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx @@ -40,8 +40,6 @@ describe('AlertsTableComponent', () => { clearEventsDeleted={jest.fn()} showBuildingBlockAlerts={false} onShowBuildingBlockAlertsChanged={jest.fn()} - updateTimelineIsLoading={jest.fn()} - updateTimeline={jest.fn()} /> ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 854565ace9b4b..63e1c8aca9082 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -7,7 +7,7 @@ import { EuiPanel, EuiLoadingContent } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { connect, ConnectedProps, useDispatch } from 'react-redux'; +import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; @@ -22,15 +22,10 @@ import { inputsSelectors, State, inputsModel } from '../../../common/store'; import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; import { TimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; -import { - useManageTimeline, - TimelineRowActionArgs, -} from '../../../timelines/components/manage_timeline'; -import { useApolloClient } from '../../../common/utils/apollo_context'; +import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { updateAlertStatusAction } from './actions'; import { - getAlertActions, requiredFieldsForActions, alertsDefaultModel, buildAlertStatusFilter, @@ -39,23 +34,16 @@ import { FILTER_OPEN, AlertsTableFilterGroup } from './alerts_filter_group'; import { AlertsUtilityBar } from './alerts_utility_bar'; import * as i18n from './translations'; import { - CreateTimelineProps, SetEventsDeletedProps, SetEventsLoadingProps, UpdateAlertsStatusCallback, UpdateAlertsStatusProps, } from './types'; -import { dispatchUpdateTimeline } from '../../../timelines/components/open_timeline/helpers'; import { useStateToaster, displaySuccessToast, displayErrorToast, } from '../../../common/components/toasters'; -import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; -import { - AddExceptionModal, - AddExceptionModalBaseProps, -} from '../../../common/components/exceptions/add_exception_modal'; interface OwnProps { timelineId: TimelineIdLiteral; @@ -72,14 +60,6 @@ interface OwnProps { type AlertsTableComponentProps = OwnProps & PropsFromRedux; -const addExceptionModalInitialState: AddExceptionModalBaseProps = { - ruleName: '', - ruleId: '', - ruleIndices: [], - exceptionListType: 'detection', - alertData: undefined, -}; - export const AlertsTableComponent: React.FC = ({ timelineId, canUserCRUD, @@ -101,30 +81,16 @@ export const AlertsTableComponent: React.FC = ({ onShowBuildingBlockAlertsChanged, signalsIndex, to, - updateTimeline, - updateTimelineIsLoading, }) => { - const dispatch = useDispatch(); - const apolloClient = useApolloClient(); - const [showClearSelectionAction, setShowClearSelectionAction] = useState(false); const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); - const [shouldShowAddExceptionModal, setShouldShowAddExceptionModal] = useState(false); - const [addExceptionModalState, setAddExceptionModalState] = useState( - addExceptionModalInitialState - ); const [{ browserFields, indexPatterns, isLoading: indexPatternsLoading }] = useFetchIndexPatterns( signalsIndex !== '' ? [signalsIndex] : [], 'alerts_table' ); const kibana = useKibana(); const [, dispatchToaster] = useStateToaster(); - const { - initializeTimeline, - setSelectAll, - setTimelineRowActions, - setIndexToAdd, - } = useManageTimeline(); + const { initializeTimeline, setSelectAll, setIndexToAdd } = useManageTimeline(); const getGlobalQuery = useCallback( (customFilters: Filter[]) => { @@ -149,27 +115,6 @@ export const AlertsTableComponent: React.FC = ({ [browserFields, defaultFilters, globalFilters, globalQuery, indexPatterns, kibana, to, from] ); - // Callback for creating a new timeline -- utilized by row/batch actions - const createTimelineCallback = useCallback( - ({ from: fromTimeline, timeline, to: toTimeline, ruleNote, notes }: CreateTimelineProps) => { - updateTimelineIsLoading({ id: 'timeline-1', isLoading: false }); - updateTimeline({ - duplicate: true, - forceNotes: true, - from: fromTimeline, - id: 'timeline-1', - notes, - timeline: { - ...timeline, - show: true, - }, - to: toTimeline, - ruleNote, - })(); - }, - [updateTimeline, updateTimelineIsLoading] - ); - const setEventsLoadingCallback = useCallback( ({ eventIds, isLoading }: SetEventsLoadingProps) => { setEventsLoading!({ id: timelineId, eventIds, isLoading }); @@ -220,28 +165,6 @@ export const AlertsTableComponent: React.FC = ({ [dispatchToaster] ); - const openAddExceptionModalCallback = useCallback( - ({ - ruleName, - ruleIndices, - ruleId, - exceptionListType, - alertData, - }: AddExceptionModalBaseProps) => { - if (alertData != null) { - setShouldShowAddExceptionModal(true); - setAddExceptionModalState({ - ruleName, - ruleId, - ruleIndices, - exceptionListType, - alertData, - }); - } - }, - [setShouldShowAddExceptionModal, setAddExceptionModalState] - ); - // Catches state change isSelectAllChecked->false upon user selection change to reset utility bar useEffect(() => { if (isSelectAllChecked) { @@ -297,7 +220,6 @@ export const AlertsTableComponent: React.FC = ({ ? getGlobalQuery(currentStatusFilter)?.filterQuery : undefined, alertIds: Object.keys(selectedEventIds), - status, selectedStatus, setEventsDeleted: setEventsDeletedCallback, setEventsLoading: setEventsLoadingCallback, @@ -352,42 +274,6 @@ export const AlertsTableComponent: React.FC = ({ ] ); - // Send to Timeline / Update Alert Status Actions for each table row - const additionalActions = useMemo( - () => ({ ecsData, nonEcsData }: TimelineRowActionArgs) => - getAlertActions({ - apolloClient, - canUserCRUD, - createTimeline: createTimelineCallback, - ecsRowData: ecsData, - nonEcsRowData: nonEcsData, - dispatch, - hasIndexWrite, - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted: setEventsDeletedCallback, - setEventsLoading: setEventsLoadingCallback, - status: filterGroup, - timelineId, - updateTimelineIsLoading, - openAddExceptionModal: openAddExceptionModalCallback, - }), - [ - apolloClient, - canUserCRUD, - createTimelineCallback, - dispatch, - hasIndexWrite, - filterGroup, - setEventsLoadingCallback, - setEventsDeletedCallback, - timelineId, - updateTimelineIsLoading, - onAlertStatusUpdateSuccess, - onAlertStatusUpdateFailure, - openAddExceptionModalCallback, - ] - ); const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]); const defaultFiltersMemo = useMemo(() => { if (isEmpty(defaultFilters)) { @@ -408,21 +294,12 @@ export const AlertsTableComponent: React.FC = ({ indexToAdd: defaultIndices, loadingText: i18n.LOADING_ALERTS, selectAll: false, - timelineRowActions: () => [getInvestigateInResolverAction({ dispatch, timelineId })], + queryFields: requiredFieldsForActions, title: '', }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - setTimelineRowActions({ - id: timelineId, - queryFields: requiredFieldsForActions, - timelineRowActions: additionalActions, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [additionalActions]); - useEffect(() => { setIndexToAdd({ id: timelineId, indexToAdd: defaultIndices }); }, [timelineId, defaultIndices, setIndexToAdd]); @@ -432,53 +309,6 @@ export const AlertsTableComponent: React.FC = ({ [onFilterGroupChangedCallback] ); - const closeAddExceptionModal = useCallback(() => { - setShouldShowAddExceptionModal(false); - setAddExceptionModalState(addExceptionModalInitialState); - }, [setShouldShowAddExceptionModal, setAddExceptionModalState]); - - const onAddExceptionCancel = useCallback(() => { - closeAddExceptionModal(); - }, [closeAddExceptionModal]); - - const onAddExceptionConfirm = useCallback( - (refetch: inputsModel.Refetch) => (): void => { - refetch(); - closeAddExceptionModal(); - }, - [closeAddExceptionModal] - ); - - // Callback for creating the AddExceptionModal and allowing it - // access to the refetchQuery to update the page - const exceptionModalCallback = useCallback( - (refetchQuery: inputsModel.Refetch) => { - if (shouldShowAddExceptionModal) { - return ( - - ); - } else { - return <>; - } - }, - [ - addExceptionModalState, - filterGroup, - onAddExceptionCancel, - onAddExceptionConfirm, - shouldShowAddExceptionModal, - ] - ); - if (loading || indexPatternsLoading || isEmpty(signalsIndex)) { return ( @@ -489,19 +319,16 @@ export const AlertsTableComponent: React.FC = ({ } return ( - <> - - + ); }; @@ -551,9 +378,6 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ }) => dispatch(timelineActions.setEventsDeleted({ id, eventIds, isDeleted })), clearEventsDeleted: ({ id }: { id: string }) => dispatch(timelineActions.clearEventsDeleted({ id })), - updateTimelineIsLoading: ({ id, isLoading }: { id: string; isLoading: boolean }) => - dispatch(timelineActions.updateIsLoading({ id, isLoading })), - updateTimeline: dispatchUpdateTimeline(dispatch), }); const connector = connect(makeMapStateToProps, mapDispatchToProps); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx new file mode 100644 index 0000000000000..589116c901c30 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -0,0 +1,484 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { + EuiText, + EuiButtonIcon, + EuiContextMenuPanel, + EuiPopover, + EuiContextMenuItem, +} from '@elastic/eui'; +import styled from 'styled-components'; + +import { TimelineId } from '../../../../../common/types/timeline'; +import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { isThresholdRule } from '../../../../../common/detection_engine/utils'; +import { RuleType } from '../../../../../common/detection_engine/types'; +import { isMlRule } from '../../../../../common/machine_learning/helpers'; +import { timelineActions } from '../../../../timelines/store/timeline'; +import { EventsTd, EventsTdContent } from '../../../../timelines/components/timeline/styles'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../../timelines/components/timeline/helpers'; +import { FILTER_OPEN, FILTER_CLOSED, FILTER_IN_PROGRESS } from '../alerts_filter_group'; +import { updateAlertStatusAction } from '../actions'; +import { SetEventsDeletedProps, SetEventsLoadingProps } from '../types'; +import { Ecs, TimelineNonEcsData } from '../../../../graphql/types'; +import { + AddExceptionModal as AddExceptionModalComponent, + AddExceptionModalBaseProps, +} from '../../../../common/components/exceptions/add_exception_modal'; +import { getMappedNonEcsValue } from '../../../../common/components/exceptions/helpers'; +import * as i18n from '../translations'; +import { + useStateToaster, + displaySuccessToast, + displayErrorToast, +} from '../../../../common/components/toasters'; +import { inputsModel } from '../../../../common/store'; +import { useUserData } from '../../user_info'; + +interface AlertContextMenuProps { + disabled: boolean; + ecsRowData: Ecs; + nonEcsRowData: TimelineNonEcsData[]; + refetch: inputsModel.Refetch; + timelineId: string; +} + +const addExceptionModalInitialState: AddExceptionModalBaseProps = { + ruleName: '', + ruleId: '', + ruleIndices: [], + exceptionListType: 'detection', + alertData: undefined, +}; + +const AlertContextMenuComponent: React.FC = ({ + disabled, + ecsRowData, + nonEcsRowData, + refetch, + timelineId, +}) => { + const dispatch = useDispatch(); + const [, dispatchToaster] = useStateToaster(); + const [isPopoverOpen, setPopover] = useState(false); + const [alertStatus, setAlertStatus] = useState( + (ecsRowData.signal?.status && (ecsRowData.signal.status[0] as Status)) ?? undefined + ); + const eventId = ecsRowData._id; + + const onButtonClick = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const closePopover = useCallback(() => { + setPopover(false); + }, []); + const [shouldShowAddExceptionModal, setShouldShowAddExceptionModal] = useState(false); + const [addExceptionModalState, setAddExceptionModalState] = useState( + addExceptionModalInitialState + ); + const [{ canUserCRUD, hasIndexWrite }] = useUserData(); + + const isEndpointAlert = useMemo(() => { + if (!nonEcsRowData) { + return false; + } + + const [module] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.original_event.module', + }); + const [kind] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.original_event.kind', + }); + return module === 'endpoint' && kind === 'alert'; + }, [nonEcsRowData]); + + const closeAddExceptionModal = useCallback(() => { + setShouldShowAddExceptionModal(false); + setAddExceptionModalState(addExceptionModalInitialState); + }, [setShouldShowAddExceptionModal, setAddExceptionModalState]); + + const onAddExceptionCancel = useCallback(() => { + closeAddExceptionModal(); + }, [closeAddExceptionModal]); + + const onAddExceptionConfirm = useCallback( + (didCloseAlert: boolean, didBulkCloseAlert) => { + closeAddExceptionModal(); + if (didCloseAlert) { + setAlertStatus('closed'); + } + if (timelineId !== TimelineId.active || didBulkCloseAlert) { + refetch(); + } + }, + [closeAddExceptionModal, timelineId, refetch] + ); + + const onAlertStatusUpdateSuccess = useCallback( + (count: number, newStatus: Status) => { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_SUCCESS_TOAST(count); + break; + case 'open': + title = i18n.OPENED_ALERT_SUCCESS_TOAST(count); + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(count); + } + displaySuccessToast(title, dispatchToaster); + setAlertStatus(newStatus); + }, + [dispatchToaster] + ); + + const onAlertStatusUpdateFailure = useCallback( + (newStatus: Status, error: Error) => { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_FAILED_TOAST; + break; + case 'open': + title = i18n.OPENED_ALERT_FAILED_TOAST; + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST; + } + displayErrorToast(title, [error.message], dispatchToaster); + }, + [dispatchToaster] + ); + + const setEventsLoading = useCallback( + ({ eventIds, isLoading }: SetEventsLoadingProps) => { + dispatch(timelineActions.setEventsLoading({ id: timelineId, eventIds, isLoading })); + }, + [dispatch, timelineId] + ); + + const setEventsDeleted = useCallback( + ({ eventIds, isDeleted }: SetEventsDeletedProps) => { + dispatch(timelineActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted })); + }, + [dispatch, timelineId] + ); + + const openAlertActionOnClick = useCallback(() => { + updateAlertStatusAction({ + alertIds: [eventId], + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + selectedStatus: FILTER_OPEN, + }); + closePopover(); + }, [ + closePopover, + eventId, + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + ]); + + const openAlertActionComponent = ( + + {i18n.ACTION_OPEN_ALERT} + + ); + + const closeAlertActionClick = useCallback(() => { + updateAlertStatusAction({ + alertIds: [eventId], + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + selectedStatus: FILTER_CLOSED, + }); + closePopover(); + }, [ + closePopover, + eventId, + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + ]); + + const closeAlertActionComponent = ( + + {i18n.ACTION_CLOSE_ALERT} + + ); + + const inProgressAlertActionClick = useCallback(() => { + updateAlertStatusAction({ + alertIds: [eventId], + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + selectedStatus: FILTER_IN_PROGRESS, + }); + closePopover(); + }, [ + closePopover, + eventId, + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + ]); + + const inProgressAlertActionComponent = ( + + {i18n.ACTION_IN_PROGRESS_ALERT} + + ); + + const openAddExceptionModal = useCallback( + ({ + ruleName, + ruleIndices, + ruleId, + exceptionListType, + alertData, + }: AddExceptionModalBaseProps) => { + if (alertData !== null && alertData !== undefined) { + setShouldShowAddExceptionModal(true); + setAddExceptionModalState({ + ruleName, + ruleId, + ruleIndices, + exceptionListType, + alertData, + }); + } + }, + [setShouldShowAddExceptionModal, setAddExceptionModalState] + ); + + const AddExceptionModal = useCallback( + () => + shouldShowAddExceptionModal === true && addExceptionModalState.alertData !== null ? ( + + ) : null, + [ + shouldShowAddExceptionModal, + addExceptionModalState.alertData, + addExceptionModalState.ruleName, + addExceptionModalState.ruleId, + addExceptionModalState.ruleIndices, + addExceptionModalState.exceptionListType, + onAddExceptionCancel, + onAddExceptionConfirm, + alertStatus, + ] + ); + + const button = ( + + ); + + const handleAddEndpointExceptionClick = useCallback(() => { + const [ruleName] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.rule.name', + }); + const [ruleId] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.rule.id', + }); + const ruleIndices = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.rule.index', + }); + + closePopover(); + + if (ruleId !== undefined) { + openAddExceptionModal({ + ruleName: ruleName ?? '', + ruleId, + ruleIndices: ruleIndices.length > 0 ? ruleIndices : DEFAULT_INDEX_PATTERN, + exceptionListType: 'endpoint', + alertData: { + ecsData: ecsRowData, + nonEcsData: nonEcsRowData, + }, + }); + } + }, [closePopover, ecsRowData, nonEcsRowData, openAddExceptionModal]); + + const addEndpointExceptionComponent = ( + + {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} + + ); + + const handleAddExceptionClick = useCallback(() => { + const [ruleName] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.rule.name', + }); + const [ruleId] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.rule.id', + }); + const ruleIndices = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.rule.index', + }); + + closePopover(); + + if (ruleId !== undefined) { + openAddExceptionModal({ + ruleName: ruleName ?? '', + ruleId, + ruleIndices: ruleIndices.length > 0 ? ruleIndices : DEFAULT_INDEX_PATTERN, + exceptionListType: 'detection', + alertData: { + ecsData: ecsRowData, + nonEcsData: nonEcsRowData, + }, + }); + } + }, [closePopover, ecsRowData, nonEcsRowData, openAddExceptionModal]); + + const areExceptionsAllowed = useMemo(() => { + const ruleTypes = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.rule.type', + }); + const [ruleType] = ruleTypes as RuleType[]; + return !isMlRule(ruleType) && !isThresholdRule(ruleType); + }, [nonEcsRowData]); + + const addExceptionComponent = ( + + {i18n.ACTION_ADD_EXCEPTION} + + ); + + const statusFilters = useMemo(() => { + if (!alertStatus) { + return []; + } + + switch (alertStatus) { + case 'open': + return [inProgressAlertActionComponent, closeAlertActionComponent]; + case 'in-progress': + return [openAlertActionComponent, closeAlertActionComponent]; + case 'closed': + return [openAlertActionComponent, inProgressAlertActionComponent]; + default: + return []; + } + }, [ + alertStatus, + closeAlertActionComponent, + inProgressAlertActionComponent, + openAlertActionComponent, + ]); + + const items = useMemo( + () => [...statusFilters, addEndpointExceptionComponent, addExceptionComponent], + [addEndpointExceptionComponent, addExceptionComponent, statusFilters] + ); + + return ( + <> + + + + + + + + + + ); +}; + +const ContextMenuPanel = styled(EuiContextMenuPanel)` + font-size: ${({ theme }) => theme.eui.euiFontSizeS}; +`; + +ContextMenuPanel.displayName = 'ContextMenuPanel'; + +export const AlertContextMenu = React.memo(AlertContextMenuComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx new file mode 100644 index 0000000000000..f4080de5b4ba1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { TimelineId } from '../../../../../common/types/timeline'; +import { TimelineNonEcsData, Ecs } from '../../../../../public/graphql/types'; +import { timelineActions } from '../../../../timelines/store/timeline'; +import { useApolloClient } from '../../../../common/utils/apollo_context'; +import { sendAlertToTimelineAction } from '../actions'; +import { dispatchUpdateTimeline } from '../../../../timelines/components/open_timeline/helpers'; +import { ActionIconItem } from '../../../../timelines/components/timeline/body/actions/action_icon_item'; + +import { CreateTimelineProps } from '../types'; +import { + ACTION_INVESTIGATE_IN_TIMELINE, + ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL, +} from '../translations'; + +interface InvestigateInTimelineActionProps { + ecsRowData: Ecs; + nonEcsRowData: TimelineNonEcsData[]; +} + +const InvestigateInTimelineActionComponent: React.FC = ({ + ecsRowData, + nonEcsRowData, +}) => { + const dispatch = useDispatch(); + const apolloClient = useApolloClient(); + + const updateTimelineIsLoading = useCallback( + (payload) => dispatch(timelineActions.updateIsLoading(payload)), + [dispatch] + ); + + const createTimeline = useCallback( + ({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => { + updateTimelineIsLoading({ id: TimelineId.active, isLoading: false }); + dispatchUpdateTimeline(dispatch)({ + duplicate: true, + from: fromTimeline, + id: TimelineId.active, + notes: [], + timeline: { + ...timeline, + show: true, + }, + to: toTimeline, + ruleNote, + })(); + }, + [dispatch, updateTimelineIsLoading] + ); + + const investigateInTimelineAlertClick = useCallback( + () => + sendAlertToTimelineAction({ + apolloClient, + createTimeline, + ecsData: ecsRowData, + nonEcsData: nonEcsRowData, + updateTimelineIsLoading, + }), + [apolloClient, createTimeline, ecsRowData, nonEcsRowData, updateTimelineIsLoading] + ); + + return ( + + ); +}; + +export const InvestigateInTimelineAction = React.memo(InvestigateInTimelineActionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index 3d6c3dc0a7a8e..b4da0267d2ea5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -115,6 +115,13 @@ export const ACTION_INVESTIGATE_IN_TIMELINE = i18n.translate( } ); +export const ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.actions.investigateInTimelineAriaLabel', + { + defaultMessage: 'Send alert to timeline', + } +); + export const ACTION_ADD_EXCEPTION = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.actions.addException', { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts index 2e77e77f6b3d5..d8ba0ab2d40b9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts @@ -41,7 +41,6 @@ export type UpdateAlertsStatus = ({ export interface UpdateAlertStatusActionProps { query?: string; alertIds: string[]; - status: Status; selectedStatus: Status; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; diff --git a/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx b/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx index 50348578cb039..e1a29c3575d95 100644 --- a/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx @@ -121,7 +121,7 @@ export const userInfoReducer = (state: State, action: Action): State => { const StateUserInfoContext = createContext<[State, Dispatch]>([initialState, () => noop]); -const useUserData = () => useContext(StateUserInfoContext); +export const useUserData = () => useContext(StateUserInfoContext); interface ManageUserInfoProps { children: React.ReactNode; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index 982712cbe9797..8c21f6a1e8cb7 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -19,7 +19,7 @@ import { } from '../../../common/mock'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; import { DetectionEnginePageComponent } from './detection_engine'; -import { useUserInfo } from '../../components/user_info'; +import { useUserData } from '../../components/user_info'; import { useWithSource } from '../../../common/containers/source'; import { createStore, State } from '../../../common/store'; import { mockHistory, Router } from '../../../cases/components/__mock__/router'; @@ -73,7 +73,7 @@ const store = createStore( describe('DetectionEnginePageComponent', () => { beforeAll(() => { (useParams as jest.Mock).mockReturnValue({}); - (useUserInfo as jest.Mock).mockReturnValue({}); + (useUserData as jest.Mock).mockReturnValue([{}]); (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index d76da592e1c81..3a3854f145db3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -30,7 +30,7 @@ import { NoApiIntegrationKeyCallOut } from '../../components/no_api_integration_ import { NoWriteAlertsCallOut } from '../../components/no_write_alerts_callout'; import { AlertsHistogramPanel } from '../../components/alerts_histogram_panel'; import { alertsHistogramOptions } from '../../components/alerts_histogram_panel/config'; -import { useUserInfo } from '../../components/user_info'; +import { useUserData } from '../../components/user_info'; import { OverviewEmpty } from '../../../overview/components/overview_empty'; import { DetectionEngineNoIndex } from './detection_engine_no_index'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; @@ -55,15 +55,17 @@ export const DetectionEnginePageComponent: React.FC = ({ }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(); const { globalFullScreen } = useFullScreen(); - const { - loading: userInfoLoading, - isSignalIndexExists, - isAuthenticated: isUserAuthenticated, - hasEncryptionKey, - canUserCRUD, - signalIndexName, - hasIndexWrite, - } = useUserInfo(); + const [ + { + loading: userInfoLoading, + isSignalIndexExists, + isAuthenticated: isUserAuthenticated, + hasEncryptionKey, + canUserCRUD, + signalIndexName, + hasIndexWrite, + }, + ] = useUserData(); const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/index.test.tsx index d4e654321ef98..045e7d402fd2b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/index.test.tsx @@ -12,8 +12,8 @@ import { DetectionEngineContainer } from './index'; describe('DetectionEngineContainer', () => { it('renders correctly', () => { - const wrapper = shallow(); + const wrapper = shallow(); - expect(wrapper.find('ManageUserInfo')).toHaveLength(1); + expect(wrapper.find('Switch')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/index.tsx index 914734aba4ec6..5f379f7dbb70e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/index.tsx @@ -5,37 +5,32 @@ */ import React from 'react'; -import { Route, Switch, RouteComponentProps } from 'react-router-dom'; +import { Route, Switch } from 'react-router-dom'; -import { ManageUserInfo } from '../../components/user_info'; import { CreateRulePage } from './rules/create'; import { DetectionEnginePage } from './detection_engine'; import { EditRulePage } from './rules/edit'; import { RuleDetailsPage } from './rules/details'; import { RulesPage } from './rules'; -type Props = Partial> & { url: string }; - -const DetectionEngineContainerComponent: React.FC = () => ( - - - - - - - - - - - - - - - - - - - +const DetectionEngineContainerComponent: React.FC = () => ( + + + + + + + + + + + + + + + + + ); export const DetectionEngineContainer = React.memo(DetectionEngineContainerComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx index 50407c5eb219b..deffee5a56d46 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx @@ -10,7 +10,7 @@ import { shallow } from 'enzyme'; import '../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../common/mock'; import { CreateRulePage } from './index'; -import { useUserInfo } from '../../../../components/user_info'; +import { useUserData } from '../../../../components/user_info'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -29,7 +29,7 @@ jest.mock('../../../../components/user_info'); describe('CreateRulePage', () => { it('renders correctly', () => { - (useUserInfo as jest.Mock).mockReturnValue({}); + (useUserData as jest.Mock).mockReturnValue([{}]); const wrapper = shallow(, { wrappingComponent: TestProviders }); expect(wrapper.find('[title="Create new rule"]')).toHaveLength(1); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 70f278197b005..d2eb3228cbbf3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -19,7 +19,7 @@ import { import { WrapperPage } from '../../../../../common/components/wrapper_page'; import { displaySuccessToast, useStateToaster } from '../../../../../common/components/toasters'; import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; -import { useUserInfo } from '../../../../components/user_info'; +import { useUserData } from '../../../../components/user_info'; import { AccordionTitle } from '../../../../components/rules/accordion_title'; import { FormData, FormHook } from '../../../../../shared_imports'; import { StepAboutRule } from '../../../../components/rules/step_about_rule'; @@ -84,13 +84,15 @@ const StepDefineRuleAccordion: StyledComponent< StepDefineRuleAccordion.displayName = 'StepDefineRuleAccordion'; const CreateRulePageComponent: React.FC = () => { - const { - loading: userInfoLoading, - isSignalIndexExists, - isAuthenticated, - hasEncryptionKey, - canUserCRUD, - } = useUserInfo(); + const [ + { + loading: userInfoLoading, + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + canUserCRUD, + }, + ] = useUserData(); const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx index 5e6587dab1736..f8f9da78b2a06 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx @@ -19,7 +19,7 @@ import { import { RuleDetailsPageComponent } from './index'; import { createStore, State } from '../../../../../common/store'; import { setAbsoluteRangeDatePicker } from '../../../../../common/store/inputs/actions'; -import { useUserInfo } from '../../../../components/user_info'; +import { useUserData } from '../../../../components/user_info'; import { useWithSource } from '../../../../../common/containers/source'; import { useParams } from 'react-router-dom'; import { mockHistory, Router } from '../../../../../cases/components/__mock__/router'; @@ -69,7 +69,7 @@ const store = createStore( describe('RuleDetailsPageComponent', () => { beforeAll(() => { - (useUserInfo as jest.Mock).mockReturnValue({}); + (useUserData as jest.Mock).mockReturnValue([{}]); (useParams as jest.Mock).mockReturnValue({}); (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index f48dc64966bfc..2988e031c4dd6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -44,7 +44,7 @@ import { StepAboutRuleToggleDetails } from '../../../../components/rules/step_ab import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; import { AlertsHistogramPanel } from '../../../../components/alerts_histogram_panel'; import { AlertsTable } from '../../../../components/alerts_table'; -import { useUserInfo } from '../../../../components/user_info'; +import { useUserData } from '../../../../components/user_info'; import { OverviewEmpty } from '../../../../../overview/components/overview_empty'; import { useAlertInfo } from '../../../../components/alerts_info'; import { StepDefineRule } from '../../../../components/rules/step_define_rule'; @@ -124,15 +124,17 @@ export const RuleDetailsPageComponent: FC = ({ setAbsoluteRangeDatePicker, }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(); - const { - loading: userInfoLoading, - isSignalIndexExists, - isAuthenticated, - hasEncryptionKey, - canUserCRUD, - hasIndexWrite, - signalIndexName, - } = useUserInfo(); + const [ + { + loading: userInfoLoading, + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + canUserCRUD, + hasIndexWrite, + signalIndexName, + }, + ] = useUserData(); const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx index 2e45dbc6521b7..e89c899b12c39 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx @@ -10,7 +10,7 @@ import { shallow } from 'enzyme'; import '../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../common/mock'; import { EditRulePage } from './index'; -import { useUserInfo } from '../../../../components/user_info'; +import { useUserData } from '../../../../components/user_info'; import { useParams } from 'react-router-dom'; jest.mock('../../../../containers/detection_engine/lists/use_lists_config'); @@ -28,7 +28,7 @@ jest.mock('react-router-dom', () => { describe('EditRulePage', () => { it('renders correctly', () => { - (useUserInfo as jest.Mock).mockReturnValue({}); + (useUserData as jest.Mock).mockReturnValue([{}]); (useParams as jest.Mock).mockReturnValue({}); const wrapper = shallow(, { wrappingComponent: TestProviders }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index 4033d247c4ecb..530222ee19624 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -26,7 +26,7 @@ import { } from '../../../../../common/components/link_to/redirect_to_detection_engine'; import { displaySuccessToast, useStateToaster } from '../../../../../common/components/toasters'; import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; -import { useUserInfo } from '../../../../components/user_info'; +import { useUserData } from '../../../../components/user_info'; import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; import { FormHook, FormData } from '../../../../../shared_imports'; import { StepPanel } from '../../../../components/rules/step_panel'; @@ -72,13 +72,15 @@ interface ActionsStepRuleForm extends StepRuleForm { const EditRulePageComponent: FC = () => { const history = useHistory(); const [, dispatchToaster] = useStateToaster(); - const { - loading: userInfoLoading, - isSignalIndexExists, - isAuthenticated, - hasEncryptionKey, - canUserCRUD, - } = useUserInfo(); + const [ + { + loading: userInfoLoading, + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + canUserCRUD, + }, + ] = useUserData(); const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx index 95ef85ec1317a..886a24dd7cbe8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx @@ -9,7 +9,7 @@ import { shallow } from 'enzyme'; import '../../../../common/mock/match_media'; import { RulesPage } from './index'; -import { useUserInfo } from '../../../components/user_info'; +import { useUserData } from '../../../components/user_info'; import { usePrePackagedRules } from '../../../containers/detection_engine/rules'; jest.mock('react-router-dom', () => { @@ -30,7 +30,7 @@ jest.mock('../../../containers/detection_engine/rules'); describe('RulesPage', () => { beforeAll(() => { - (useUserInfo as jest.Mock).mockReturnValue({}); + (useUserData as jest.Mock).mockReturnValue([{}]); (usePrePackagedRules as jest.Mock).mockReturnValue({}); }); it('renders correctly', () => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index 92ec0bb5a72cd..53c82569f94ae 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -18,7 +18,7 @@ import { DetectionEngineHeaderPage } from '../../../components/detection_engine_ import { WrapperPage } from '../../../../common/components/wrapper_page'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; -import { useUserInfo } from '../../../components/user_info'; +import { useUserData } from '../../../components/user_info'; import { AllRules } from './all'; import { ImportDataModal } from '../../../../common/components/import_data_modal'; import { ReadOnlyCallOut } from '../../../components/rules/read_only_callout'; @@ -42,14 +42,16 @@ const RulesPageComponent: React.FC = () => { const [showImportModal, setShowImportModal] = useState(false); const [showValueListsModal, setShowValueListsModal] = useState(false); const refreshRulesData = useRef(null); - const { - loading: userInfoLoading, - isSignalIndexExists, - isAuthenticated, - hasEncryptionKey, - canUserCRUD, - hasIndexWrite, - } = useUserInfo(); + const [ + { + loading: userInfoLoading, + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + canUserCRUD, + hasIndexWrite, + }, + ] = useUserData(); const { loading: listsConfigLoading, canWriteIndex: canWriteListsIndex, diff --git a/x-pack/plugins/security_solution/public/detections/routes.tsx b/x-pack/plugins/security_solution/public/detections/routes.tsx index 8f542d1f88670..b5f7bc6983752 100644 --- a/x-pack/plugins/security_solution/public/detections/routes.tsx +++ b/x-pack/plugins/security_solution/public/detections/routes.tsx @@ -12,12 +12,11 @@ import { NotFoundPage } from '../app/404'; export const AlertsRoutes: React.FC = () => ( - ( - - )} - /> - } /> + + + + + + ); diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 7b20873bf63cc..b32083fec1b5e 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -4719,6 +4719,14 @@ "type": { "kind": "SCALAR", "name": "ToStringArray", "ofType": null }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "status", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "ToStringArray", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index f7d2c81f536be..65d9212f77dcc 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -1020,6 +1020,8 @@ export interface SignalField { rule?: Maybe; original_time?: Maybe; + + status?: Maybe; } export interface RuleField { @@ -5098,6 +5100,8 @@ export namespace GetTimelineQuery { export type Signal = { __typename?: 'SignalField'; + status: Maybe; + original_time: Maybe; rule: Maybe<_Rule>; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx index 88886a874a949..6c8eb9eb04941 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx @@ -14,7 +14,7 @@ import { hostsModel } from '../../store'; import { MatrixHistogramOption, MatrixHistogramMappingTypes, - MatrixHisrogramConfigs, + MatrixHistogramConfigs, } from '../../../common/components/matrix_histogram/types'; import { MatrixHistogramContainer } from '../../../common/components/matrix_histogram'; import { KpiHostsChartColors } from '../../components/kpi_hosts/types'; @@ -49,7 +49,7 @@ export const authMatrixDataMappingFields: MatrixHistogramMappingTypes = { }, }; -const histogramConfigs: MatrixHisrogramConfigs = { +const histogramConfigs: MatrixHistogramConfigs = { defaultStackByOption: authStackByOptions.find((o) => o.text === DEFAULT_STACK_BY) ?? authStackByOptions[0], errorMessage: i18n.ERROR_FETCHING_AUTHENTICATIONS_DATA, diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index cea987db485f4..f28c3dfa1ad77 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -14,14 +14,13 @@ import { hostsModel } from '../../store'; import { eventsDefaultModel } from '../../../common/components/events_viewer/default_model'; import { MatrixHistogramOption, - MatrixHisrogramConfigs, + MatrixHistogramConfigs, } from '../../../common/components/matrix_histogram/types'; import { MatrixHistogramContainer } from '../../../common/components/matrix_histogram'; import { useFullScreen } from '../../../common/containers/use_full_screen'; import * as i18n from '../translations'; import { HistogramType } from '../../../graphql/types'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; -import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; const EVENTS_HISTOGRAM_ID = 'eventsOverTimeQuery'; @@ -42,7 +41,7 @@ export const eventsStackByOptions: MatrixHistogramOption[] = [ const DEFAULT_STACK_BY = 'event.action'; -export const histogramConfigs: MatrixHisrogramConfigs = { +export const histogramConfigs: MatrixHistogramConfigs = { defaultStackByOption: eventsStackByOptions.find((o) => o.text === DEFAULT_STACK_BY) ?? eventsStackByOptions[0], errorMessage: i18n.ERROR_FETCHING_EVENTS_DATA, @@ -52,14 +51,14 @@ export const histogramConfigs: MatrixHisrogramConfigs = { title: i18n.NAVIGATION_EVENTS_TITLE, }; -export const EventsQueryTabBody = ({ +const EventsQueryTabBodyComponent: React.FC = ({ deleteQuery, endDate, filterQuery, pageFilters, setQuery, startDate, -}: HostsComponentsQueryProps) => { +}) => { const { initializeTimeline } = useManageTimeline(); const dispatch = useDispatch(); const { globalFullScreen } = useFullScreen(); @@ -67,9 +66,6 @@ export const EventsQueryTabBody = ({ initializeTimeline({ id: TimelineId.hostsPageEvents, defaultModel: eventsDefaultModel, - timelineRowActions: () => [ - getInvestigateInResolverAction({ dispatch, timelineId: TimelineId.hostsPageEvents }), - ], }); }, [dispatch, initializeTimeline]); @@ -106,4 +102,8 @@ export const EventsQueryTabBody = ({ ); }; +EventsQueryTabBodyComponent.displayName = 'EventsQueryTabBodyComponent'; + +export const EventsQueryTabBody = React.memo(EventsQueryTabBodyComponent); + EventsQueryTabBody.displayName = 'EventsQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx index 77283dc330257..2886089a1eb99 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx @@ -16,7 +16,7 @@ import { networkModel } from '../../store'; import { MatrixHistogramOption, - MatrixHisrogramConfigs, + MatrixHistogramConfigs, } from '../../../common/components/matrix_histogram/types'; import * as i18n from '../translations'; import { MatrixHistogramContainer } from '../../../common/components/matrix_histogram'; @@ -33,7 +33,7 @@ const dnsStackByOptions: MatrixHistogramOption[] = [ const DEFAULT_STACK_BY = 'dns.question.registered_domain'; -export const histogramConfigs: Omit = { +export const histogramConfigs: Omit = { defaultStackByOption: dnsStackByOptions.find((o) => o.text === DEFAULT_STACK_BY) ?? dnsStackByOptions[0], errorMessage: i18n.ERROR_FETCHING_DNS_DATA, @@ -64,7 +64,7 @@ export const DnsQueryTabBody = ({ [] ); - const dnsHistogramConfigs: MatrixHisrogramConfigs = useMemo( + const dnsHistogramConfigs: MatrixHistogramConfigs = useMemo( () => ({ ...histogramConfigs, title: getTitle, diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx index 6e59d81a1eae9..111935782949b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx @@ -26,7 +26,7 @@ import { alertsStackByOptions, histogramConfigs, } from '../../../common/components/alerts_viewer/histogram_configs'; -import { MatrixHisrogramConfigs } from '../../../common/components/matrix_histogram/types'; +import { MatrixHistogramConfigs } from '../../../common/components/matrix_histogram/types'; import { getTabsOnHostsUrl } from '../../../common/components/link_to/redirect_to_hosts'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SecurityPageName } from '../../../app/types'; @@ -93,7 +93,7 @@ const AlertsByCategoryComponent: React.FC = ({ [goToHostAlerts, formatUrl] ); - const alertsByCategoryHistogramConfigs: MatrixHisrogramConfigs = useMemo( + const alertsByCategoryHistogramConfigs: MatrixHistogramConfigs = useMemo( () => ({ ...histogramConfigs, defaultStackByOption: diff --git a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx index f18fccee50e22..2e9c25f01b3c1 100644 --- a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx @@ -14,7 +14,7 @@ import { SHOWING, UNIT } from '../../../common/components/events_viewer/translat import { getTabsOnHostsUrl } from '../../../common/components/link_to/redirect_to_hosts'; import { MatrixHistogramContainer } from '../../../common/components/matrix_histogram'; import { - MatrixHisrogramConfigs, + MatrixHistogramConfigs, MatrixHistogramOption, } from '../../../common/components/matrix_histogram/types'; import { eventsStackByOptions } from '../../../hosts/pages/navigation'; @@ -127,7 +127,7 @@ const EventsByDatasetComponent: React.FC = ({ [combinedQueries, kibana, indexPattern, query, filters] ); - const eventsByDatasetHistogramConfigs: MatrixHisrogramConfigs = useMemo( + const eventsByDatasetHistogramConfigs: MatrixHistogramConfigs = useMemo( () => ({ ...histogramConfigs, stackByOptions: diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.tsx index 07c4893e4550b..3c9101878be8d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.tsx @@ -46,13 +46,7 @@ PanesFlexGroup.displayName = 'PanesFlexGroup'; type Props = Pick< FieldBrowserProps, - | 'browserFields' - | 'isEventViewer' - | 'height' - | 'onFieldSelected' - | 'onUpdateColumns' - | 'timelineId' - | 'width' + 'browserFields' | 'height' | 'onFieldSelected' | 'onUpdateColumns' | 'timelineId' | 'width' > & { /** * The current timeline column headers @@ -106,7 +100,6 @@ const FieldsBrowserComponent: React.FC = ({ browserFields, columnHeaders, filteredBrowserFields, - isEventViewer, isSearching, onCategorySelected, onFieldSelected, @@ -193,7 +186,6 @@ const FieldsBrowserComponent: React.FC = ({
void; onSearchInputChange: (event: React.ChangeEvent) => void; @@ -93,7 +92,6 @@ CountRow.displayName = 'CountRow'; const TitleRow = React.memo<{ id: string; - isEventViewer?: boolean; onOutsideClick: () => void; onUpdateColumns: OnUpdateColumns; }>(({ id, onOutsideClick, onUpdateColumns }) => { @@ -130,7 +128,6 @@ TitleRow.displayName = 'TitleRow'; export const Header = React.memo( ({ - isEventViewer, isSearching, filteredBrowserFields, onOutsideClick, @@ -140,12 +137,7 @@ export const Header = React.memo( timelineId, }) => ( - + = ({ columnHeaders, browserFields, height, - isEventViewer = false, onFieldSelected, onUpdateColumns, timelineId, @@ -164,7 +163,6 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ filteredBrowserFields != null ? filteredBrowserFields : browserFieldsWithDefaultCategory } height={height} - isEventViewer={isEventViewer} isSearching={isSearching} onCategorySelected={updateSelectedCategoryId} onFieldSelected={onFieldSelected} diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.test.tsx index b918e5abc652b..fe0f0c8f8b91f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.test.tsx @@ -8,7 +8,6 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { getTimelineDefaults, useTimelineManager, UseTimelineManager } from './'; import { FilterManager } from '../../../../../../../src/plugins/data/public/query/filter_manager'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { TimelineRowAction } from '../timeline/body/actions'; const isStringifiedComparisonEqual = (a: {}, b: {}): boolean => JSON.stringify(a) === JSON.stringify(b); @@ -17,13 +16,14 @@ describe('useTimelineManager', () => { const setupMock = coreMock.createSetup(); const testId = 'coolness'; const timelineDefaults = getTimelineDefaults(testId); - const timelineRowActions = () => []; const mockFilterManager = new FilterManager(setupMock.uiSettings); + beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); }); - it('initilizes an undefined timeline', async () => { + + it('initializes an undefined timeline', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => useTimelineManager() @@ -33,6 +33,7 @@ describe('useTimelineManager', () => { expect(isStringifiedComparisonEqual(uninitializedTimeline, timelineDefaults)).toBeTruthy(); }); }); + it('getIndexToAddById', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -43,6 +44,7 @@ describe('useTimelineManager', () => { expect(data).toEqual(timelineDefaults.indexToAdd); }); }); + it('setIndexToAdd', async () => { await act(async () => { const indexToAddArgs = { id: testId, indexToAdd: ['example'] }; @@ -52,13 +54,13 @@ describe('useTimelineManager', () => { await waitForNextUpdate(); result.current.initializeTimeline({ id: testId, - timelineRowActions, }); result.current.setIndexToAdd(indexToAddArgs); const data = result.current.getIndexToAddById(testId); expect(data).toEqual(indexToAddArgs.indexToAdd); }); }); + it('setIsTimelineLoading', async () => { await act(async () => { const isLoadingArgs = { id: testId, isLoading: true }; @@ -68,7 +70,6 @@ describe('useTimelineManager', () => { await waitForNextUpdate(); result.current.initializeTimeline({ id: testId, - timelineRowActions, }); let timeline = result.current.getManageTimelineById(testId); expect(timeline.isLoading).toBeFalsy(); @@ -77,29 +78,7 @@ describe('useTimelineManager', () => { expect(timeline.isLoading).toBeTruthy(); }); }); - it('setTimelineRowActions', async () => { - await act(async () => { - const timelineRowActionsEx = () => [ - { id: 'wow', content: 'hey', displayType: 'icon', onClick: () => {} } as TimelineRowAction, - ]; - const { result, waitForNextUpdate } = renderHook(() => - useTimelineManager() - ); - await waitForNextUpdate(); - result.current.initializeTimeline({ - id: testId, - timelineRowActions, - }); - let timeline = result.current.getManageTimelineById(testId); - expect(timeline.timelineRowActions).toEqual(timelineRowActions); - result.current.setTimelineRowActions({ - id: testId, - timelineRowActions: timelineRowActionsEx, - }); - timeline = result.current.getManageTimelineById(testId); - expect(timeline.timelineRowActions).toEqual(timelineRowActionsEx); - }); - }); + it('getTimelineFilterManager undefined on uninitialized', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -110,6 +89,7 @@ describe('useTimelineManager', () => { expect(data).toEqual(undefined); }); }); + it('getTimelineFilterManager defined at initialize', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -118,13 +98,13 @@ describe('useTimelineManager', () => { await waitForNextUpdate(); result.current.initializeTimeline({ id: testId, - timelineRowActions, filterManager: mockFilterManager, }); const data = result.current.getTimelineFilterManager(testId); expect(data).toEqual(mockFilterManager); }); }); + it('isManagedTimeline returns false when unset and then true when set', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -135,7 +115,6 @@ describe('useTimelineManager', () => { expect(data).toBeFalsy(); result.current.initializeTimeline({ id: testId, - timelineRowActions, filterManager: mockFilterManager, }); data = result.current.isManagedTimeline(testId); diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index 560d4c6928e4e..f82158fe65c11 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -9,12 +9,10 @@ import { noop } from 'lodash/fp'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FilterManager } from '../../../../../../../src/plugins/data/public/query/filter_manager'; -import { TimelineRowAction } from '../timeline/body/actions'; import { SubsetTimelineModel } from '../../store/timeline/model'; import * as i18n from '../../../common/components/events_viewer/translations'; import * as i18nF from '../timeline/footer/translations'; import { timelineDefaults as timelineDefaultModel } from '../../store/timeline/defaults'; -import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; interface ManageTimelineInit { documentType?: string; @@ -25,16 +23,11 @@ interface ManageTimelineInit { indexToAdd?: string[] | null; loadingText?: string; selectAll?: boolean; - timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; + queryFields?: string[]; title?: string; unit?: (totalCount: number) => string; } -export interface TimelineRowActionArgs { - ecsData: Ecs; - nonEcsData: TimelineNonEcsData[]; -} - interface ManageTimeline { documentType: string; defaultModel: SubsetTimelineModel; @@ -46,7 +39,6 @@ interface ManageTimeline { loadingText: string; queryFields: string[]; selectAll: boolean; - timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; title: string; unit: (totalCount: number) => string; } @@ -75,14 +67,6 @@ type ActionManageTimeline = type: 'SET_SELECT_ALL'; id: string; payload: boolean; - } - | { - type: 'SET_TIMELINE_ACTIONS'; - id: string; - payload: { - queryFields?: string[]; - timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; - }; }; export const getTimelineDefaults = (id: string) => ({ @@ -95,7 +79,6 @@ export const getTimelineDefaults = (id: string) => ({ id, isLoading: false, queryFields: [], - timelineRowActions: () => [], title: i18n.EVENTS, unit: (n: number) => i18n.UNIT(n), }); @@ -129,14 +112,7 @@ const reducerManageTimeline = ( selectAll: action.payload, }, } as ManageTimelineById; - case 'SET_TIMELINE_ACTIONS': - return { - ...state, - [action.id]: { - ...state[action.id], - ...action.payload, - }, - } as ManageTimelineById; + case 'SET_IS_LOADING': return { ...state, @@ -159,11 +135,6 @@ export interface UseTimelineManager { setIndexToAdd: (indexToAddArgs: { id: string; indexToAdd: string[] }) => void; setIsTimelineLoading: (isLoadingArgs: { id: string; isLoading: boolean }) => void; setSelectAll: (selectAllArgs: { id: string; selectAll: boolean }) => void; - setTimelineRowActions: (actionsArgs: { - id: string; - queryFields?: string[]; - timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; - }) => void; } export const useTimelineManager = ( @@ -181,25 +152,6 @@ export const useTimelineManager = ( }); }, []); - const setTimelineRowActions = useCallback( - ({ - id, - queryFields, - timelineRowActions, - }: { - id: string; - queryFields?: string[]; - timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; - }) => { - dispatch({ - type: 'SET_TIMELINE_ACTIONS', - id, - payload: { queryFields, timelineRowActions }, - }); - }, - [] - ); - const setIsTimelineLoading = useCallback( ({ id, isLoading }: { id: string; isLoading: boolean }) => { dispatch({ @@ -236,7 +188,7 @@ export const useTimelineManager = ( if (state[id] != null) { return state[id]; } - initializeTimeline({ id, timelineRowActions: () => [] }); + initializeTimeline({ id }); return getTimelineDefaults(id); }, [initializeTimeline, state] @@ -261,7 +213,6 @@ export const useTimelineManager = ( setIndexToAdd, setIsTimelineLoading, setSelectAll, - setTimelineRowActions, }; }; @@ -274,7 +225,6 @@ const init = { setIndexToAdd: () => undefined, setIsTimelineLoading: () => noop, setSelectAll: () => noop, - setTimelineRowActions: () => noop, }; const ManageTimelineContext = createContext(init); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index ac6c61b33b35e..ed44fc14e3efa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -36,7 +36,7 @@ import { KueryFilterQueryKind } from '../../../common/store/model'; import { Note } from '../../../common/lib/note'; import moment from 'moment'; import sinon from 'sinon'; -import { TimelineType, TimelineStatus } from '../../../../common/types/timeline'; +import { TimelineId, TimelineType, TimelineStatus } from '../../../../common/types/timeline'; jest.mock('../../../common/store/inputs/actions'); jest.mock('../../../common/components/url_state/normalize_time_range.ts'); @@ -942,7 +942,7 @@ describe('helpers', () => { test('it invokes date range picker dispatch', () => { timelineDispatch({ duplicate: true, - id: 'timeline-1', + id: TimelineId.active, from: '2020-03-26T14:35:56.356Z', to: '2020-03-26T14:41:56.356Z', notes: [], @@ -958,7 +958,7 @@ describe('helpers', () => { test('it invokes add timeline dispatch', () => { timelineDispatch({ duplicate: true, - id: 'timeline-1', + id: TimelineId.active, from: '2020-03-26T14:35:56.356Z', to: '2020-03-26T14:41:56.356Z', notes: [], @@ -966,7 +966,7 @@ describe('helpers', () => { })(); expect(dispatchAddTimeline).toHaveBeenCalledWith({ - id: 'timeline-1', + id: TimelineId.active, savedTimeline: true, timeline: mockTimelineModel, }); @@ -975,7 +975,7 @@ describe('helpers', () => { test('it does not invoke kql filter query dispatches if timeline.kqlQuery.filterQuery is null', () => { timelineDispatch({ duplicate: true, - id: 'timeline-1', + id: TimelineId.active, from: '2020-03-26T14:35:56.356Z', to: '2020-03-26T14:41:56.356Z', notes: [], @@ -989,7 +989,7 @@ describe('helpers', () => { test('it does not invoke notes dispatch if duplicate is true', () => { timelineDispatch({ duplicate: true, - id: 'timeline-1', + id: TimelineId.active, from: '2020-03-26T14:35:56.356Z', to: '2020-03-26T14:41:56.356Z', notes: [], @@ -1012,7 +1012,7 @@ describe('helpers', () => { }; timelineDispatch({ duplicate: true, - id: 'timeline-1', + id: TimelineId.active, from: '2020-03-26T14:35:56.356Z', to: '2020-03-26T14:41:56.356Z', notes: [], @@ -1036,7 +1036,7 @@ describe('helpers', () => { }; timelineDispatch({ duplicate: true, - id: 'timeline-1', + id: TimelineId.active, from: '2020-03-26T14:35:56.356Z', to: '2020-03-26T14:41:56.356Z', notes: [], @@ -1044,14 +1044,14 @@ describe('helpers', () => { })(); expect(dispatchSetKqlFilterQueryDraft).toHaveBeenCalledWith({ - id: 'timeline-1', + id: TimelineId.active, filterQueryDraft: { kind: 'kuery', expression: 'expression', }, }); expect(dispatchApplyKqlFilterQuery).toHaveBeenCalledWith({ - id: 'timeline-1', + id: TimelineId.active, filterQuery: { kuery: { kind: 'kuery', @@ -1065,7 +1065,7 @@ describe('helpers', () => { test('it invokes dispatchAddNotes if duplicate is false', () => { timelineDispatch({ duplicate: false, - id: 'timeline-1', + id: TimelineId.active, from: '2020-03-26T14:35:56.356Z', to: '2020-03-26T14:41:56.356Z', notes: [ @@ -1099,7 +1099,7 @@ describe('helpers', () => { test('it invokes dispatch to create a timeline note if duplicate is true and ruleNote exists', () => { timelineDispatch({ duplicate: true, - id: 'timeline-1', + id: TimelineId.active, from: '2020-03-26T14:35:56.356Z', to: '2020-03-26T14:41:56.356Z', notes: [], @@ -1119,7 +1119,7 @@ describe('helpers', () => { expect(dispatchAddNotes).not.toHaveBeenCalled(); expect(dispatchUpdateNote).toHaveBeenCalledWith({ note: expectedNote }); expect(dispatchAddGlobalTimelineNote).toHaveBeenLastCalledWith({ - id: 'timeline-1', + id: TimelineId.active, noteId: 'uuid.v4()', }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index c2e23cc19d89e..b6b6148340a4a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -22,7 +22,12 @@ import { DataProviderResult, } from '../../../graphql/types'; -import { DataProviderType, TimelineStatus, TimelineType } from '../../../../common/types/timeline'; +import { + DataProviderType, + TimelineId, + TimelineStatus, + TimelineType, +} from '../../../../common/types/timeline'; import { addNotes as dispatchAddNotes, @@ -315,7 +320,7 @@ export const queryTimelineById = ({ updateIsLoading, updateTimeline, }: QueryTimelineById) => { - updateIsLoading({ id: 'timeline-1', isLoading: true }); + updateIsLoading({ id: TimelineId.active, isLoading: true }); if (apolloClient) { apolloClient .query({ @@ -343,7 +348,7 @@ export const queryTimelineById = ({ updateTimeline({ duplicate, from, - id: 'timeline-1', + id: TimelineId.active, notes, timeline: { ...timeline, @@ -355,7 +360,7 @@ export const queryTimelineById = ({ } }) .finally(() => { - updateIsLoading({ id: 'timeline-1', isLoading: false }); + updateIsLoading({ id: TimelineId.active, isLoading: false }); }); } }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index 4c5db80a6c916..f681043a9047d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -11,6 +11,7 @@ import { Dispatch } from 'redux'; import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../../graphql/types'; import { State } from '../../../common/store'; +import { TimelineId } from '../../../../common/types/timeline'; import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; @@ -192,7 +193,7 @@ export const StatefulOpenTimelineComponent = React.memo( const deleteTimelines: DeleteTimelines = useCallback( async (timelineIds: string[]) => { if (timelineIds.includes(timeline.savedObjectId || '')) { - createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false }); + createNewTimeline({ id: TimelineId.active, columns: defaultHeaders, show: false }); } await apolloClient.mutate< @@ -369,7 +370,7 @@ export const StatefulOpenTimelineComponent = React.memo( const makeMapStateToProps = () => { const getTimeline = timelineSelectors.getTimelineByIdSelector(); const mapStateToProps = (state: State) => { - const timeline = getTimeline(state, 'timeline-1') ?? timelineDefaults; + const timeline = getTimeline(state, TimelineId.active) ?? timelineDefaults; return { timeline, }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx new file mode 100644 index 0000000000000..64f8ce3727f39 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { MouseEvent } from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; + +import { EventsTd, EventsTdContent } from '../../styles'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; + +interface ActionIconItemProps { + ariaLabel?: string; + id: string; + width?: number; + dataTestSubj?: string; + content?: string; + iconType?: string; + isDisabled?: boolean; + onClick?: (event: MouseEvent) => void; + children?: React.ReactNode; +} + +const ActionIconItemComponent: React.FC = ({ + id, + width = DEFAULT_ICON_BUTTON_WIDTH, + dataTestSubj, + content, + ariaLabel, + iconType, + isDisabled = false, + onClick, + children, +}) => ( + + + {children ?? ( + + + + )} + + +); + +ActionIconItemComponent.displayName = 'ActionIconItemComponent'; + +export const ActionIconItem = React.memo(ActionIconItemComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx new file mode 100644 index 0000000000000..a82821675d956 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { TimelineType, TimelineStatus } from '../../../../../../common/types/timeline'; +import { AssociateNote, UpdateNote } from '../../../notes/helpers'; +import * as i18n from '../translations'; +import { NotesButton } from '../../properties/helpers'; +import { Note } from '../../../../../common/lib/note'; +import { ActionIconItem } from './action_icon_item'; + +interface AddEventNoteActionProps { + associateNote: AssociateNote; + getNotesByIds: (noteIds: string[]) => Note[]; + noteIds: string[]; + showNotes: boolean; + status: TimelineStatus; + timelineType: TimelineType; + toggleShowNotes: () => void; + updateNote: UpdateNote; +} + +const AddEventNoteActionComponent: React.FC = ({ + associateNote, + getNotesByIds, + noteIds, + showNotes, + status, + timelineType, + toggleShowNotes, + updateNote, +}) => ( + + + +); + +AddEventNoteActionComponent.displayName = 'AddEventNoteActionComponent'; + +export const AddEventNoteAction = React.memo(AddEventNoteActionComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index 78ee9bdd053b2..fb1709df01320 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -9,10 +9,7 @@ import { useSelector } from 'react-redux'; import { TestProviders, mockTimelineModel } from '../../../../../common/mock'; import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants'; -import * as i18n from '../translations'; - import { Actions } from '.'; -import { TimelineType } from '../../../../../../common/types/timeline'; jest.mock('react-redux', () => { const origin = jest.requireActual('react-redux'); @@ -30,22 +27,14 @@ describe('Actions', () => { ); @@ -58,22 +47,14 @@ describe('Actions', () => { ); @@ -86,22 +67,14 @@ describe('Actions', () => { ); @@ -116,22 +89,14 @@ describe('Actions', () => { ); @@ -140,197 +105,4 @@ describe('Actions', () => { expect(onEventToggled).toBeCalled(); }); - - test('it does NOT render a notes button when isEventsViewer is true', () => { - const toggleShowNotes = jest.fn(); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="timeline-notes-button-small"]').exists()).toBe(false); - }); - - test('it invokes toggleShowNotes when the button for adding notes is clicked', () => { - const toggleShowNotes = jest.fn(); - - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="timeline-notes-button-small"]').first().simulate('click'); - - expect(toggleShowNotes).toBeCalled(); - }); - - test('it renders correct tooltip for NotesButton - timeline', () => { - const toggleShowNotes = jest.fn(); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual(i18n.NOTES_TOOLTIP); - }); - - test('it renders correct tooltip for NotesButton - timeline template', () => { - (useSelector as jest.Mock).mockReturnValue({ - ...mockTimelineModel, - timelineType: TimelineType.template, - }); - const toggleShowNotes = jest.fn(); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual( - i18n.NOTES_DISABLE_TOOLTIP - ); - (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); - }); - - test('it does NOT render a pin button when isEventViewer is true', () => { - const onPinClicked = jest.fn(); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="pin"]').exists()).toBe(false); - }); - - test('it invokes onPinClicked when the button for pinning events is clicked', () => { - const onPinClicked = jest.fn(); - - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="pin"]').first().simulate('click'); - - expect(onPinClicked).toHaveBeenCalled(); - }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index c9c8250922161..3d08d56d6fb19 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -3,203 +3,90 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { useSelector } from 'react-redux'; -import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { EuiButtonIcon, EuiLoadingSpinner, EuiCheckbox } from '@elastic/eui'; -import { Note } from '../../../../../common/lib/note'; -import { StoreState } from '../../../../../common/store/types'; -import { TimelineType } from '../../../../../../common/types/timeline'; - -import { TimelineModel } from '../../../../store/timeline/model'; - -import { AssociateNote, UpdateNote } from '../../../notes/helpers'; -import { Pin } from '../../pin'; -import { NotesButton } from '../../properties/helpers'; import { EventsLoading, EventsTd, EventsTdContent, EventsTdGroupActions } from '../../styles'; -import { eventHasNotes, getPinTooltip } from '../helpers'; import * as i18n from '../translations'; import { OnRowSelected } from '../../events'; -import { Ecs, TimelineNonEcsData } from '../../../../../graphql/types'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; -export interface TimelineRowActionOnClick { - eventId: string; - ecsData: Ecs; - data: TimelineNonEcsData[]; -} - -export interface TimelineRowAction { - ariaLabel?: string; - dataTestSubj?: string; - displayType: 'icon' | 'contextMenu'; - iconType?: string; - id: string; - isActionDisabled?: (ecsData?: Ecs) => boolean; - onClick: ({ eventId, ecsData }: TimelineRowActionOnClick) => void; - content: string | JSX.Element; - width?: number; -} - interface Props { actionsColumnWidth: number; additionalActions?: JSX.Element[]; - associateNote: AssociateNote; checked: boolean; onRowSelected: OnRowSelected; expanded: boolean; eventId: string; - eventIsPinned: boolean; - getNotesByIds: (noteIds: string[]) => Note[]; - isEventViewer?: boolean; loading: boolean; loadingEventIds: Readonly; - noteIds: string[]; onEventToggled: () => void; - onPinClicked: () => void; - showNotes: boolean; showCheckboxes: boolean; - toggleShowNotes: () => void; - updateNote: UpdateNote; } -const emptyNotes: string[] = []; - -export const Actions = React.memo( - ({ - actionsColumnWidth, - additionalActions, - associateNote, - checked, - expanded, - eventId, - eventIsPinned, - getNotesByIds, - isEventViewer = false, - loading = false, - loadingEventIds, - noteIds, - onEventToggled, - onPinClicked, - onRowSelected, - showCheckboxes, - showNotes, - toggleShowNotes, - updateNote, - }) => { - const timeline = useSelector((state) => { - return state.timeline.timelineById['timeline-1']; - }); - return ( - - {showCheckboxes && ( - - - {loadingEventIds.includes(eventId) ? ( - - ) : ( - ) => { - onRowSelected({ - eventIds: [eventId], - isSelected: event.currentTarget.checked, - }); - }} - /> - )} - - - )} +const ActionsComponent: React.FC = ({ + actionsColumnWidth, + additionalActions, + checked, + expanded, + eventId, + loading = false, + loadingEventIds, + onEventToggled, + onRowSelected, + showCheckboxes, +}) => { + const handleSelectEvent = useCallback( + (event: React.ChangeEvent) => + onRowSelected({ + eventIds: [eventId], + isSelected: event.currentTarget.checked, + }), + [eventId, onRowSelected] + ); - + return ( + + {showCheckboxes && ( + - {loading ? ( - + {loadingEventIds.includes(eventId) ? ( + ) : ( - )} + )} + + + {loading ? ( + + ) : ( + + )} + + - <>{additionalActions} + <>{additionalActions} + + ); +}; - {!isEventViewer && ( - <> - - - - - - - +ActionsComponent.displayName = 'ActionsComponent'; - - - - - - - )} - - ); - }, - (nextProps, prevProps) => { - return ( - prevProps.actionsColumnWidth === nextProps.actionsColumnWidth && - prevProps.additionalActions === nextProps.additionalActions && - prevProps.checked === nextProps.checked && - prevProps.expanded === nextProps.expanded && - prevProps.eventId === nextProps.eventId && - prevProps.eventIsPinned === nextProps.eventIsPinned && - prevProps.loading === nextProps.loading && - prevProps.loadingEventIds === nextProps.loadingEventIds && - prevProps.noteIds === nextProps.noteIds && - prevProps.onRowSelected === nextProps.onRowSelected && - prevProps.showCheckboxes === nextProps.showCheckboxes && - prevProps.showNotes === nextProps.showNotes - ); - } -); -Actions.displayName = 'Actions'; +export const Actions = React.memo(ActionsComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/pin_event_action.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/pin_event_action.tsx new file mode 100644 index 0000000000000..2f9f15938cad6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/pin_event_action.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiToolTip } from '@elastic/eui'; + +import { EventsTd, EventsTdContent } from '../../styles'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; +import { eventHasNotes, getPinTooltip } from '../helpers'; +import { Pin } from '../../pin'; +import { TimelineType } from '../../../../../../common/types/timeline'; + +interface PinEventActionProps { + noteIds: string[]; + onPinClicked: () => void; + eventIsPinned: boolean; + timelineType: TimelineType; +} + +const PinEventActionComponent: React.FC = ({ + noteIds, + onPinClicked, + eventIsPinned, + timelineType, +}) => { + const tooltipContent = useMemo( + () => + getPinTooltip({ + isPinned: eventIsPinned, + eventHasNotes: eventHasNotes(noteIds), + timelineType, + }), + [eventIsPinned, noteIds, timelineType] + ); + + return ( + + + + + + + + ); +}; + +PinEventActionComponent.displayName = 'PinEventActionComponent'; + +export const PinEventAction = React.memo(PinEventActionComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index a3e177604fbd4..120fc12b425f4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -235,7 +235,6 @@ export const ColumnHeadersComponent = ({ columnHeaders={columnHeaders} data-test-subj="field-browser" height={FIELD_BROWSER_HEIGHT} - isEventViewer={isEventViewer} onUpdateColumns={onUpdateColumns} timelineId={timelineId} toggleColumn={toggleColumn} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx new file mode 100644 index 0000000000000..ae552ade665cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { mount } from 'enzyme'; +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { TestProviders, mockTimelineModel } from '../../../../../common/mock'; +import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants'; +import * as i18n from '../translations'; + +import { EventColumnView } from './event_column_view'; +import { TimelineType } from '../../../../../../common/types/timeline'; + +jest.mock('react-redux', () => { + const origin = jest.requireActual('react-redux'); + return { + ...origin, + useSelector: jest.fn(), + }; +}); + +describe('EventColumnView', () => { + (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + + const props = { + id: 'event-id', + actionsColumnWidth: DEFAULT_ACTIONS_COLUMN_WIDTH, + associateNote: jest.fn(), + columnHeaders: [], + columnRenderers: [], + data: [ + { + field: 'host.name', + }, + ], + ecsData: { + _id: 'id', + }, + eventIdToNoteIds: {}, + expanded: false, + getNotesByIds: jest.fn(), + loading: false, + loadingEventIds: [], + onColumnResized: jest.fn(), + onEventToggled: jest.fn(), + onPinEvent: jest.fn(), + onRowSelected: jest.fn(), + onUnPinEvent: jest.fn(), + refetch: jest.fn(), + selectedEventIds: {}, + showCheckboxes: false, + showNotes: false, + timelineId: 'timeline-1', + toggleShowNotes: jest.fn(), + updateNote: jest.fn(), + isEventPinned: false, + }; + + test('it does NOT render a notes button when isEventsViewer is true', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('[data-test-subj="timeline-notes-button-small"]').exists()).toBe(false); + }); + + test('it invokes toggleShowNotes when the button for adding notes is clicked', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + + expect(props.toggleShowNotes).not.toHaveBeenCalled(); + + wrapper.find('[data-test-subj="timeline-notes-button-small"]').first().simulate('click'); + + expect(props.toggleShowNotes).toHaveBeenCalled(); + }); + + test('it renders correct tooltip for NotesButton - timeline', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + + expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual(i18n.NOTES_TOOLTIP); + }); + + test('it renders correct tooltip for NotesButton - timeline template', () => { + (useSelector as jest.Mock).mockReturnValue({ + ...mockTimelineModel, + timelineType: TimelineType.template, + }); + + const wrapper = mount(, { wrappingComponent: TestProviders }); + + expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual( + i18n.NOTES_DISABLE_TOOLTIP + ); + (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + }); + + test('it does NOT render a pin button when isEventViewer is true', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('[data-test-subj="pin"]').exists()).toBe(false); + }); + + test('it invokes onPinClicked when the button for pinning events is clicked', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + + expect(props.onPinEvent).not.toHaveBeenCalled(); + + wrapper.find('[data-test-subj="pin"]').first().simulate('click'); + + expect(props.onPinEvent).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index e7462188001e9..f1d45d5458554 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -4,29 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import uuid from 'uuid'; +import { useSelector, shallowEqual } from 'react-redux'; -import { - EuiButtonIcon, - EuiToolTip, - EuiContextMenuPanel, - EuiPopover, - EuiContextMenuItem, -} from '@elastic/eui'; -import styled from 'styled-components'; import { TimelineNonEcsData, Ecs } from '../../../../../graphql/types'; -import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; import { Note } from '../../../../../common/lib/note'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { AssociateNote, UpdateNote } from '../../../notes/helpers'; import { OnColumnResized, OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; -import { EventsTd, EventsTdContent, EventsTrData } from '../../styles'; +import { EventsTrData } from '../../styles'; import { Actions } from '../actions'; import { DataDrivenColumns } from '../data_driven_columns'; -import { eventHasNotes, getPinOnClick } from '../helpers'; +import { + eventHasNotes, + getEventType, + getPinOnClick, + InvestigateInResolverAction, +} from '../helpers'; import { ColumnRenderer } from '../renderers/column_renderer'; -import { useManageTimeline } from '../../../manage_timeline'; +import { AlertContextMenu } from '../../../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; +import { InvestigateInTimelineAction } from '../../../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action'; +import { AddEventNoteAction } from '../actions/add_note_icon_item'; +import { PinEventAction } from '../actions/pin_event_action'; +import { StoreState } from '../../../../../common/store/types'; +import { inputsModel } from '../../../../../common/store'; +import { TimelineId } from '../../../../../../common/types/timeline'; + +import { TimelineModel } from '../../../../store/timeline/model'; interface Props { id: string; @@ -48,6 +53,7 @@ interface Props { onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; onUnPinEvent: OnUnPinEvent; + refetch: inputsModel.Refetch; selectedEventIds: Readonly>; showCheckboxes: boolean; showNotes: boolean; @@ -81,6 +87,7 @@ export const EventColumnView = React.memo( onPinEvent, onRowSelected, onUnPinEvent, + refetch, selectedEventIds, showCheckboxes, showNotes, @@ -88,114 +95,10 @@ export const EventColumnView = React.memo( toggleShowNotes, updateNote, }) => { - const { getManageTimelineById } = useManageTimeline(); - const timelineActions = useMemo( - () => getManageTimelineById(timelineId).timelineRowActions({ nonEcsData: data, ecsData }), - [data, ecsData, getManageTimelineById, timelineId] + const { timelineType, status } = useSelector( + (state) => state.timeline.timelineById[timelineId], + shallowEqual ); - const [isPopoverOpen, setPopover] = useState(false); - - const onButtonClick = useCallback(() => { - setPopover(!isPopoverOpen); - }, [isPopoverOpen]); - - const closePopover = useCallback(() => { - setPopover(false); - }, []); - - const button = ( - - ); - - const onClickCb = useCallback((cb: () => void) => { - cb(); - closePopover(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const additionalActions = useMemo(() => { - const grouped = timelineActions.reduce( - ( - acc: { - contextMenu: JSX.Element[]; - icon: JSX.Element[]; - }, - action - ) => { - if (action.displayType === 'icon') { - return { - ...acc, - icon: [ - ...acc.icon, - - - - action.onClick({ eventId: id, ecsData, data })} - /> - - - , - ], - }; - } - return { - ...acc, - contextMenu: [ - ...acc.contextMenu, - onClickCb(() => action.onClick({ eventId: id, ecsData, data }))} - > - {action.content} - , - ], - }; - }, - { icon: [], contextMenu: [] } - ); - return grouped.contextMenu.length > 0 - ? [ - ...grouped.icon, - - - - - - - , - ] - : grouped.icon; - }, [button, closePopover, id, onClickCb, data, ecsData, timelineActions, isPopoverOpen]); const handlePinClicked = useCallback( () => @@ -209,29 +112,90 @@ export const EventColumnView = React.memo( [eventIdToNoteIds, id, isEventPinned, onPinEvent, onUnPinEvent] ); + const eventType = getEventType(ecsData); + + const additionalActions = useMemo( + () => [ + , + ...(timelineId !== TimelineId.active && eventType === 'signal' + ? [ + , + ] + : []), + ...(!isEventViewer + ? [ + , + , + ] + : []), + , + ], + [ + associateNote, + data, + ecsData, + eventIdToNoteIds, + eventType, + getNotesByIds, + handlePinClicked, + id, + isEventPinned, + isEventViewer, + refetch, + showNotes, + status, + timelineId, + timelineType, + toggleShowNotes, + updateNote, + ] + ); + return ( ( /> ); - }, - (prevProps, nextProps) => { - return ( - prevProps.id === nextProps.id && - prevProps.actionsColumnWidth === nextProps.actionsColumnWidth && - prevProps.columnHeaders === nextProps.columnHeaders && - prevProps.columnRenderers === nextProps.columnRenderers && - prevProps.data === nextProps.data && - prevProps.eventIdToNoteIds === nextProps.eventIdToNoteIds && - prevProps.expanded === nextProps.expanded && - prevProps.loading === nextProps.loading && - prevProps.loadingEventIds === nextProps.loadingEventIds && - prevProps.isEventPinned === nextProps.isEventPinned && - prevProps.onRowSelected === nextProps.onRowSelected && - prevProps.selectedEventIds === nextProps.selectedEventIds && - prevProps.showCheckboxes === nextProps.showCheckboxes && - prevProps.showNotes === nextProps.showNotes && - prevProps.timelineId === nextProps.timelineId - ); } ); -const ContextMenuPanel = styled(EuiContextMenuPanel)` - font-size: ${({ theme }) => theme.eui.euiFontSizeS}; -`; -ContextMenuPanel.displayName = 'ContextMenuPanel'; +EventColumnView.displayName = 'EventColumnView'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx index ca7a64db58c95..64d55f8cf6c6a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import { inputsModel } from '../../../../../common/store'; import { BrowserFields, DocValueFields } from '../../../../../common/containers/source'; import { TimelineItem, TimelineNonEcsData } from '../../../../../graphql/types'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; @@ -44,6 +45,7 @@ interface Props { onUpdateColumns: OnUpdateColumns; onUnPinEvent: OnUnPinEvent; pinnedEventIds: Readonly>; + refetch: inputsModel.Refetch; rowRenderers: RowRenderer[]; selectedEventIds: Readonly>; showCheckboxes: boolean; @@ -71,6 +73,7 @@ const EventsComponent: React.FC = ({ onUpdateColumns, onUnPinEvent, pinnedEventIds, + refetch, rowRenderers, selectedEventIds, showCheckboxes, @@ -78,7 +81,7 @@ const EventsComponent: React.FC = ({ updateNote, }) => ( - {data.map((event, i) => ( + {data.map((event) => ( = ({ onRowSelected={onRowSelected} onUnPinEvent={onUnPinEvent} onUpdateColumns={onUpdateColumns} + refetch={refetch} rowRenderers={rowRenderers} selectedEventIds={selectedEventIds} showCheckboxes={showCheckboxes} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 3236482e6bc27..c91fc473708e2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -9,6 +9,7 @@ import { useSelector } from 'react-redux'; import uuid from 'uuid'; import VisibilitySensor from 'react-visibility-sensor'; +import { TimelineId } from '../../../../../../common/types/timeline'; import { BrowserFields, DocValueFields } from '../../../../../common/containers/source'; import { TimelineDetailsQuery } from '../../../../containers/details'; import { TimelineItem, DetailItem, TimelineNonEcsData } from '../../../../../graphql/types'; @@ -33,7 +34,7 @@ import { getEventType } from '../helpers'; import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; -import { StoreState } from '../../../../../common/store'; +import { inputsModel, StoreState } from '../../../../../common/store'; interface Props { actionsColumnWidth: number; @@ -55,6 +56,7 @@ interface Props { onUnPinEvent: OnUnPinEvent; onUpdateColumns: OnUpdateColumns; isEventPinned: boolean; + refetch: inputsModel.Refetch; rowRenderers: RowRenderer[]; selectedEventIds: Readonly>; showCheckboxes: boolean; @@ -121,6 +123,7 @@ const StatefulEventComponent: React.FC = ({ onRowSelected, onUnPinEvent, onUpdateColumns, + refetch, rowRenderers, selectedEventIds, showCheckboxes, @@ -130,9 +133,9 @@ const StatefulEventComponent: React.FC = ({ }) => { const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); - const timeline = useSelector((state) => { - return state.timeline.timelineById['timeline-1']; - }); + const { status: timelineStatus } = useSelector( + (state) => state.timeline.timelineById[TimelineId.active] + ); const divElement = useRef(null); const onToggleShowNotes = useCallback(() => { @@ -206,6 +209,7 @@ const StatefulEventComponent: React.FC = ({ onPinEvent={onPinEvent} onRowSelected={onRowSelected} onUnPinEvent={onUnPinEvent} + refetch={refetch} selectedEventIds={selectedEventIds} showCheckboxes={showCheckboxes} showNotes={!!showNotes[event._id]} @@ -226,7 +230,7 @@ const StatefulEventComponent: React.FC = ({ getNotesByIds={getNotesByIds} noteIds={eventIdToNoteIds[event._id] || emptyNotes} showAddNote={!!showNotes[event._id]} - status={timeline.status} + status={timelineStatus} toggleShowAddNote={onToggleShowNotes} updateNote={updateNote} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx similarity index 67% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts rename to x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx index b62888fbf8427..5753efa2bf1bb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx @@ -4,16 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import React, { useCallback, useMemo } from 'react'; import { get, isEmpty } from 'lodash/fp'; -import { Dispatch } from 'redux'; +import { useDispatch } from 'react-redux'; import { Ecs, TimelineItem, TimelineNonEcsData } from '../../../../graphql/types'; -import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; import { updateTimelineGraphEventId } from '../../../store/timeline/actions'; -import { EventType } from '../../../../timelines/store/timeline/model'; +import { EventType } from '../../../store/timeline/model'; import { OnPinEvent, OnUnPinEvent } from '../events'; - -import { TimelineRowAction, TimelineRowActionOnClick } from './actions'; +import { ActionIconItem } from './actions/action_icon_item'; import * as i18n from './translations'; import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline'; @@ -89,8 +88,8 @@ export const getEventIdToDataMapping = ( timelineData: TimelineItem[], eventIds: string[], fieldsToKeep: string[] -): Record => { - return timelineData.reduce((acc, v) => { +): Record => + timelineData.reduce((acc, v) => { const fvm = eventIds.includes(v._id) ? { [v._id]: v.data.filter((ti) => fieldsToKeep.includes(ti.field)) } : {}; @@ -99,7 +98,6 @@ export const getEventIdToDataMapping = ( ...fvm, }; }, {}); -}; /** Return eventType raw or signal */ export const getEventType = (event: Ecs): Omit => { @@ -109,29 +107,40 @@ export const getEventType = (event: Ecs): Omit => { return 'raw'; }; -export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => { +export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => + get(['agent', 'type', 0], ecsData) === 'endpoint' && + get(['process', 'entity_id'], ecsData)?.length === 1 && + get(['process', 'entity_id', 0], ecsData) !== ''; + +interface InvestigateInResolverActionProps { + timelineId: string; + ecsData: Ecs; +} + +const InvestigateInResolverActionComponent: React.FC = ({ + timelineId, + ecsData, +}) => { + const dispatch = useDispatch(); + const isDisabled = useMemo(() => !isInvestigateInResolverActionEnabled(ecsData), [ecsData]); + const handleClick = useCallback( + () => dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: ecsData._id })), + [dispatch, ecsData._id, timelineId] + ); + return ( - get(['agent', 'type', 0], ecsData) === 'endpoint' && - get(['process', 'entity_id'], ecsData)?.length === 1 && - get(['process', 'entity_id', 0], ecsData) !== '' + ); }; -export const getInvestigateInResolverAction = ({ - dispatch, - timelineId, -}: { - dispatch: Dispatch; - timelineId: string; -}): TimelineRowAction => ({ - ariaLabel: i18n.ACTION_INVESTIGATE_IN_RESOLVER, - content: i18n.ACTION_INVESTIGATE_IN_RESOLVER, - dataTestSubj: 'investigate-in-resolver', - displayType: 'icon', - iconType: 'node', - id: 'investigateInResolver', - isActionDisabled: (ecsData?: Ecs) => !isInvestigateInResolverActionEnabled(ecsData), - onClick: ({ eventId }: TimelineRowActionOnClick) => - dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: eventId })), - width: DEFAULT_ICON_BUTTON_WIDTH, -}); +InvestigateInResolverActionComponent.displayName = 'InvestigateInResolverActionComponent'; + +export const InvestigateInResolverAction = React.memo(InvestigateInResolverActionComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 4eac5360321c1..657e1617e8d24 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -64,7 +64,6 @@ describe('Body', () => { data: mockTimelineData, docValueFields: [], eventIdToNoteIds: {}, - id: 'timeline-test', isSelectAllChecked: false, getNotesByIds: mockGetNotesByIds, loadingEventIds: [], @@ -78,11 +77,13 @@ describe('Body', () => { onUnPinEvent: jest.fn(), onUpdateColumns: jest.fn(), pinnedEventIds: {}, + refetch: jest.fn(), rowRenderers, selectedEventIds: {}, show: true, sort: mockSort, showCheckboxes: false, + timelineId: 'timeline-test', timelineType: TimelineType.default, toggleColumn: jest.fn(), updateNote: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 6f578ffe3e956..40cc12afde51d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -6,10 +6,11 @@ import React, { useMemo, useRef } from 'react'; +import { inputsModel } from '../../../../common/store'; import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; import { TimelineItem, TimelineNonEcsData } from '../../../../graphql/types'; import { Note } from '../../../../common/lib/note'; -import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions, EventType } from '../../../../timelines/store/timeline/model'; import { AddNoteToEvent, UpdateNote } from '../../notes/helpers'; import { OnColumnRemoved, @@ -42,10 +43,10 @@ export interface BodyProps { docValueFields: DocValueFields[]; getNotesByIds: (noteIds: string[]) => Note[]; graphEventId?: string; - id: string; isEventViewer?: boolean; isSelectAllChecked: boolean; eventIdToNoteIds: Readonly>; + eventType?: EventType; loadingEventIds: Readonly; onColumnRemoved: OnColumnRemoved; onColumnResized: OnColumnResized; @@ -57,18 +58,23 @@ export interface BodyProps { onUpdateColumns: OnUpdateColumns; onUnPinEvent: OnUnPinEvent; pinnedEventIds: Readonly>; + refetch: inputsModel.Refetch; rowRenderers: RowRenderer[]; selectedEventIds: Readonly>; show: boolean; showCheckboxes: boolean; sort: Sort; + timelineId: string; timelineType: TimelineType; toggleColumn: (column: ColumnHeaderOptions) => void; updateNote: UpdateNote; } -export const hasAdditonalActions = (id: string): boolean => - id === TimelineId.detectionsPage || id === TimelineId.detectionsRulesDetailsPage; +export const hasAdditionalActions = (id: string, eventType?: EventType): boolean => + id === TimelineId.detectionsPage || + id === TimelineId.detectionsRulesDetailsPage || + ((id === TimelineId.active && eventType && ['all', 'signal', 'alert'].includes(eventType)) ?? + false); const EXTRA_WIDTH = 4; // px @@ -82,9 +88,9 @@ export const Body = React.memo( data, docValueFields, eventIdToNoteIds, + eventType, getNotesByIds, graphEventId, - id, isEventViewer = false, isSelectAllChecked, loadingEventIds, @@ -99,11 +105,13 @@ export const Body = React.memo( onUnPinEvent, pinnedEventIds, rowRenderers, + refetch, selectedEventIds, show, showCheckboxes, sort, toggleColumn, + timelineId, timelineType, updateNote, }) => { @@ -113,9 +121,9 @@ export const Body = React.memo( getActionsColumnWidth( isEventViewer, showCheckboxes, - hasAdditonalActions(id) ? DEFAULT_ICON_BUTTON_WIDTH + EXTRA_WIDTH : 0 + hasAdditionalActions(timelineId, eventType) ? DEFAULT_ICON_BUTTON_WIDTH + EXTRA_WIDTH : 0 ), - [isEventViewer, showCheckboxes, id] + [isEventViewer, showCheckboxes, timelineId, eventType] ); const columnWidths = useMemo( @@ -127,11 +135,15 @@ export const Body = React.memo( return ( <> {graphEventId && ( - + )} @@ -151,7 +163,7 @@ export const Body = React.memo( showEventsSelect={false} showSelectAllCheckbox={showCheckboxes} sort={sort} - timelineId={id} + timelineId={timelineId} toggleColumn={toggleColumn} /> @@ -166,7 +178,7 @@ export const Body = React.memo( docValueFields={docValueFields} eventIdToNoteIds={eventIdToNoteIds} getNotesByIds={getNotesByIds} - id={id} + id={timelineId} isEventViewer={isEventViewer} loadingEventIds={loadingEventIds} onColumnResized={onColumnResized} @@ -175,6 +187,7 @@ export const Body = React.memo( onUpdateColumns={onUpdateColumns} onUnPinEvent={onUnPinEvent} pinnedEventIds={pinnedEventIds} + refetch={refetch} rowRenderers={rowRenderers} selectedEventIds={selectedEventIds} showCheckboxes={showCheckboxes} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx index 8deda03ece70e..9b7b896a2ec69 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx @@ -14,7 +14,7 @@ import { RowRendererId, TimelineId } from '../../../../../common/types/timeline' import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; import { TimelineItem } from '../../../../graphql/types'; import { Note } from '../../../../common/lib/note'; -import { appSelectors, State } from '../../../../common/store'; +import { appSelectors, inputsModel, State } from '../../../../common/store'; import { appActions } from '../../../../common/store/actions'; import { useManageTimeline } from '../../manage_timeline'; import { ColumnHeaderOptions, TimelineModel } from '../../../store/timeline/model'; @@ -46,6 +46,7 @@ interface OwnProps { isEventViewer?: boolean; sort: Sort; toggleColumn: (column: ColumnHeaderOptions) => void; + refetch: inputsModel.Refetch; } type StatefulBodyComponentProps = OwnProps & PropsFromRedux; @@ -61,6 +62,7 @@ const StatefulBodyComponent = React.memo( data, docValueFields, eventIdToNoteIds, + eventType, excludedRowRendererIds, id, isEventViewer = false, @@ -76,6 +78,7 @@ const StatefulBodyComponent = React.memo( show, showCheckboxes, graphEventId, + refetch, sort, timelineType, toggleColumn, @@ -195,9 +198,9 @@ const StatefulBodyComponent = React.memo( data={data} docValueFields={docValueFields} eventIdToNoteIds={eventIdToNoteIds} + eventType={eventType} getNotesByIds={getNotesByIds} graphEventId={graphEventId} - id={id} isEventViewer={isEventViewer} isSelectAllChecked={isSelectAllChecked} loadingEventIds={loadingEventIds} @@ -211,11 +214,13 @@ const StatefulBodyComponent = React.memo( onUnPinEvent={onUnPinEvent} onUpdateColumns={onUpdateColumns} pinnedEventIds={pinnedEventIds} + refetch={refetch} rowRenderers={enabledRowRenderers} selectedEventIds={selectedEventIds} show={id === TimelineId.active ? show : true} showCheckboxes={showCheckboxes} sort={sort} + timelineId={id} timelineType={timelineType} toggleColumn={toggleColumn} updateNote={onUpdateNote} @@ -229,6 +234,7 @@ const StatefulBodyComponent = React.memo( deepEqual(prevProps.excludedRowRendererIds, nextProps.excludedRowRendererIds) && deepEqual(prevProps.docValueFields, nextProps.docValueFields) && prevProps.eventIdToNoteIds === nextProps.eventIdToNoteIds && + prevProps.eventType === nextProps.eventType && prevProps.graphEventId === nextProps.graphEventId && deepEqual(prevProps.notesById, nextProps.notesById) && prevProps.id === nextProps.id && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 8f18a173f3bed..4ab05af5dd6d4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -331,26 +331,23 @@ const LargeNotesButton = React.memo(({ noteIds, text, tog LargeNotesButton.displayName = 'LargeNotesButton'; interface SmallNotesButtonProps { - noteIds: string[]; toggleShowNotes: () => void; timelineType: TimelineTypeLiteral; } -const SmallNotesButton = React.memo( - ({ noteIds, toggleShowNotes, timelineType }) => { - const isTemplate = timelineType === TimelineType.template; - - return ( - toggleShowNotes()} - isDisabled={isTemplate} - /> - ); - } -); +const SmallNotesButton = React.memo(({ toggleShowNotes, timelineType }) => { + const isTemplate = timelineType === TimelineType.template; + + return ( + toggleShowNotes()} + isDisabled={isTemplate} + /> + ); +}); SmallNotesButton.displayName = 'SmallNotesButton'; /** @@ -375,11 +372,7 @@ const NotesButtonComponent = React.memo( {size === 'l' ? ( ) : ( - + )} {size === 'l' && showNotes ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx index e88ecee81d364..b5aadaa6f1ef8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { TimelineType } from '../../../../../common/types/timeline'; +import { TimelineId, TimelineType } from '../../../../../common/types/timeline'; import { useKibana } from '../../../../common/lib/kibana'; import { useCreateTimelineButton } from './use_create_timeline'; @@ -22,7 +22,7 @@ export const NewTemplateTimelineComponent: React.FC = ({ closeGearMenu, outline, title, - timelineId = 'timeline-1', + timelineId = TimelineId.active, }) => { const uiCapabilities = useKibana().services.application.capabilities; const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.siem.crud; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index a2ee1e56306b5..7b1c1bd2119cd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -7,7 +7,6 @@ import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter, EuiProgress } from '@elastic/eui'; import { getOr, isEmpty } from 'lodash/fp'; import React, { useState, useMemo, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { FlyoutHeaderWithCloseButton } from '../flyout/header_with_close_button'; @@ -17,7 +16,6 @@ import { Direction } from '../../../graphql/types'; import { useKibana } from '../../../common/lib/kibana'; import { ColumnHeaderOptions, KqlMode, EventType } from '../../../timelines/store/timeline/model'; import { defaultHeaders } from './body/column_headers/default_headers'; -import { getInvestigateInResolverAction } from './body/helpers'; import { Sort } from './body/sort'; import { StatefulBody } from './body/stateful_body'; import { DataProvider } from './data_providers/data_provider'; @@ -43,6 +41,7 @@ import { } from '../../../../../../../src/plugins/data/public'; import { useManageTimeline } from '../manage_timeline'; import { TimelineType, TimelineStatusLiteral } from '../../../../common/types/timeline'; +import { requiredFieldsForActions } from '../../../detections/components/alerts_table/default_config'; const TimelineContainer = styled.div` height: 100%; @@ -168,7 +167,6 @@ export const TimelineComponent: React.FC = ({ toggleColumn, usersViewing, }) => { - const dispatch = useDispatch(); const kibana = useKibana(); const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); const esQueryConfig = useMemo(() => esQuery.getEsQueryConfig(kibana.services.uiSettings), [ @@ -213,7 +211,10 @@ export const TimelineComponent: React.FC = ({ [isLoadingSource, combinedQueries, start, end] ); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; - const timelineQueryFields = useMemo(() => columnsHeader.map((c) => c.id), [columnsHeader]); + const timelineQueryFields = useMemo(() => { + const columnFields = columnsHeader.map((c) => c.id); + return [...columnFields, ...requiredFieldsForActions]; + }, [columnsHeader]); const timelineQuerySortField = useMemo( () => ({ sortFieldId: sort.columnId, @@ -228,7 +229,6 @@ export const TimelineComponent: React.FC = ({ filterManager, id, indexToAdd, - timelineRowActions: () => [getInvestigateInResolverAction({ dispatch, timelineId: id })], }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -317,6 +317,7 @@ export const TimelineComponent: React.FC = ({ data={events} docValueFields={docValueFields} id={id} + refetch={refetch} sort={sort} toggleColumn={toggleColumn} /> diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts index 5a162fd2206a1..c67ad45bede94 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts @@ -200,6 +200,7 @@ export const timelineQuery = gql` country_iso_code } signal { + status original_time rule { id diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index 1d2e16b3fe5b8..79d0f909c7d59 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -9,7 +9,7 @@ import React, { useCallback, useState } from 'react'; import styled from 'styled-components'; import { useParams } from 'react-router-dom'; -import { TimelineType } from '../../../common/types/timeline'; +import { TimelineId, TimelineType } from '../../../common/types/timeline'; import { HeaderPage } from '../../common/components/header_page'; import { WrapperPage } from '../../common/components/wrapper_page'; import { useKibana } from '../../common/lib/kibana'; @@ -65,7 +65,7 @@ export const TimelinesPageComponent: React.FC = () => { {tabName === TimelineType.default ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 06dd6f44bea94..8c3f30c75c35b 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -42,7 +42,7 @@ import { Direction } from '../../../graphql/types'; import { addTimelineInStorage } from '../../containers/local_storage'; import { isPageTimeline } from './epic_local_storage'; -import { TimelineStatus, TimelineType } from '../../../../common/types/timeline'; +import { TimelineId, TimelineStatus, TimelineType } from '../../../../common/types/timeline'; jest.mock('../../containers/local_storage'); @@ -115,7 +115,7 @@ describe('epicLocalStorage', () => { }); it('filters correctly page timelines', () => { - expect(isPageTimeline('timeline-1')).toBe(false); + expect(isPageTimeline(TimelineId.active)).toBe(false); expect(isPageTimeline('hosts-page-alerts')).toBe(true); }); diff --git a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts index bdc69f85d3542..60c2ce8ceca64 100644 --- a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts @@ -424,6 +424,7 @@ export const ecsSchema = gql` type SignalField { rule: RuleField original_time: ToStringArray + status: ToStringArray } type RuleEcsField { diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index fa55af351651e..7638ebd03f6b1 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -1022,6 +1022,8 @@ export interface SignalField { rule?: Maybe; original_time?: Maybe; + + status?: Maybe; } export interface RuleField { @@ -4930,6 +4932,8 @@ export namespace SignalFieldResolvers { rule?: RuleResolver, TypeParent, TContext>; original_time?: OriginalTimeResolver, TypeParent, TContext>; + + status?: StatusResolver, TypeParent, TContext>; } export type RuleResolver< @@ -4942,6 +4946,11 @@ export namespace SignalFieldResolvers { Parent = SignalField, TContext = SiemContext > = Resolver; + export type StatusResolver< + R = Maybe, + Parent = SignalField, + TContext = SiemContext + > = Resolver; } export namespace RuleFieldResolvers { diff --git a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts b/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts index 19b16bd4bc6d2..d1c8290b3462d 100644 --- a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts +++ b/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts @@ -324,6 +324,7 @@ export const signalFieldsMap: Readonly> = { 'signal.rule.note': 'signal.rule.note', 'signal.rule.threshold': 'signal.rule.threshold', 'signal.rule.exceptions_list': 'signal.rule.exceptions_list', + 'signal.status': 'signal.status', }; export const ruleFieldsMap: Readonly> = { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts index 245146dda183f..90d5b538a5200 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts @@ -5,7 +5,7 @@ */ import { omit } from 'lodash/fp'; -import { TimelineType, TimelineStatus } from '../../../../../common/types/timeline'; +import { TimelineId, TimelineType, TimelineStatus } from '../../../../../common/types/timeline'; export const mockDuplicateIdErrors = []; @@ -332,8 +332,7 @@ export const mockCheckTimelinesStatusBeforeInstallResult = { value: '3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', operator: ':', }, - id: - 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + id: `send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-signal-id-3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509`, enabled: true, }, ], @@ -496,8 +495,7 @@ export const mockCheckTimelinesStatusBeforeInstallResult = { value: '30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', operator: ':', }, - id: - 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', + id: `send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-signal-id-30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d`, enabled: true, }, ], @@ -675,8 +673,7 @@ export const mockCheckTimelinesStatusBeforeInstallResult = { value: '590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', operator: ':', }, - id: - 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', + id: `send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-signal-id-590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde`, enabled: true, }, ], @@ -848,8 +845,7 @@ export const mockCheckTimelinesStatusAfterInstallResult = { value: '30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', operator: ':', }, - id: - 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', + id: `send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-signal-id-30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d`, enabled: true, }, ], @@ -1031,8 +1027,7 @@ export const mockCheckTimelinesStatusAfterInstallResult = { value: '590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', operator: ':', }, - id: - 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', + id: `send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-signal-id-590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde`, enabled: true, }, ], @@ -1152,8 +1147,7 @@ export const mockCheckTimelinesStatusAfterInstallResult = { value: '3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', operator: ':', }, - id: - 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + id: `send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-signal-id-3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509`, enabled: true, }, ], From 1c234bfd25124cfa2b7af5a47f7c595623f5dac9 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 1 Sep 2020 15:33:23 -0400 Subject: [PATCH 07/53] [SECURITY_SOLUTION][ENDPOINT] Trusted Apps Create API (#76178) * Create Trusted App API --- .../common/endpoint/constants.ts | 1 + .../endpoint/schema/trusted_apps.test.ts | 178 +++++++++++++++++- .../common/endpoint/schema/trusted_apps.ts | 17 ++ .../common/endpoint/types/trusted_apps.ts | 11 +- .../endpoint/routes/trusted_apps/handlers.ts | 34 +++- .../endpoint/routes/trusted_apps/index.ts | 22 ++- .../routes/trusted_apps/trusted_apps.test.ts | 119 +++++++++++- .../endpoint/routes/trusted_apps/utils.ts | 32 +++- 8 files changed, 404 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 507ce63c7b815..b72a52f0a0eb7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -13,3 +13,4 @@ export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurre export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; +export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index 7aec8e15c317c..b0c769216732d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { GetTrustedAppsRequestSchema } from './trusted_apps'; +import { GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema } from './trusted_apps'; describe('When invoking Trusted Apps Schema', () => { describe('for GET List', () => { @@ -68,4 +68,180 @@ describe('When invoking Trusted Apps Schema', () => { }); }); }); + + describe('for POST Create', () => { + const getCreateTrustedAppItem = () => ({ + name: 'Some Anti-Virus App', + description: 'this one is ok', + os: 'windows', + entries: [ + { + field: 'path', + type: 'match', + operator: 'included', + value: 'c:/programs files/Anti-Virus', + }, + ], + }); + const body = PostTrustedAppCreateRequestSchema.body; + + it('should not error on a valid message', () => { + const bodyMsg = getCreateTrustedAppItem(); + expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); + }); + + it('should validate `name` is required', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + name: undefined, + }; + expect(() => body.validate(bodyMsg)).toThrow(); + }); + + it('should validate `name` value to be non-empty', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + name: '', + }; + expect(() => body.validate(bodyMsg)).toThrow(); + }); + + it('should validate `description` as optional', () => { + const { description, ...bodyMsg } = getCreateTrustedAppItem(); + expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); + }); + + it('should validate `description` to be non-empty if defined', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + description: '', + }; + expect(() => body.validate(bodyMsg)).toThrow(); + }); + + it('should validate `os` to to only accept known values', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + os: undefined, + }; + expect(() => body.validate(bodyMsg)).toThrow(); + + const bodyMsg2 = { + ...bodyMsg, + os: '', + }; + expect(() => body.validate(bodyMsg2)).toThrow(); + + const bodyMsg3 = { + ...bodyMsg, + os: 'winz', + }; + expect(() => body.validate(bodyMsg3)).toThrow(); + + ['linux', 'macos', 'windows'].forEach((os) => { + expect(() => { + body.validate({ + ...bodyMsg, + os, + }); + }).not.toThrow(); + }); + }); + + it('should validate `entries` as required', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: undefined, + }; + expect(() => body.validate(bodyMsg)).toThrow(); + + const { entries, ...bodyMsg2 } = getCreateTrustedAppItem(); + expect(() => body.validate(bodyMsg2)).toThrow(); + }); + + it('should validate `entries` to have at least 1 item', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [], + }; + expect(() => body.validate(bodyMsg)).toThrow(); + }); + + describe('when `entries` are defined', () => { + const getTrustedAppItemEntryItem = () => getCreateTrustedAppItem().entries[0]; + + it('should validate `entry.field` is required', () => { + const { field, ...entry } = getTrustedAppItemEntryItem(); + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [entry], + }; + expect(() => body.validate(bodyMsg)).toThrow(); + }); + + it('should validate `entry.field` is limited to known values', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + field: '', + }, + ], + }; + expect(() => body.validate(bodyMsg)).toThrow(); + + const bodyMsg2 = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + field: 'invalid value', + }, + ], + }; + expect(() => body.validate(bodyMsg2)).toThrow(); + + ['hash', 'path'].forEach((field) => { + const bodyMsg3 = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + field, + }, + ], + }; + + expect(() => body.validate(bodyMsg3)).not.toThrow(); + }); + }); + + it.todo('should validate `entry.type` is limited to known values'); + + it.todo('should validate `entry.operator` is limited to known values'); + + it('should validate `entry.value` required', () => { + const { value, ...entry } = getTrustedAppItemEntryItem(); + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [entry], + }; + expect(() => body.validate(bodyMsg)).toThrow(); + }); + + it('should validate `entry.value` is non-empty', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + value: '', + }, + ], + }; + expect(() => body.validate(bodyMsg)).toThrow(); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 20fab93aaf304..7535b23a10e8a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -12,3 +12,20 @@ export const GetTrustedAppsRequestSchema = { per_page: schema.maybe(schema.number({ defaultValue: 20, min: 1 })), }), }; + +export const PostTrustedAppCreateRequestSchema = { + body: schema.object({ + name: schema.string({ minLength: 1 }), + description: schema.maybe(schema.string({ minLength: 1 })), + os: schema.oneOf([schema.literal('linux'), schema.literal('macos'), schema.literal('windows')]), + entries: schema.arrayOf( + schema.object({ + field: schema.oneOf([schema.literal('hash'), schema.literal('path')]), + type: schema.literal('match'), + operator: schema.literal('included'), + value: schema.string({ minLength: 1 }), + }), + { minSize: 1 } + ), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 2905274bef1cb..7aeb6c6024b99 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -5,7 +5,10 @@ */ import { TypeOf } from '@kbn/config-schema'; -import { GetTrustedAppsRequestSchema } from '../schema/trusted_apps'; +import { + GetTrustedAppsRequestSchema, + PostTrustedAppCreateRequestSchema, +} from '../schema/trusted_apps'; /** API request params for retrieving a list of Trusted Apps */ export type GetTrustedAppsListRequest = TypeOf; @@ -16,6 +19,12 @@ export interface GetTrustedListAppsResponse { data: TrustedApp[]; } +/** API Request body for creating a new Trusted App entry */ +export type PostTrustedAppCreateRequest = TypeOf; +export interface PostTrustedAppCreateResponse { + data: TrustedApp; +} + interface MacosLinuxConditionEntry { field: 'hash' | 'path'; type: 'match'; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts index 6c29a2244c203..977683ab55495 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts @@ -8,9 +8,10 @@ import { RequestHandler } from 'kibana/server'; import { GetTrustedAppsListRequest, GetTrustedListAppsResponse, + PostTrustedAppCreateRequest, } from '../../../../common/endpoint/types'; import { EndpointAppContext } from '../../types'; -import { exceptionItemToTrustedAppItem } from './utils'; +import { exceptionItemToTrustedAppItem, newTrustedAppItemToExceptionItem } from './utils'; import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; export const getTrustedAppsListRouteHandler = ( @@ -24,7 +25,7 @@ export const getTrustedAppsListRouteHandler = ( try { // Ensure list is created if it does not exist - await exceptionsListService?.createTrustedAppsList(); + await exceptionsListService.createTrustedAppsList(); const results = await exceptionsListService.findExceptionListItem({ listId: ENDPOINT_TRUSTED_APPS_LIST_ID, page, @@ -47,3 +48,32 @@ export const getTrustedAppsListRouteHandler = ( } }; }; + +export const getTrustedAppsCreateRouteHandler = ( + endpointAppContext: EndpointAppContext +): RequestHandler => { + const logger = endpointAppContext.logFactory.get('trusted_apps'); + + return async (constext, req, res) => { + const exceptionsListService = endpointAppContext.service.getExceptionsList(); + const newTrustedApp = req.body; + + try { + // Ensure list is created if it does not exist + await exceptionsListService.createTrustedAppsList(); + + const createdTrustedAppExceptionItem = await exceptionsListService.createExceptionListItem( + newTrustedAppItemToExceptionItem(newTrustedApp) + ); + + return res.ok({ + body: { + data: exceptionItemToTrustedAppItem(createdTrustedAppExceptionItem), + }, + }); + } catch (error) { + logger.error(error); + return res.internalError({ body: error }); + } + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts index 178aa06eee877..1302b10533ccf 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts @@ -5,9 +5,15 @@ */ import { IRouter } from 'kibana/server'; -import { GetTrustedAppsRequestSchema } from '../../../../common/endpoint/schema/trusted_apps'; -import { TRUSTED_APPS_LIST_API } from '../../../../common/endpoint/constants'; -import { getTrustedAppsListRouteHandler } from './handlers'; +import { + GetTrustedAppsRequestSchema, + PostTrustedAppCreateRequestSchema, +} from '../../../../common/endpoint/schema/trusted_apps'; +import { + TRUSTED_APPS_CREATE_API, + TRUSTED_APPS_LIST_API, +} from '../../../../common/endpoint/constants'; +import { getTrustedAppsCreateRouteHandler, getTrustedAppsListRouteHandler } from './handlers'; import { EndpointAppContext } from '../../types'; export const registerTrustedAppsRoutes = ( @@ -23,4 +29,14 @@ export const registerTrustedAppsRoutes = ( }, getTrustedAppsListRouteHandler(endpointAppContext) ); + + // CREATE + router.post( + { + path: TRUSTED_APPS_CREATE_API, + validate: PostTrustedAppCreateRequestSchema, + options: { authRequired: true }, + }, + getTrustedAppsCreateRouteHandler(endpointAppContext) + ); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts index 1d4a7919b89f5..488c8390411b0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts @@ -12,12 +12,20 @@ import { import { IRouter, RequestHandler } from 'kibana/server'; import { httpServerMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; import { registerTrustedAppsRoutes } from './index'; -import { TRUSTED_APPS_LIST_API } from '../../../../common/endpoint/constants'; -import { GetTrustedAppsListRequest } from '../../../../common/endpoint/types'; +import { + TRUSTED_APPS_CREATE_API, + TRUSTED_APPS_LIST_API, +} from '../../../../common/endpoint/constants'; +import { + GetTrustedAppsListRequest, + PostTrustedAppCreateRequest, +} from '../../../../common/endpoint/types'; import { xpackMocks } from '../../../../../../mocks'; import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; import { EndpointAppContext } from '../../types'; import { ExceptionListClient } from '../../../../../lists/server'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { ExceptionListItemSchema } from '../../../../../lists/common/schemas/response'; describe('when invoking endpoint trusted apps route handlers', () => { let routerMock: jest.Mocked; @@ -105,4 +113,111 @@ describe('when invoking endpoint trusted apps route handlers', () => { expect(endpointAppContext.logFactory.get('trusted_apps').error).toHaveBeenCalled(); }); }); + + describe('when creating a trusted app', () => { + let routeHandler: RequestHandler; + const createNewTrustedAppBody = (): PostTrustedAppCreateRequest => ({ + name: 'Some Anti-Virus App', + description: 'this one is ok', + os: 'windows', + entries: [ + { + field: 'path', + type: 'match', + operator: 'included', + value: 'c:/programs files/Anti-Virus', + }, + ], + }); + const createPostRequest = () => { + return httpServerMock.createKibanaRequest({ + path: TRUSTED_APPS_LIST_API, + method: 'post', + body: createNewTrustedAppBody(), + }); + }; + + beforeEach(() => { + // Get the registered POST handler from the IRouter instance + [, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(TRUSTED_APPS_CREATE_API) + )!; + + // Mock the impelementation of `createExceptionListItem()` so that the return value + // merges in the provided input + exceptionsListClient.createExceptionListItem.mockImplementation(async (newExceptionItem) => { + return ({ + ...getExceptionListItemSchemaMock(), + ...newExceptionItem, + } as unknown) as ExceptionListItemSchema; + }); + }); + + it('should create trusted app list first', async () => { + const request = createPostRequest(); + await routeHandler(context, request, response); + expect(exceptionsListClient.createTrustedAppsList).toHaveBeenCalled(); + expect(response.ok).toHaveBeenCalled(); + }); + + it('should map new trusted app item to an exception list item', async () => { + const request = createPostRequest(); + await routeHandler(context, request, response); + expect(exceptionsListClient.createExceptionListItem.mock.calls[0][0]).toEqual({ + _tags: ['os:windows'], + comments: [], + description: 'this one is ok', + entries: [ + { + field: 'path', + operator: 'included', + type: 'match', + value: 'c:/programs files/Anti-Virus', + }, + ], + itemId: expect.stringMatching(/.*/), + listId: 'endpoint_trusted_apps', + meta: undefined, + name: 'Some Anti-Virus App', + namespaceType: 'agnostic', + tags: [], + type: 'simple', + }); + }); + + it('should return new trusted app item', async () => { + const request = createPostRequest(); + await routeHandler(context, request, response); + expect(response.ok.mock.calls[0][0]).toEqual({ + body: { + data: { + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'some user', + description: 'this one is ok', + entries: [ + { + field: 'path', + operator: 'included', + type: 'match', + value: 'c:/programs files/Anti-Virus', + }, + ], + id: '1', + name: 'Some Anti-Virus App', + os: 'windows', + }, + }, + }); + }); + + it('should log unexpected error if one occurs', async () => { + exceptionsListClient.createExceptionListItem.mockImplementation(() => { + throw new Error('expected error for create'); + }); + const request = createPostRequest(); + await routeHandler(context, request, response); + expect(response.internalError).toHaveBeenCalled(); + expect(endpointAppContext.logFactory.get('trusted_apps').error).toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts index 2b417a4c6a8e1..794c1db4b49aa 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts @@ -4,8 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import uuid from 'uuid'; import { ExceptionListItemSchema } from '../../../../../lists/common/shared_exports'; -import { TrustedApp } from '../../../../common/endpoint/types'; +import { NewTrustedApp, TrustedApp } from '../../../../common/endpoint/types'; +import { ExceptionListClient } from '../../../../../lists/server'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; + +type NewExecptionItem = Parameters[0]; /** * Map an ExcptionListItem to a TrustedApp item @@ -40,3 +45,28 @@ const osFromTagsList = (tags: string[]): TrustedApp['os'] | 'unknown' => { } return 'unknown'; }; + +export const newTrustedAppItemToExceptionItem = ({ + os, + entries, + name, + description = '', +}: NewTrustedApp): NewExecptionItem => { + return { + _tags: tagsListFromOs(os), + comments: [], + description, + entries, + itemId: uuid.v4(), + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + meta: undefined, + name, + namespaceType: 'agnostic', + tags: [], + type: 'simple', + }; +}; + +const tagsListFromOs = (os: NewTrustedApp['os']): NewExecptionItem['_tags'] => { + return [`os:${os}`]; +}; From 4705755b3b5bf2a1d303406091be1e572d6c751b Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Tue, 1 Sep 2020 15:51:55 -0400 Subject: [PATCH 08/53] Delete unused file. (#76386) --- x-pack/plugins/uptime/public/breadcrumbs.ts | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 x-pack/plugins/uptime/public/breadcrumbs.ts diff --git a/x-pack/plugins/uptime/public/breadcrumbs.ts b/x-pack/plugins/uptime/public/breadcrumbs.ts deleted file mode 100644 index 41bc2aa258807..0000000000000 --- a/x-pack/plugins/uptime/public/breadcrumbs.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ From 2ed920021e83e9809b3697960a7be94d7c9918a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 1 Sep 2020 21:55:14 +0200 Subject: [PATCH 09/53] Create APM issue template (#76362) --- .github/ISSUE_TEMPLATE/APM.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/APM.md diff --git a/.github/ISSUE_TEMPLATE/APM.md b/.github/ISSUE_TEMPLATE/APM.md new file mode 100644 index 0000000000000..983806f70bc3f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/APM.md @@ -0,0 +1,11 @@ +--- +name: APM Issue +about: Issues related to the APM solution in Kibana +labels: Team:apm +title: [APM] +--- + +**Versions** +Kibana: (if relevant) +APM Server: (if relevant) +Elasticsearch: (if relevant) From f6aa79853556e863e9323c26fc57fa2ef06da380 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Tue, 1 Sep 2020 15:50:29 -0500 Subject: [PATCH 10/53] remove dupe tinymath section (#76093) --- .../canvas/canvas-tinymath-functions.asciidoc | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/docs/canvas/canvas-tinymath-functions.asciidoc b/docs/canvas/canvas-tinymath-functions.asciidoc index 73808fc6625d1..f92f7c642a2ee 100644 --- a/docs/canvas/canvas-tinymath-functions.asciidoc +++ b/docs/canvas/canvas-tinymath-functions.asciidoc @@ -492,37 +492,6 @@ find the mean by index. |one or more numbers or arrays of numbers |=== -*Returns*: `number` | `Array.`. The maximum value of all numbers if -`args` contains only numbers. Returns an array with the the maximum values at each -index, including all scalar numbers in `args` in the calculation at each index if -`args` contains at least one array. - -*Throws*: `'Array length mismatch'` if `args` contains arrays of different lengths - -*Example* -[source, js] ------------- -max(1, 2, 3) // returns 3 -max([10, 20, 30, 40], 15) // returns [15, 20, 30, 40] -max([1, 9], 4, [3, 5]) // returns [max([1, 4, 3]), max([9, 4, 5])] = [4, 9] ------------- - -[float] -=== mean( ...args ) - -Finds the mean value of one of more numbers/arrays of numbers passed into the function. -If at least one array of numbers is passed into the function, the function will -find the mean by index. - -[cols="3*^<"] -|=== -|Param |Type |Description - -|...args -|number \| Array. -|one or more numbers or arrays of numbers -|=== - *Returns*: `number` | `Array.`. The mean value of all numbers if `args` contains only numbers. Returns an array with the the mean values of each index, including all scalar numbers in `args` in the calculation at each index if `args` From b5faf41b04201fc3081efea15480df7bbc1a1789 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Tue, 1 Sep 2020 13:59:11 -0700 Subject: [PATCH 11/53] Manually building `KueryNode` for Fleet's routes (#75693) * Simple benchmark tests for kuery * Building manually is "better" still not free * Building the KueryNode manually * Removing benchmark tests * Another query is building the KueryNode manually * Empty strings are inherently falsy * No longer reaching into the data plugin, import from the "root" indexes * Using AGENT_ACTION_SAVED_OBJECT_TYPE everywhere * Adding SavedObjectsRepository#find unit test for KueryNode * Adding KQL KueryNode test for validateConvertFilterToKueryNode * /s/KQL string/KQL expression * Updating API docs * Adding micro benchmark * Revert "Adding micro benchmark" This reverts commit 97e19c0bf37b03f3740fe7c00e0613fbbfe4600a. * Adding an empty string filters test Co-authored-by: Elastic Machine --- ...e-public.savedobjectsfindoptions.filter.md | 2 +- ...gin-core-public.savedobjectsfindoptions.md | 2 +- ...e-server.savedobjectsfindoptions.filter.md | 2 +- ...gin-core-server.savedobjectsfindoptions.md | 2 +- src/core/public/public.api.md | 4 +- .../service/lib/filter_utils.test.ts | 14 +++++- .../saved_objects/service/lib/filter_utils.ts | 7 +-- .../service/lib/repository.test.js | 45 ++++++++++++++++++- src/core/server/saved_objects/types.ts | 5 ++- src/core/server/server.api.md | 4 +- .../server/services/agents/actions.ts | 37 ++++++++++++++- 11 files changed, 110 insertions(+), 14 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.filter.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.filter.md index 900f8e333f337..2c20fe2dab00f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.filter.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.filter.md @@ -7,5 +7,5 @@ Signature: ```typescript -filter?: string; +filter?: string | KueryNode; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index ebd0a99531755..903462ac3039d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -17,7 +17,7 @@ export interface SavedObjectsFindOptions | --- | --- | --- | | [defaultSearchOperator](./kibana-plugin-core-public.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | | | [fields](./kibana-plugin-core-public.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | -| [filter](./kibana-plugin-core-public.savedobjectsfindoptions.filter.md) | string | | +| [filter](./kibana-plugin-core-public.savedobjectsfindoptions.filter.md) | string | KueryNode | | | [hasReference](./kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | | [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.filter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.filter.md index ae7b7a28bcd09..c98a4fe5e8796 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.filter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.filter.md @@ -7,5 +7,5 @@ Signature: ```typescript -filter?: string; +filter?: string | KueryNode; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index 15a9d99b3d062..804c83f7c1b48 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -17,7 +17,7 @@ export interface SavedObjectsFindOptions | --- | --- | --- | | [defaultSearchOperator](./kibana-plugin-core-server.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | | | [fields](./kibana-plugin-core-server.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | -| [filter](./kibana-plugin-core-server.savedobjectsfindoptions.filter.md) | string | | +| [filter](./kibana-plugin-core-server.savedobjectsfindoptions.filter.md) | string | KueryNode | | | [hasReference](./kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | | [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number | | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 570732fa6e5d6..bacbd6e757114 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1189,8 +1189,10 @@ export interface SavedObjectsFindOptions { // (undocumented) defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; + // Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts + // // (undocumented) - filter?: string; + filter?: string | KueryNode; // (undocumented) hasReference?: { type: string; diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts index 4d9bcdda3c8ae..60e8aa0afdda4 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -83,7 +83,19 @@ const mockMappings = { describe('Filter Utils', () => { describe('#validateConvertFilterToKueryNode', () => { - test('Validate a simple filter', () => { + test('Empty string filters are ignored', () => { + expect(validateConvertFilterToKueryNode(['foo'], '', mockMappings)).toBeUndefined(); + }); + test('Validate a simple KQL KueryNode filter', () => { + expect( + validateConvertFilterToKueryNode( + ['foo'], + esKuery.nodeTypes.function.buildNode('is', `foo.attributes.title`, 'best', true), + mockMappings + ) + ).toEqual(esKuery.fromKueryExpression('foo.title: "best"')); + }); + test('Validate a simple KQL expression filter', () => { expect( validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockMappings) ).toEqual(esKuery.fromKueryExpression('foo.title: "best"')); diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts index 5fbe62a074b29..d19f06d74e419 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -28,11 +28,12 @@ const astFunctionType = ['is', 'range', 'nested']; export const validateConvertFilterToKueryNode = ( allowedTypes: string[], - filter: string, + filter: string | KueryNode, indexMapping: IndexMapping ): KueryNode | undefined => { - if (filter && filter.length > 0 && indexMapping) { - const filterKueryNode = esKuery.fromKueryExpression(filter); + if (filter && indexMapping) { + const filterKueryNode = + typeof filter === 'string' ? esKuery.fromKueryExpression(filter) : filter; const validationFilterKuery = validateFilterKueryNode({ astFilter: filterKueryNode, diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 39433981dfd59..b1d6028465713 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -25,6 +25,8 @@ import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { DocumentMigrator } from '../../migrations/core/document_migrator'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { nodeTypes } from '../../../../../plugins/data/common/es_query'; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); @@ -2529,7 +2531,7 @@ describe('SavedObjectsRepository', () => { expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, relevantOpts); }); - it(`accepts KQL filter and passes kueryNode to getSearchDsl`, async () => { + it(`accepts KQL expression filter and passes KueryNode to getSearchDsl`, async () => { const findOpts = { namespace, search: 'foo*', @@ -2570,6 +2572,47 @@ describe('SavedObjectsRepository', () => { `); }); + it(`accepts KQL KueryNode filter and passes KueryNode to getSearchDsl`, async () => { + const findOpts = { + namespace, + search: 'foo*', + searchFields: ['foo'], + type: ['dashboard'], + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + indexPattern: undefined, + filter: nodeTypes.function.buildNode('is', `dashboard.attributes.otherField`, '*'), + }; + + await findSuccess(findOpts, namespace); + const { kueryNode } = getSearchDslNS.getSearchDsl.mock.calls[0][2]; + expect(kueryNode).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "dashboard.otherField", + }, + Object { + "type": "wildcard", + "value": "@kuery-wildcard@", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + } + `); + }); + it(`supports multiple types`, async () => { const types = ['config', 'index-pattern']; await findSuccess({ type: types }); diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index edbdbe4d16784..000153cd542fa 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -39,6 +39,9 @@ import { SavedObjectUnsanitizedDoc } from './serialization'; import { SavedObjectsMigrationLogger } from './migrations/core/migration_logger'; import { SavedObject } from '../../types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KueryNode } from '../../../plugins/data/common'; + export { SavedObjectAttributes, SavedObjectAttribute, @@ -89,7 +92,7 @@ export interface SavedObjectsFindOptions { rootSearchFields?: string[]; hasReference?: { type: string; id: string }; defaultSearchOperator?: 'AND' | 'OR'; - filter?: string; + filter?: string | KueryNode; namespaces?: string[]; /** An optional ES preference value to be used for the query **/ preference?: string; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index fb4e4494801ed..05afad5a4f7a4 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2320,8 +2320,10 @@ export interface SavedObjectsFindOptions { // (undocumented) defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; + // Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts + // // (undocumented) - filter?: string; + filter?: string | KueryNode; // (undocumented) hasReference?: { type: string; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts index 8d1b320c89ae6..cd0dd92131230 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/actions.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts @@ -9,6 +9,7 @@ import { Agent, AgentAction, AgentActionSOAttributes } from '../../../common/typ import { AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../../common/constants'; import { savedObjectToAgentAction } from './saved_objects'; import { appContextService } from '../app_context'; +import { nodeTypes } from '../../../../../../src/plugins/data/common'; export async function createAgentAction( soClient: SavedObjectsClientContract, @@ -29,9 +30,24 @@ export async function getAgentActionsForCheckin( soClient: SavedObjectsClientContract, agentId: string ): Promise { + const filter = nodeTypes.function.buildNode('and', [ + nodeTypes.function.buildNode( + 'not', + nodeTypes.function.buildNode( + 'is', + `${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.sent_at`, + '*' + ) + ), + nodeTypes.function.buildNode( + 'is', + `${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.agent_id`, + agentId + ), + ]); const res = await soClient.find({ type: AGENT_ACTION_SAVED_OBJECT_TYPE, - filter: `not ${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.sent_at: * and ${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.agent_id:${agentId}`, + filter, }); return Promise.all( @@ -78,9 +94,26 @@ export async function getAgentActionByIds( } export async function getNewActionsSince(soClient: SavedObjectsClientContract, timestamp: string) { + const filter = nodeTypes.function.buildNode('and', [ + nodeTypes.function.buildNode( + 'not', + nodeTypes.function.buildNode( + 'is', + `${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.sent_at`, + '*' + ) + ), + nodeTypes.function.buildNode( + 'range', + `${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.created_at`, + { + gte: timestamp, + } + ), + ]); const res = await soClient.find({ type: AGENT_ACTION_SAVED_OBJECT_TYPE, - filter: `not ${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.sent_at: * AND ${AGENT_ACTION_SAVED_OBJECT_TYPE}.attributes.created_at >= "${timestamp}"`, + filter, }); return res.saved_objects.map(savedObjectToAgentAction); From 030d5e1390c87f3e35e47ae6a597e5d68cedc26e Mon Sep 17 00:00:00 2001 From: John Schulz Date: Tue, 1 Sep 2020 17:28:23 -0400 Subject: [PATCH 12/53] [Ingest Manager] Improve agent vs kibana version checks (#76238) * Add logic spec'd in issue comments. Tests pass. * Change fn to accept 1 (opt 2) string vs object * Add tests based on issue comments * Change expected error message in test * Capitalize Kibana in error message Co-authored-by: Elastic Machine --- .../server/services/agents/enroll.test.ts | 70 ++++++++----------- .../server/services/agents/enroll.ts | 61 +++++++++++----- .../apis/fleet/agents/enroll.ts | 2 +- 3 files changed, 75 insertions(+), 58 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/services/agents/enroll.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/enroll.test.ts index 764564cfa49f5..44473bf2d8d79 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/enroll.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/enroll.test.ts @@ -5,62 +5,52 @@ */ import { validateAgentVersion } from './enroll'; -import { appContextService } from '../app_context'; -import { IngestManagerAppContext } from '../../plugin'; describe('validateAgentVersion', () => { it('should throw with agent > kibana version', () => { - appContextService.start(({ - kibanaVersion: '8.0.0', - } as unknown) as IngestManagerAppContext); - expect(() => - validateAgentVersion({ - local: { elastic: { agent: { version: '8.8.0' } } }, - userProvided: {}, - }) - ).toThrowError(/Agent version is not compatible with kibana version/); + expect(() => validateAgentVersion('8.8.0', '8.0.0')).toThrowError('not compatible'); }); it('should work with agent < kibana version', () => { - appContextService.start(({ - kibanaVersion: '8.0.0', - } as unknown) as IngestManagerAppContext); - validateAgentVersion({ local: { elastic: { agent: { version: '7.8.0' } } }, userProvided: {} }); + validateAgentVersion('7.8.0', '8.0.0'); }); it('should work with agent = kibana version', () => { - appContextService.start(({ - kibanaVersion: '8.0.0', - } as unknown) as IngestManagerAppContext); - validateAgentVersion({ local: { elastic: { agent: { version: '8.0.0' } } }, userProvided: {} }); + validateAgentVersion('8.0.0', '8.0.0'); }); it('should work with SNAPSHOT version', () => { - appContextService.start(({ - kibanaVersion: '8.0.0-SNAPSHOT', - } as unknown) as IngestManagerAppContext); - validateAgentVersion({ - local: { elastic: { agent: { version: '8.0.0-SNAPSHOT' } } }, - userProvided: {}, - }); + validateAgentVersion('8.0.0-SNAPSHOT', '8.0.0-SNAPSHOT'); }); it('should work with a agent using SNAPSHOT version', () => { - appContextService.start(({ - kibanaVersion: '7.8.0', - } as unknown) as IngestManagerAppContext); - validateAgentVersion({ - local: { elastic: { agent: { version: '7.8.0-SNAPSHOT' } } }, - userProvided: {}, - }); + validateAgentVersion('7.8.0-SNAPSHOT', '7.8.0'); }); it('should work with a kibana using SNAPSHOT version', () => { - appContextService.start(({ - kibanaVersion: '7.8.0-SNAPSHOT', - } as unknown) as IngestManagerAppContext); - validateAgentVersion({ - local: { elastic: { agent: { version: '7.8.0' } } }, - userProvided: {}, - }); + validateAgentVersion('7.8.0', '7.8.0-SNAPSHOT'); + }); + + it('very close versions, e.g. patch/prerelease - all combos should work', () => { + validateAgentVersion('7.9.1', '7.9.2'); + validateAgentVersion('7.8.1', '7.8.2'); + validateAgentVersion('7.6.99', '7.6.2'); + validateAgentVersion('7.6.2', '7.6.99'); + validateAgentVersion('5.94.3', '5.94.1234-SNAPSHOT'); + validateAgentVersion('5.94.3-SNAPSHOT', '5.94.1'); + }); + + it('somewhat close versions, minor release is 1 or 2 versions back and is older than the stack', () => { + validateAgentVersion('7.9.1', '7.10.2'); + validateAgentVersion('7.9.9', '7.11.1'); + validateAgentVersion('7.6.99', '7.6.2'); + validateAgentVersion('7.6.2', '7.6.99'); + expect(() => validateAgentVersion('5.94.3-SNAPSHOT', '5.93.1')).toThrowError('not compatible'); + expect(() => validateAgentVersion('5.94.3', '5.92.99-SNAPSHOT')).toThrowError('not compatible'); + }); + + it('versions where Agent is a minor version or major version greater (newer) than the stack should not work', () => { + expect(() => validateAgentVersion('7.10.1', '7.9.99')).toThrowError('not compatible'); + expect(() => validateAgentVersion('7.9.9', '6.11.1')).toThrowError('not compatible'); + expect(() => validateAgentVersion('5.94.3', '5.92.99-SNAPSHOT')).toThrowError('not compatible'); }); }); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts b/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts index 606d5c4dcbb90..3c5850c722e97 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts @@ -20,7 +20,8 @@ export async function enroll( metadata?: { local: any; userProvided: any }, sharedId?: string ): Promise { - validateAgentVersion(metadata); + const agentVersion = metadata?.local?.elastic?.agent?.version; + validateAgentVersion(agentVersion); const existingAgent = sharedId ? await getAgentBySharedId(soClient, sharedId) : null; @@ -89,24 +90,50 @@ async function getAgentBySharedId(soClient: SavedObjectsClientContract, sharedId return null; } -export function validateAgentVersion(metadata?: { local: any; userProvided: any }) { - const kibanaVersion = semver.parse(appContextService.getKibanaVersion()); - if (!kibanaVersion) { - throw Boom.badRequest('Kibana version is not set'); - } - const version = semver.parse(metadata?.local?.elastic?.agent?.version); - if (!version) { - throw Boom.badRequest('Agent version not provided in metadata.'); +export function validateAgentVersion( + agentVersion: string, + kibanaVersion = appContextService.getKibanaVersion() +) { + const agentVersionParsed = semver.parse(agentVersion); + if (!agentVersionParsed) { + throw Boom.badRequest('Agent version not provided'); } - if (!version || !semver.lte(formatVersion(version), formatVersion(kibanaVersion))) { - throw Boom.badRequest('Agent version is not compatible with kibana version'); + const kibanaVersionParsed = semver.parse(kibanaVersion); + if (!kibanaVersionParsed) { + throw Boom.badRequest('Kibana version is not set or provided'); } -} -/** - * used to remove prelease from version as includePrerelease in not working as expected - */ -function formatVersion(version: semver.SemVer) { - return `${version.major}.${version.minor}.${version.patch}`; + const diff = semver.diff(agentVersion, kibanaVersion); + switch (diff) { + // section 1) very close versions, only patch release differences - all combos should work + // Agent a.b.1 < Kibana a.b.2 + // Agent a.b.2 > Kibana a.b.1 + case null: + case 'prerelease': + case 'prepatch': + case 'patch': + return; // OK + + // section 2) somewhat close versions, Agent minor release is 1 or 2 versions back and is older than the stack: + // Agent a.9.x < Kibana a.10.x + // Agent a.9.x < Kibana a.11.x + case 'preminor': + case 'minor': + if ( + agentVersionParsed.minor < kibanaVersionParsed.minor && + kibanaVersionParsed.minor - agentVersionParsed.minor <= 2 + ) + return; + + // section 3) versions where Agent is a minor version or major version greater (newer) than the stack should not work: + // Agent 7.10.x > Kibana 7.9.x + // Agent 8.0.x > Kibana 7.9.x + default: + if (semver.lte(agentVersionParsed, kibanaVersionParsed)) return; + else + throw Boom.badRequest( + `Agent version ${agentVersion} is not compatible with Kibana version ${kibanaVersion}` + ); + } } diff --git a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts index ce356dbd081c8..93f656ea952d2 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts @@ -115,7 +115,7 @@ export default function (providerContext: FtrProviderContext) { }, }) .expect(400); - expect(apiResponse.message).to.match(/Agent version is not compatible with kibana/); + expect(apiResponse.message).to.match(/is not compatible/); }); it('should allow to enroll an agent with a valid enrollment token', async () => { From 9a7c418327f32f69b9dc708d0ae48861a2fb4d64 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Tue, 1 Sep 2020 17:29:19 -0400 Subject: [PATCH 13/53] [Ingest Manager] Support both zip & tar archives from Registry (#76197) * Quick pass at restoring support for both zip & tar Restored unzip functions from https://github.com/elastic/kibana/pull/43764 Persist the `download` value returned by EPR (e.g. `/epr/system/system-0.5.3.zip` or `/epr/system/system-0.5.3.tar.gz`) as "archive key" for a package name/version combo. The same name&version should return the same archive. The value initially given by the registry. Based on that value, we decide which decompression to use. * Use template literal vs JSON.stringify for keygen * Factor unzip/untar logic out to getBufferExtractor * Add tests for getBufferExtractor * Replace `[aA]rchiveKey*` with `[aA]rchiveLocation*` * Include given name & version in error message Co-authored-by: Elastic Machine --- package.json | 1 + .../server/services/epm/registry/cache.ts | 9 +++- .../server/services/epm/registry/extract.ts | 38 ++++++++++++++++ .../services/epm/registry/index.test.ts | 30 ++++++++++++- .../server/services/epm/registry/index.ts | 45 ++++++++++--------- 5 files changed, 99 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 3485ce5d7a7fc..28f2025300f39 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", + "@types/yauzl": "^2.9.1", "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", "accept": "3.0.2", diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts index af11bc7f6c831..e9c8317a6251d 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { pkgToPkgKey } from './index'; const cache: Map = new Map(); export const cacheGet = (key: string) => cache.get(key); @@ -10,4 +11,10 @@ export const cacheSet = (key: string, value: Buffer) => cache.set(key, value); export const cacheHas = (key: string) => cache.has(key); export const cacheClear = () => cache.clear(); export const cacheDelete = (key: string) => cache.delete(key); -export const getCacheKey = (key: string) => key + '.tar.gz'; + +const archiveLocationCache: Map = new Map(); +export const getArchiveLocation = (name: string, version: string) => + archiveLocationCache.get(pkgToPkgKey({ name, version })); + +export const setArchiveLocation = (name: string, version: string, location: string) => + archiveLocationCache.set(pkgToPkgKey({ name, version }), location); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts index 1f708c5edbcc7..6d029b54a6317 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts @@ -5,6 +5,7 @@ */ import tar from 'tar'; +import yauzl from 'yauzl'; import { bufferToStream, streamToBuffer } from './streams'; export interface ArchiveEntry { @@ -30,3 +31,40 @@ export async function untarBuffer( deflatedStream.pipe(inflateStream); }); } + +export async function unzipBuffer( + buffer: Buffer, + filter = (entry: ArchiveEntry): boolean => true, + onEntry = (entry: ArchiveEntry): void => {} +): Promise { + const zipfile = await yauzlFromBuffer(buffer, { lazyEntries: true }); + zipfile.readEntry(); + zipfile.on('entry', async (entry: yauzl.Entry) => { + const path = entry.fileName; + if (!filter({ path })) return zipfile.readEntry(); + + const entryBuffer = await getZipReadStream(zipfile, entry).then(streamToBuffer); + onEntry({ buffer: entryBuffer, path }); + zipfile.readEntry(); + }); + return new Promise((resolve, reject) => zipfile.on('end', resolve).on('error', reject)); +} + +function yauzlFromBuffer(buffer: Buffer, opts: yauzl.Options): Promise { + return new Promise((resolve, reject) => + yauzl.fromBuffer(buffer, opts, (err?: Error, handle?: yauzl.ZipFile) => + err ? reject(err) : resolve(handle) + ) + ); +} + +function getZipReadStream( + zipfile: yauzl.ZipFile, + entry: yauzl.Entry +): Promise { + return new Promise((resolve, reject) => + zipfile.openReadStream(entry, (err?: Error, readStream?: NodeJS.ReadableStream) => + err ? reject(err) : resolve(readStream) + ) + ); +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts index 085dc990fa376..b40638eefbae2 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts @@ -5,7 +5,17 @@ */ import { AssetParts } from '../../../types'; -import { pathParts, splitPkgKey } from './index'; +import { getBufferExtractor, pathParts, splitPkgKey } from './index'; +import { getArchiveLocation } from './cache'; +import { untarBuffer, unzipBuffer } from './extract'; + +jest.mock('./cache', () => { + return { + getArchiveLocation: jest.fn(), + }; +}); + +const mockedGetArchiveLocation = getArchiveLocation as jest.Mock; const testPaths = [ { @@ -80,3 +90,21 @@ describe('splitPkgKey tests', () => { expect(pkgVersion).toBe('0.13.0-alpha.1+abcd'); }); }); + +describe('getBufferExtractor', () => { + it('throws if the archive has not been downloaded/cached yet', () => { + expect(() => getBufferExtractor('missing', '1.2.3')).toThrow('no archive location'); + }); + + it('returns unzipBuffer if the archive key ends in .zip', () => { + mockedGetArchiveLocation.mockImplementation(() => '.zip'); + const extractor = getBufferExtractor('will-use-mocked-key', 'a.b.c'); + expect(extractor).toBe(unzipBuffer); + }); + + it('returns untarBuffer if the key ends in anything else', () => { + mockedGetArchiveLocation.mockImplementation(() => 'xyz'); + const extractor = getBufferExtractor('will-use-mocked-key', 'a.b.c'); + expect(extractor).toBe(untarBuffer); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts index b635378960468..61c8cd4aabb7b 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts @@ -17,8 +17,8 @@ import { RegistrySearchResults, RegistrySearchResult, } from '../../../types'; -import { cacheGet, cacheSet, getCacheKey, cacheHas } from './cache'; -import { ArchiveEntry, untarBuffer } from './extract'; +import { cacheGet, cacheSet, cacheHas, getArchiveLocation, setArchiveLocation } from './cache'; +import { ArchiveEntry, untarBuffer, unzipBuffer } from './extract'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { streamToBuffer } from './streams'; import { getRegistryUrl } from './registry_url'; @@ -130,7 +130,9 @@ export async function getArchiveInfo( filter = (entry: ArchiveEntry): boolean => true ): Promise { const paths: string[] = []; - const onEntry = (entry: ArchiveEntry) => { + const archiveBuffer = await getOrFetchArchiveBuffer(pkgName, pkgVersion); + const bufferExtractor = getBufferExtractor(pkgName, pkgVersion); + await bufferExtractor(archiveBuffer, filter, (entry: ArchiveEntry) => { const { path, buffer } = entry; const { file } = pathParts(path); if (!file) return; @@ -138,9 +140,7 @@ export async function getArchiveInfo( cacheSet(path, buffer); paths.push(path); } - }; - - await extract(pkgName, pkgVersion, filter, onEntry); + }); return paths; } @@ -175,24 +175,20 @@ export function pathParts(path: string): AssetParts { } as AssetParts; } -async function extract( - pkgName: string, - pkgVersion: string, - filter = (entry: ArchiveEntry): boolean => true, - onEntry: (entry: ArchiveEntry) => void -) { - const archiveBuffer = await getOrFetchArchiveBuffer(pkgName, pkgVersion); +export function getBufferExtractor(pkgName: string, pkgVersion: string) { + const archiveLocation = getArchiveLocation(pkgName, pkgVersion); + if (!archiveLocation) throw new Error(`no archive location for ${pkgName} ${pkgVersion}`); + const isZip = archiveLocation.endsWith('.zip'); + const bufferExtractor = isZip ? unzipBuffer : untarBuffer; - return untarBuffer(archiveBuffer, filter, onEntry); + return bufferExtractor; } async function getOrFetchArchiveBuffer(pkgName: string, pkgVersion: string): Promise { - // assume .tar.gz for now. add support for .zip if/when we need it - const key = getCacheKey(`${pkgName}-${pkgVersion}`); - let buffer = cacheGet(key); + const key = getArchiveLocation(pkgName, pkgVersion); + let buffer = key && cacheGet(key); if (!buffer) { buffer = await fetchArchiveBuffer(pkgName, pkgVersion); - cacheSet(key, buffer); } if (buffer) { @@ -203,16 +199,21 @@ async function getOrFetchArchiveBuffer(pkgName: string, pkgVersion: string): Pro } export async function ensureCachedArchiveInfo(name: string, version: string) { - const pkgkey = getCacheKey(`${name}-${version}`); - if (!cacheHas(pkgkey)) { + const pkgkey = getArchiveLocation(name, version); + if (!pkgkey || !cacheHas(pkgkey)) { await getArchiveInfo(name, version); } } async function fetchArchiveBuffer(pkgName: string, pkgVersion: string): Promise { const { download: archivePath } = await fetchInfo(pkgName, pkgVersion); - const registryUrl = getRegistryUrl(); - return getResponseStream(`${registryUrl}${archivePath}`).then(streamToBuffer); + const archiveUrl = `${getRegistryUrl()}${archivePath}`; + const buffer = await getResponseStream(archiveUrl).then(streamToBuffer); + + setArchiveLocation(pkgName, pkgVersion, archivePath); + cacheSet(archivePath, buffer); + + return buffer; } export function getAsset(key: string) { From ac8e9367f7e354dcce1ad7e72d7877e30c01bf85 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 1 Sep 2020 17:39:04 -0400 Subject: [PATCH 14/53] [Resolver] generator uses setup_node_env (#76422) --- x-pack/plugins/security_solution/package.json | 2 +- .../plugins/security_solution/scripts/endpoint/README.md | 4 ---- .../security_solution/scripts/endpoint/cli_tsconfig.json | 7 ------- .../scripts/endpoint/resolver_generator.js | 8 ++++++++ ...resolver_generator.ts => resolver_generator_script.ts} | 0 5 files changed, 9 insertions(+), 12 deletions(-) delete mode 100644 x-pack/plugins/security_solution/scripts/endpoint/cli_tsconfig.json create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.js rename x-pack/plugins/security_solution/scripts/endpoint/{resolver_generator.ts => resolver_generator_script.ts} (100%) diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 687099541b3d2..4d2602d1498ee 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -10,7 +10,7 @@ "cypress:open": "cypress open --config-file ./cypress/cypress.json", "cypress:run": "cypress run --browser chrome --headless --spec ./cypress/integration/**/*.spec.ts --config-file ./cypress/cypress.json --reporter ../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json; status=$?; ../../node_modules/.bin/mochawesome-merge --reportDir ../../../target/kibana-security-solution/cypress/results > ../../../target/kibana-security-solution/cypress/results/output.json; ../../../node_modules/.bin/marge ../../../target/kibana-security-solution/cypress/results/output.json --reportDir ../../../target/kibana-security-solution/cypress/results; mkdir -p ../../../target/junit && cp ../../../target/kibana-security-solution/cypress/results/*.xml ../../../target/junit/ && exit $status;", "cypress:run-as-ci": "node ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.ts", - "test:generate": "ts-node --project scripts/endpoint/cli_tsconfig.json scripts/endpoint/resolver_generator.ts" + "test:generate": "node scripts/endpoint/resolver_generator" }, "devDependencies": { "@types/md5": "^2.2.0", diff --git a/x-pack/plugins/security_solution/scripts/endpoint/README.md b/x-pack/plugins/security_solution/scripts/endpoint/README.md index bd9502f2f59e0..2827ab065504b 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/README.md +++ b/x-pack/plugins/security_solution/scripts/endpoint/README.md @@ -5,10 +5,6 @@ The default behavior is to create 1 endpoint with 1 alert and a moderate number A seed value can be provided as a string for the random number generator for repeatable behavior, useful for demos etc. Use the `-d` option if you want to delete and remake the indices, otherwise it will add documents to existing indices. -The sample data generator script depends on ts-node, install with npm: - -`npm install -g ts-node` - Example command sequence to get ES and kibana running with sample data after installing ts-node: `yarn es snapshot` -> starts ES diff --git a/x-pack/plugins/security_solution/scripts/endpoint/cli_tsconfig.json b/x-pack/plugins/security_solution/scripts/endpoint/cli_tsconfig.json deleted file mode 100644 index 5c68f8ee0abf2..0000000000000 --- a/x-pack/plugins/security_solution/scripts/endpoint/cli_tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../../tsconfig.json", - "compilerOptions": { - "target": "es2019", - "resolveJsonModule": true - } - } diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.js b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.js new file mode 100644 index 0000000000000..fb5a865439bc2 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.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; + * you may not use this file except in compliance with the Elastic License. + */ + +require('../../../../../src/setup_node_env'); +require('./resolver_generator_script'); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts similarity index 100% rename from x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts rename to x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts From 7d82e273a681c1ff685bea913dbcb96950a00aa0 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Wed, 2 Sep 2020 01:12:17 +0300 Subject: [PATCH 15/53] Add `auto` interval to histogram AggConfig (#76001) * Add `auto` interval to histogram AggConfig Closes: #75438 * fix JEST * update UI * add tests * some changes * small changes * cleanup code * fix PR comment * fix PR comments * fix PR comment * Update src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.test.ts Co-authored-by: Wylie Conlon * Change algorithm for auto interval * Update src/plugins/data/common/search/aggs/buckets/histogram.ts Co-authored-by: Luke Elmers * added some comments * Update src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.ts Co-authored-by: Wylie Conlon Co-authored-by: Elastic Machine Co-authored-by: Wylie Conlon Co-authored-by: Luke Elmers --- .../search/aggs/buckets/_interval_options.ts | 5 +- .../create_filter/date_histogram.test.ts | 7 +- .../search/aggs/buckets/date_histogram.ts | 8 +- .../search/aggs/buckets/histogram.test.ts | 21 +++ .../common/search/aggs/buckets/histogram.ts | 65 +++----- .../search/aggs/buckets/histogram_fn.test.ts | 7 +- .../search/aggs/buckets/histogram_fn.ts | 6 + .../lib/histogram_calculate_interval.test.ts | 144 ++++++++++++++++++ .../lib/histogram_calculate_interval.ts | 143 +++++++++++++++++ .../lib/time_buckets/time_buckets.test.ts | 3 +- .../buckets/lib/time_buckets/time_buckets.ts | 5 +- .../utils/calculate_auto_time_expression.ts | 3 +- .../public/components/agg_params_map.ts | 1 + .../public/components/controls/index.ts | 1 + .../public/components/controls/max_bars.tsx | 108 +++++++++++++ .../components/controls/number_interval.tsx | 93 ++++++++--- .../page_objects/visualize_editor_page.ts | 9 ++ 17 files changed, 553 insertions(+), 76 deletions(-) create mode 100644 src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.test.ts create mode 100644 src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.ts create mode 100644 src/plugins/vis_default_editor/public/components/controls/max_bars.tsx diff --git a/src/plugins/data/common/search/aggs/buckets/_interval_options.ts b/src/plugins/data/common/search/aggs/buckets/_interval_options.ts index 00cf50c272fa0..f94484a6edc2e 100644 --- a/src/plugins/data/common/search/aggs/buckets/_interval_options.ts +++ b/src/plugins/data/common/search/aggs/buckets/_interval_options.ts @@ -20,12 +20,15 @@ import { i18n } from '@kbn/i18n'; import { IBucketAggConfig } from './bucket_agg_type'; +export const autoInterval = 'auto'; +export const isAutoInterval = (value: unknown) => value === autoInterval; + export const intervalOptions = [ { display: i18n.translate('data.search.aggs.buckets.intervalOptions.autoDisplayName', { defaultMessage: 'Auto', }), - val: 'auto', + val: autoInterval, enabled(agg: IBucketAggConfig) { // not only do we need a time field, but the selected field needs // to be the time field. (see #3028) diff --git a/src/plugins/data/common/search/aggs/buckets/create_filter/date_histogram.test.ts b/src/plugins/data/common/search/aggs/buckets/create_filter/date_histogram.test.ts index 143d549836900..3d0224b213e8d 100644 --- a/src/plugins/data/common/search/aggs/buckets/create_filter/date_histogram.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/create_filter/date_histogram.test.ts @@ -19,7 +19,7 @@ import moment from 'moment'; import { createFilterDateHistogram } from './date_histogram'; -import { intervalOptions } from '../_interval_options'; +import { intervalOptions, autoInterval } from '../_interval_options'; import { AggConfigs } from '../../agg_configs'; import { mockAggTypesRegistry } from '../../test_helpers'; import { IBucketDateHistogramAggConfig } from '../date_histogram'; @@ -33,7 +33,10 @@ describe('AggConfig Filters', () => { let bucketStart: any; let field: any; - const init = (interval: string = 'auto', duration: any = moment.duration(15, 'minutes')) => { + const init = ( + interval: string = autoInterval, + duration: any = moment.duration(15, 'minutes') + ) => { field = { name: 'date', }; diff --git a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts index fdf9c456b3876..c273ca53a5fed 100644 --- a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts @@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n'; import { KBN_FIELD_TYPES, TimeRange, TimeRangeBounds, UI_SETTINGS } from '../../../../common'; -import { intervalOptions } from './_interval_options'; +import { intervalOptions, autoInterval, isAutoInterval } from './_interval_options'; import { createFilterDateHistogram } from './create_filter/date_histogram'; import { BucketAggType, IBucketAggConfig } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; @@ -44,7 +44,7 @@ const updateTimeBuckets = ( customBuckets?: IBucketDateHistogramAggConfig['buckets'] ) => { const bounds = - agg.params.timeRange && (agg.fieldIsTimeField() || agg.params.interval === 'auto') + agg.params.timeRange && (agg.fieldIsTimeField() || isAutoInterval(agg.params.interval)) ? calculateBounds(agg.params.timeRange) : undefined; const buckets = customBuckets || agg.buckets; @@ -149,7 +149,7 @@ export const getDateHistogramBucketAgg = ({ return agg.getIndexPattern().timeFieldName; }, onChange(agg: IBucketDateHistogramAggConfig) { - if (get(agg, 'params.interval') === 'auto' && !agg.fieldIsTimeField()) { + if (isAutoInterval(get(agg, 'params.interval')) && !agg.fieldIsTimeField()) { delete agg.params.interval; } }, @@ -187,7 +187,7 @@ export const getDateHistogramBucketAgg = ({ } return state; }, - default: 'auto', + default: autoInterval, options: intervalOptions, write(agg, output, aggs) { updateTimeBuckets(agg, calculateBounds); diff --git a/src/plugins/data/common/search/aggs/buckets/histogram.test.ts b/src/plugins/data/common/search/aggs/buckets/histogram.test.ts index 3727747984d3e..a8ac72c174c72 100644 --- a/src/plugins/data/common/search/aggs/buckets/histogram.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/histogram.test.ts @@ -103,7 +103,28 @@ describe('Histogram Agg', () => { }); }); + describe('maxBars', () => { + test('should not be written to the DSL', () => { + const aggConfigs = getAggConfigs({ + maxBars: 50, + field: { + name: 'field', + }, + }); + const { [BUCKET_TYPES.HISTOGRAM]: params } = aggConfigs.aggs[0].toDsl(); + + expect(params).not.toHaveProperty('maxBars'); + }); + }); + describe('interval', () => { + test('accepts "auto" value', () => { + const params = getParams({ + interval: 'auto', + }); + + expect(params).toHaveProperty('interval', 1); + }); test('accepts a whole number', () => { const params = getParams({ interval: 100, diff --git a/src/plugins/data/common/search/aggs/buckets/histogram.ts b/src/plugins/data/common/search/aggs/buckets/histogram.ts index 2b263013e55a2..4b631e1fd7cd7 100644 --- a/src/plugins/data/common/search/aggs/buckets/histogram.ts +++ b/src/plugins/data/common/search/aggs/buckets/histogram.ts @@ -28,6 +28,8 @@ import { BucketAggType, IBucketAggConfig } from './bucket_agg_type'; import { createFilterHistogram } from './create_filter/histogram'; import { BUCKET_TYPES } from './bucket_agg_types'; import { ExtendedBounds } from './lib/extended_bounds'; +import { isAutoInterval, autoInterval } from './_interval_options'; +import { calculateHistogramInterval } from './lib/histogram_calculate_interval'; export interface AutoBounds { min: number; @@ -47,6 +49,7 @@ export interface IBucketHistogramAggConfig extends IBucketAggConfig { export interface AggParamsHistogram extends BaseAggParams { field: string; interval: string; + maxBars?: number; intervalBase?: number; min_doc_count?: boolean; has_extended_bounds?: boolean; @@ -102,6 +105,7 @@ export const getHistogramBucketAgg = ({ }, { name: 'interval', + default: autoInterval, modifyAggConfigOnSearchRequestStart( aggConfig: IBucketHistogramAggConfig, searchSource: any, @@ -127,9 +131,12 @@ export const getHistogramBucketAgg = ({ return childSearchSource .fetch(options) .then((resp: any) => { + const min = resp.aggregations?.minAgg?.value ?? 0; + const max = resp.aggregations?.maxAgg?.value ?? 0; + aggConfig.setAutoBounds({ - min: get(resp, 'aggregations.minAgg.value'), - max: get(resp, 'aggregations.maxAgg.value'), + min, + max, }); }) .catch((e: Error) => { @@ -143,46 +150,24 @@ export const getHistogramBucketAgg = ({ }); }, write(aggConfig, output) { - let interval = parseFloat(aggConfig.params.interval); - if (interval <= 0) { - interval = 1; - } - const autoBounds = aggConfig.getAutoBounds(); - - // ensure interval does not create too many buckets and crash browser - if (autoBounds) { - const range = autoBounds.max - autoBounds.min; - const bars = range / interval; - - if (bars > getConfig(UI_SETTINGS.HISTOGRAM_MAX_BARS)) { - const minInterval = range / getConfig(UI_SETTINGS.HISTOGRAM_MAX_BARS); - - // Round interval by order of magnitude to provide clean intervals - // Always round interval up so there will always be less buckets than histogram:maxBars - const orderOfMagnitude = Math.pow(10, Math.floor(Math.log10(minInterval))); - let roundInterval = orderOfMagnitude; - - while (roundInterval < minInterval) { - roundInterval += orderOfMagnitude; - } - interval = roundInterval; - } - } - const base = aggConfig.params.intervalBase; - - if (base) { - if (interval < base) { - // In case the specified interval is below the base, just increase it to it's base - interval = base; - } else if (interval % base !== 0) { - // In case the interval is not a multiple of the base round it to the next base - interval = Math.round(interval / base) * base; - } - } - - output.params.interval = interval; + const values = aggConfig.getAutoBounds(); + + output.params.interval = calculateHistogramInterval({ + values, + interval: aggConfig.params.interval, + maxBucketsUiSettings: getConfig(UI_SETTINGS.HISTOGRAM_MAX_BARS), + maxBucketsUserInput: aggConfig.params.maxBars, + intervalBase: aggConfig.params.intervalBase, + }); }, }, + { + name: 'maxBars', + shouldShow(agg) { + return isAutoInterval(get(agg, 'params.interval')); + }, + write: () => {}, + }, { name: 'min_doc_count', default: false, diff --git a/src/plugins/data/common/search/aggs/buckets/histogram_fn.test.ts b/src/plugins/data/common/search/aggs/buckets/histogram_fn.test.ts index 34b6fa1a6dcd6..354946f99a2f5 100644 --- a/src/plugins/data/common/search/aggs/buckets/histogram_fn.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/histogram_fn.test.ts @@ -43,6 +43,7 @@ describe('agg_expression_functions', () => { "interval": "10", "intervalBase": undefined, "json": undefined, + "maxBars": undefined, "min_doc_count": undefined, }, "schema": undefined, @@ -55,8 +56,9 @@ describe('agg_expression_functions', () => { test('includes optional params when they are provided', () => { const actual = fn({ field: 'field', - interval: '10', + interval: 'auto', intervalBase: 1, + maxBars: 25, min_doc_count: false, has_extended_bounds: false, extended_bounds: JSON.stringify({ @@ -77,9 +79,10 @@ describe('agg_expression_functions', () => { }, "field": "field", "has_extended_bounds": false, - "interval": "10", + "interval": "auto", "intervalBase": 1, "json": undefined, + "maxBars": 25, "min_doc_count": false, }, "schema": undefined, diff --git a/src/plugins/data/common/search/aggs/buckets/histogram_fn.ts b/src/plugins/data/common/search/aggs/buckets/histogram_fn.ts index 877fd13e59f87..2e833bbe0a3eb 100644 --- a/src/plugins/data/common/search/aggs/buckets/histogram_fn.ts +++ b/src/plugins/data/common/search/aggs/buckets/histogram_fn.ts @@ -85,6 +85,12 @@ export const aggHistogram = (): FunctionDefinition => ({ defaultMessage: 'Specifies whether to use min_doc_count for this aggregation', }), }, + maxBars: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.histogram.maxBars.help', { + defaultMessage: 'Calculate interval to get approximately this many bars', + }), + }, has_extended_bounds: { types: ['boolean'], help: i18n.translate('data.search.aggs.buckets.histogram.hasExtendedBounds.help', { diff --git a/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.test.ts b/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.test.ts new file mode 100644 index 0000000000000..fd788d3339295 --- /dev/null +++ b/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.test.ts @@ -0,0 +1,144 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + calculateHistogramInterval, + CalculateHistogramIntervalParams, +} from './histogram_calculate_interval'; + +describe('calculateHistogramInterval', () => { + describe('auto calculating mode', () => { + let params: CalculateHistogramIntervalParams; + + beforeEach(() => { + params = { + interval: 'auto', + intervalBase: undefined, + maxBucketsUiSettings: 100, + maxBucketsUserInput: undefined, + values: { + min: 0, + max: 1, + }, + }; + }); + + describe('maxBucketsUserInput is defined', () => { + test('should not set interval which more than largest possible', () => { + const p = { + ...params, + maxBucketsUserInput: 200, + values: { + min: 150, + max: 250, + }, + }; + expect(calculateHistogramInterval(p)).toEqual(1); + }); + + test('should correctly work for float numbers (small numbers)', () => { + expect( + calculateHistogramInterval({ + ...params, + maxBucketsUserInput: 50, + values: { + min: 0.1, + max: 0.9, + }, + }) + ).toBe(0.02); + }); + + test('should correctly work for float numbers (big numbers)', () => { + expect( + calculateHistogramInterval({ + ...params, + maxBucketsUserInput: 10, + values: { + min: 10.45, + max: 1000.05, + }, + }) + ).toBe(100); + }); + }); + + describe('maxBucketsUserInput is not defined', () => { + test('should not set interval which more than largest possible', () => { + expect( + calculateHistogramInterval({ + ...params, + values: { + min: 0, + max: 100, + }, + }) + ).toEqual(1); + }); + + test('should set intervals for integer numbers (diff less than maxBucketsUiSettings)', () => { + expect( + calculateHistogramInterval({ + ...params, + values: { + min: 1, + max: 10, + }, + }) + ).toEqual(0.1); + }); + + test('should set intervals for integer numbers (diff more than maxBucketsUiSettings)', () => { + // diff === 44445; interval === 500; buckets === 89 + expect( + calculateHistogramInterval({ + ...params, + values: { + min: 45678, + max: 90123, + }, + }) + ).toEqual(500); + }); + + test('should set intervals the same for the same interval', () => { + // both diffs are the same + // diff === 1.655; interval === 0.02; buckets === 82 + expect( + calculateHistogramInterval({ + ...params, + values: { + min: 1.245, + max: 2.9, + }, + }) + ).toEqual(0.02); + expect( + calculateHistogramInterval({ + ...params, + values: { + min: 0.5, + max: 2.3, + }, + }) + ).toEqual(0.02); + }); + }); + }); +}); diff --git a/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.ts b/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.ts new file mode 100644 index 0000000000000..f4e42fa8881ef --- /dev/null +++ b/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.ts @@ -0,0 +1,143 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isAutoInterval } from '../_interval_options'; + +interface IntervalValuesRange { + min: number; + max: number; +} + +export interface CalculateHistogramIntervalParams { + interval: string; + maxBucketsUiSettings: number; + maxBucketsUserInput?: number; + intervalBase?: number; + values?: IntervalValuesRange; +} + +/** + * Round interval by order of magnitude to provide clean intervals + */ +const roundInterval = (minInterval: number) => { + const orderOfMagnitude = Math.pow(10, Math.floor(Math.log10(minInterval))); + let interval = orderOfMagnitude; + + while (interval < minInterval) { + interval += orderOfMagnitude; + } + + return interval; +}; + +const calculateForGivenInterval = ( + diff: number, + interval: number, + maxBucketsUiSettings: CalculateHistogramIntervalParams['maxBucketsUiSettings'] +) => { + const bars = diff / interval; + + if (bars > maxBucketsUiSettings) { + const minInterval = diff / maxBucketsUiSettings; + + return roundInterval(minInterval); + } + + return interval; +}; + +/** + * Algorithm for determining auto-interval + + 1. Define maxBars as Math.min(, ) + 2. Find the min and max values in the data + 3. Subtract the min from max to get diff + 4. Set exactInterval to diff / maxBars + 5. Based on exactInterval, find the power of 10 that's lower and higher + 6. Find the number of expected buckets that lowerPower would create: diff / lowerPower + 7. Find the number of expected buckets that higherPower would create: diff / higherPower + 8. There are three possible final intervals, pick the one that's closest to maxBars: + - The lower power of 10 + - The lower power of 10, times 2 + - The lower power of 10, times 5 + **/ +const calculateAutoInterval = ( + diff: number, + maxBucketsUiSettings: CalculateHistogramIntervalParams['maxBucketsUiSettings'], + maxBucketsUserInput: CalculateHistogramIntervalParams['maxBucketsUserInput'] +) => { + const maxBars = Math.min(maxBucketsUiSettings, maxBucketsUserInput ?? maxBucketsUiSettings); + const exactInterval = diff / maxBars; + + const lowerPower = Math.pow(10, Math.floor(Math.log10(exactInterval))); + + const autoBuckets = diff / lowerPower; + + if (autoBuckets > maxBars) { + if (autoBuckets / 2 <= maxBars) { + return lowerPower * 2; + } else if (autoBuckets / 5 <= maxBars) { + return lowerPower * 5; + } else { + return lowerPower * 10; + } + } + + return lowerPower; +}; + +export const calculateHistogramInterval = ({ + interval, + maxBucketsUiSettings, + maxBucketsUserInput, + intervalBase, + values, +}: CalculateHistogramIntervalParams) => { + const isAuto = isAutoInterval(interval); + let calculatedInterval = isAuto ? 0 : parseFloat(interval); + + // should return NaN on non-numeric or invalid values + if (Number.isNaN(calculatedInterval)) { + return calculatedInterval; + } + + if (values) { + const diff = values.max - values.min; + + if (diff) { + calculatedInterval = isAuto + ? calculateAutoInterval(diff, maxBucketsUiSettings, maxBucketsUserInput) + : calculateForGivenInterval(diff, calculatedInterval, maxBucketsUiSettings); + } + } + + if (intervalBase) { + if (calculatedInterval < intervalBase) { + // In case the specified interval is below the base, just increase it to it's base + calculatedInterval = intervalBase; + } else if (calculatedInterval % intervalBase !== 0) { + // In case the interval is not a multiple of the base round it to the next base + calculatedInterval = Math.round(calculatedInterval / intervalBase) * intervalBase; + } + } + + const defaultValueForUnspecifiedInterval = 1; + + return calculatedInterval || defaultValueForUnspecifiedInterval; +}; diff --git a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts index ae7630ecd3dac..04e64233ce196 100644 --- a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts @@ -20,6 +20,7 @@ import moment from 'moment'; import { TimeBuckets, TimeBucketsConfig } from './time_buckets'; +import { autoInterval } from '../../_interval_options'; describe('TimeBuckets', () => { const timeBucketConfig: TimeBucketsConfig = { @@ -103,7 +104,7 @@ describe('TimeBuckets', () => { test('setInterval/getInterval - intreval is a "auto"', () => { const timeBuckets = new TimeBuckets(timeBucketConfig); - timeBuckets.setInterval('auto'); + timeBuckets.setInterval(autoInterval); const interval = timeBuckets.getInterval(); expect(interval.description).toEqual('0 milliseconds'); diff --git a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.ts b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.ts index 6402a6e83ead9..d054df0c9274e 100644 --- a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.ts +++ b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.ts @@ -28,6 +28,7 @@ import { convertIntervalToEsInterval, EsInterval, } from './calc_es_interval'; +import { autoInterval } from '../../_interval_options'; interface TimeBucketsInterval extends moment.Duration { // TODO double-check whether all of these are needed @@ -189,8 +190,8 @@ export class TimeBuckets { interval = input.val; } - if (!interval || interval === 'auto') { - this._i = 'auto'; + if (!interval || interval === autoInterval) { + this._i = autoInterval; return; } diff --git a/src/plugins/data/common/search/aggs/utils/calculate_auto_time_expression.ts b/src/plugins/data/common/search/aggs/utils/calculate_auto_time_expression.ts index 622e8101f34ab..3637ded44c50a 100644 --- a/src/plugins/data/common/search/aggs/utils/calculate_auto_time_expression.ts +++ b/src/plugins/data/common/search/aggs/utils/calculate_auto_time_expression.ts @@ -21,6 +21,7 @@ import { UI_SETTINGS } from '../../../../common/constants'; import { TimeRange } from '../../../../common/query'; import { TimeBuckets } from '../buckets/lib/time_buckets'; import { toAbsoluteDates } from './date_interval_utils'; +import { autoInterval } from '../buckets/_interval_options'; export function getCalculateAutoTimeExpression(getConfig: (key: string) => any) { return function calculateAutoTimeExpression(range: TimeRange) { @@ -36,7 +37,7 @@ export function getCalculateAutoTimeExpression(getConfig: (key: string) => any) 'dateFormat:scaled': getConfig('dateFormat:scaled'), }); - buckets.setInterval('auto'); + buckets.setInterval(autoInterval); buckets.setBounds({ min: moment(dates.from), max: moment(dates.to), diff --git a/src/plugins/vis_default_editor/public/components/agg_params_map.ts b/src/plugins/vis_default_editor/public/components/agg_params_map.ts index 9bc3146b9903b..e9019e479f92f 100644 --- a/src/plugins/vis_default_editor/public/components/agg_params_map.ts +++ b/src/plugins/vis_default_editor/public/components/agg_params_map.ts @@ -43,6 +43,7 @@ const buckets = { }, [BUCKET_TYPES.HISTOGRAM]: { interval: controls.NumberIntervalParamEditor, + maxBars: controls.MaxBarsParamEditor, min_doc_count: controls.MinDocCountParamEditor, has_extended_bounds: controls.HasExtendedBoundsParamEditor, extended_bounds: controls.ExtendedBoundsParamEditor, diff --git a/src/plugins/vis_default_editor/public/components/controls/index.ts b/src/plugins/vis_default_editor/public/components/controls/index.ts index cfb236e5e22e3..26e6609c7711d 100644 --- a/src/plugins/vis_default_editor/public/components/controls/index.ts +++ b/src/plugins/vis_default_editor/public/components/controls/index.ts @@ -52,3 +52,4 @@ export { TopSizeParamEditor } from './top_size'; export { TopSortFieldParamEditor } from './top_sort_field'; export { OrderParamEditor } from './order'; export { UseGeocentroidParamEditor } from './use_geocentroid'; +export { MaxBarsParamEditor } from './max_bars'; diff --git a/src/plugins/vis_default_editor/public/components/controls/max_bars.tsx b/src/plugins/vis_default_editor/public/components/controls/max_bars.tsx new file mode 100644 index 0000000000000..b0d517e0928df --- /dev/null +++ b/src/plugins/vis_default_editor/public/components/controls/max_bars.tsx @@ -0,0 +1,108 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useCallback, useEffect } from 'react'; +import { EuiFormRow, EuiFieldNumber, EuiFieldNumberProps, EuiIconTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../kibana_react/public'; +import { AggParamEditorProps } from '../agg_param_props'; +import { UI_SETTINGS } from '../../../../data/public'; + +export interface SizeParamEditorProps extends AggParamEditorProps { + iconTip?: React.ReactNode; + disabled?: boolean; +} + +const autoPlaceholder = i18n.translate('visDefaultEditor.controls.maxBars.autoPlaceholder', { + defaultMessage: 'Auto', +}); + +const label = ( + <> + {' '} + + } + type="questionInCircle" + /> + +); + +function MaxBarsParamEditor({ + disabled, + iconTip, + value, + setValue, + showValidation, + setValidity, + setTouched, +}: SizeParamEditorProps) { + const { services } = useKibana(); + const uiSettingMaxBars = services.uiSettings?.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); + const isValid = + disabled || + value === undefined || + value === '' || + Number(value) > 0 || + value < uiSettingMaxBars; + + useEffect(() => { + setValidity(isValid); + }, [isValid, setValidity]); + + const onChange: EuiFieldNumberProps['onChange'] = useCallback( + (ev) => setValue(ev.target.value === '' ? '' : parseFloat(ev.target.value)), + [setValue] + ); + + return ( + + + + ); +} + +export { MaxBarsParamEditor }; diff --git a/src/plugins/vis_default_editor/public/components/controls/number_interval.tsx b/src/plugins/vis_default_editor/public/components/controls/number_interval.tsx index f6354027ab01b..8cdc92581cefb 100644 --- a/src/plugins/vis_default_editor/public/components/controls/number_interval.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/number_interval.tsx @@ -20,7 +20,16 @@ import { get } from 'lodash'; import React, { useEffect, useCallback } from 'react'; -import { EuiFieldNumber, EuiFormRow, EuiIconTip } from '@elastic/eui'; +import { + EuiFieldNumber, + EuiFormRow, + EuiIconTip, + EuiSwitch, + EuiSwitchProps, + EuiFieldNumberProps, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { UI_SETTINGS } from '../../../../data/public'; @@ -47,6 +56,25 @@ const label = ( ); +const autoInterval = 'auto'; +const isAutoInterval = (value: unknown) => value === autoInterval; + +const selectIntervalPlaceholder = i18n.translate( + 'visDefaultEditor.controls.numberInterval.selectIntervalPlaceholder', + { + defaultMessage: 'Enter an interval', + } +); +const autoIntervalIsUsedPlaceholder = i18n.translate( + 'visDefaultEditor.controls.numberInterval.autoInteralIsUsed', + { + defaultMessage: 'Auto interval is used', + } +); +const useAutoIntervalLabel = i18n.translate('visDefaultEditor.controls.useAutoInterval', { + defaultMessage: 'Use auto interval', +}); + function NumberIntervalParamEditor({ agg, editorConfig, @@ -55,18 +83,28 @@ function NumberIntervalParamEditor({ setTouched, setValidity, setValue, -}: AggParamEditorProps) { +}: AggParamEditorProps) { + const isAutoChecked = isAutoInterval(value); const base: number = get(editorConfig, 'interval.base') as number; const min = base || 0; - const isValid = value !== undefined && value >= min; + const isValid = + value !== '' && value !== undefined && (isAutoInterval(value) || Number(value) >= min); useEffect(() => { setValidity(isValid); }, [isValid, setValidity]); - const onChange = useCallback( - ({ target }: React.ChangeEvent) => - setValue(isNaN(target.valueAsNumber) ? undefined : target.valueAsNumber), + const onChange: EuiFieldNumberProps['onChange'] = useCallback( + ({ target }) => setValue(isNaN(target.valueAsNumber) ? '' : target.valueAsNumber), + [setValue] + ); + + const onAutoSwitchChange: EuiSwitchProps['onChange'] = useCallback( + (e) => { + const isAutoSwitchChecked = e.target.checked; + + setValue(isAutoSwitchChecked ? autoInterval : ''); + }, [setValue] ); @@ -78,23 +116,32 @@ function NumberIntervalParamEditor({ isInvalid={showValidation && !isValid} helpText={get(editorConfig, 'interval.help')} > - + + + + + + + + ); } diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 9fcb38efce0db..8cac43d97317b 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -445,6 +445,15 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP } else if (type === 'custom') { await comboBox.setCustom('visEditorInterval', newValue); } else { + if (type === 'numeric') { + const autoMode = await testSubjects.getAttribute( + `visEditorIntervalSwitch${aggNth}`, + 'aria-checked' + ); + if (autoMode === 'true') { + await testSubjects.click(`visEditorIntervalSwitch${aggNth}`); + } + } if (append) { await testSubjects.append(`visEditorInterval${aggNth}`, String(newValue)); } else { From 298a7899bcb2c2bae87be2fcf380e9784348e953 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 1 Sep 2020 15:40:51 -0700 Subject: [PATCH 16/53] skip flaky suite (#76245) --- test/functional/apps/dashboard/embeddable_rendering.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/dashboard/embeddable_rendering.js b/test/functional/apps/dashboard/embeddable_rendering.js index 73c36c7562e8b..b7b795ae11c96 100644 --- a/test/functional/apps/dashboard/embeddable_rendering.js +++ b/test/functional/apps/dashboard/embeddable_rendering.js @@ -99,7 +99,8 @@ export default function ({ getService, getPageObjects }) { await dashboardExpect.vegaTextsDoNotExist(['5,000']); }; - describe('dashboard embeddable rendering', function describeIndexTests() { + // Failing: See https://github.com/elastic/kibana/issues/76245 + describe.skip('dashboard embeddable rendering', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'animals', 'test_logstash_reader']); await esArchiver.load('dashboard/current/kibana'); From d4a9ea2fff07d4b9a2e66880f4c0005d080c6d4b Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 1 Sep 2020 16:21:49 -0700 Subject: [PATCH 17/53] [Enterprise Search] Fix various icons in dark mode (#76430) * Update Home catalogue icons to use new 28.0 EUI app icons * Fix App Search engine icons in dark mode - Convert to React SVG element in order to be able to inherit correct fill in dark mode - Fix vertical centering (taking advantage of SVG centering for us) - Move icons directly to the engines overview component instead of having to reach upwards to a shared assets folder for them * [Cleanup] Move Setup Guide images - to be nested within their own component views that use them, instead of having to grab them upwards from a shared assets folder --- .../applications/app_search/assets/engine.svg | 3 -- .../applications/app_search/assets/logo.svg | 4 --- .../app_search/assets/meta_engine.svg | 4 --- .../engine_overview/assets/engine_icon.tsx | 24 +++++++++++++++ .../assets/meta_engine_icon.tsx | 29 ++++++++++++++++++ .../engine_overview/engine_overview.scss | 4 ++- .../engine_overview/engine_overview.tsx | 8 ++--- .../setup_guide}/assets/getting_started.png | Bin .../components/setup_guide/setup_guide.tsx | 2 +- .../workplace_search/assets/logo.svg | 5 --- .../setup_guide}/assets/getting_started.png | Bin .../views/setup_guide/setup_guide.tsx | 2 +- .../enterprise_search/public/plugin.ts | 6 ++-- 13 files changed, 64 insertions(+), 27 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/assets/engine_icon.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/assets/meta_engine_icon.tsx rename x-pack/plugins/enterprise_search/public/applications/app_search/{ => components/setup_guide}/assets/getting_started.png (100%) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{ => views/setup_guide}/assets/getting_started.png (100%) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg deleted file mode 100644 index ceab918e92e70..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg deleted file mode 100644 index 2284a425b5add..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg deleted file mode 100644 index 4e01e9a0b34fb..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/assets/engine_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/assets/engine_icon.tsx new file mode 100644 index 0000000000000..64494bfaa7949 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/assets/engine_icon.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +export const EngineIcon: React.FC = () => ( + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/assets/meta_engine_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/assets/meta_engine_icon.tsx new file mode 100644 index 0000000000000..e2939ddcde9d7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/assets/meta_engine_icon.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +export const MetaEngineIcon: React.FC = () => ( + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss index e39bbbc95564b..5e8a20ba425ad 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss @@ -15,6 +15,8 @@ .engineIcon { display: inline-block; width: $euiSize; - height: $euiSize; + // Use line-height of EuiTitle - SVG will vertically center accordingly + height: map-get(map-get($euiTitles, 's'), 'line-height'); + vertical-align: top; margin-right: $euiSizeXS; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index c3b47b2b585bd..9703fde7e140a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -20,8 +20,8 @@ import { FlashMessages } from '../../../shared/flash_messages'; import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing'; import { KibanaContext, IKibanaContext } from '../../../index'; -import EnginesIcon from '../../assets/engine.svg'; -import MetaEnginesIcon from '../../assets/meta_engine.svg'; +import { EngineIcon } from './assets/engine_icon'; +import { MetaEngineIcon } from './assets/meta_engine_icon'; import { EngineOverviewHeader, LoadingState, EmptyState } from './components'; import { EngineTable } from './engine_table'; @@ -93,7 +93,7 @@ export const EngineOverview: React.FC = () => {

- + {

- + ( - - - - diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/assets/getting_started.png similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/assets/getting_started.png diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx index f9b00bdf29642..d632792f2a666 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx @@ -13,7 +13,7 @@ import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import GettingStarted from '../../assets/getting_started.png'; +import GettingStarted from './assets/getting_started.png'; const GETTING_STARTED_LINK_URL = 'https://www.elastic.co/guide/en/workplace-search/current/workplace-search-getting-started.html'; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 1ce6bae8ff603..83598a0dc971d 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -27,8 +27,6 @@ import { WORKPLACE_SEARCH_PLUGIN, } from '../common/constants'; import { ExternalUrl, IExternalUrl } from './applications/shared/enterprise_search_url'; -import AppSearchLogo from './applications/app_search/assets/logo.svg'; -import WorkplaceSearchLogo from './applications/workplace_search/assets/logo.svg'; export interface ClientConfigType { host?: string; @@ -117,7 +115,7 @@ export class EnterpriseSearchPlugin implements Plugin { plugins.home.featureCatalogue.register({ id: APP_SEARCH_PLUGIN.ID, title: APP_SEARCH_PLUGIN.NAME, - icon: AppSearchLogo, + icon: 'appSearchApp', description: APP_SEARCH_PLUGIN.DESCRIPTION, path: APP_SEARCH_PLUGIN.URL, category: FeatureCatalogueCategory.DATA, @@ -127,7 +125,7 @@ export class EnterpriseSearchPlugin implements Plugin { plugins.home.featureCatalogue.register({ id: WORKPLACE_SEARCH_PLUGIN.ID, title: WORKPLACE_SEARCH_PLUGIN.NAME, - icon: WorkplaceSearchLogo, + icon: 'workplaceSearchApp', description: WORKPLACE_SEARCH_PLUGIN.DESCRIPTION, path: WORKPLACE_SEARCH_PLUGIN.URL, category: FeatureCatalogueCategory.DATA, From a3af33ddaf32cfbfb53f66478bf20649a998a06b Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 1 Sep 2020 16:55:04 -0700 Subject: [PATCH 18/53] [Reporting] Add functional test for Reports in non-default spaces (#76053) * [Reporting] Add functional test for Download CSV in non-default space * skip tests * unskip * rm comment Co-authored-by: Elastic Machine --- .../ecommerce_kibana_spaces/data.json.gz | Bin 0 -> 1752 bytes .../ecommerce_kibana_spaces/mappings.json | 2635 +++++++++++++++++ .../reporting_and_security.config.ts | 1 - .../reporting_and_security/index.ts | 3 +- .../reporting_and_security/network_policy.ts | 2 +- .../reporting_and_security/spaces.ts | 76 + .../reporting_and_security/usage.ts | 2 +- 7 files changed, 2715 insertions(+), 4 deletions(-) create mode 100644 x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/data.json.gz create mode 100644 x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/mappings.json create mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/spaces.ts diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/data.json.gz b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..06e83f8c267d6618669bd8641c79c3dda19c1679 GIT binary patch literal 1752 zcmV;}1}FI+iwFpY>qcJ!17u-zVJ>QOZ*Bn9TU&3VI23;GuZVbDH3$f~z}srNm1d>s ztftv{n6^R=IElA_Gsbk9t@7XZ*jxgHWXQDJ#8Rt}S72+JT*fmDu0Q-*7$7789rioD> zjR!_Q7*4Q*(Res9XL5QmqZlbB)gQ?W#i*bsG-dvF<)UY?Mx_Co<^+d9B4)XJxz~J} z#{6D$jmvv2MiGX`d(9)ky#@rqp(G@nk$53#IGF-90rFkm@d2ob&&Y~n_~y=0m|#J| zi1|x23&j+irGkbQ3x*en*QpGn;F{6siuzxL2$N!#&E8?7z!1pP~;+kLSRFrT+}KdEA9vt(RXr$)sn{e{7oxBYmd&o9~eI> z1=b^bHzYN!%Z@_bOO56MJ3Uhf+GA zg?xUFd)pS6DwMM;MuXJniQXCQ2`#+}R!vK!yN7F777%;QV(85BL5VjYiR|E^DC?(^Q<@} zy9ry`vvRm4-m^|HEqK8Nzo+7aCv(a3&x_qJ(Va3MrpXyyeR#-k*2)XBoJ9CqT$;TB ztm5A6_E5Xtjfb)s`!tHgj{7$)Z`~B#w1_O2{4MA!W(j=MujV#y?P_Cvq#L`l2%*Wl z#H|;MOHjby9E;mhgShBq8J5vECVgu(wJg_mT+7mp ziSaE%x497in~RH=*W8TuX*?$jccL4>_0e~pv(a^l&Qbz;OCz8^eQcVq&~~Qb zeYlTM^H2CIL{t$i)JwwegKLb$Nk(;CWn?bmcIobDqhPur{DV>)x&uoeSk|khm}5I$ zb}hp3Kp>bsVKKc7i&eAFqHAWW+H^^h9{lko?&S!2b@qQn)>rvVw{ItB+Z~wtWHNZu z*c6~-IirXL?!b329*o}jjg4~thV9mMP!;u(a3Nl!Ldq7oMWD{lvwyJ9KVgn)6NLH2+oPE(vn$bFP9)x3dCH=w1SK2YlKSv81T&!~>Dz2Zm+Zwu$;f8=HOGo{jqs0)q;iv4N0dIf3nGJWCuO zvlmJ_4-^#))P+GHT;qcbyaHc2-xoZ;DYtrp-~luBA_Sryc+>JD)R7DSQh zaK*ZfR{hA*1`43eiA<+&jQXZAH3qIZcCCp%F(AP=`hUR*r)+mI**K-!sm6WNo|;f7 z3|(WS4<^HJt>w}mP%ibZ3hVFP(70ER36()lW#%-GOs`e;Cf51LUxzsAqFpc6me}bIdYe#FxD&q_tKj@0WI$WU&n4pZ^|)+myIXZaLvq*;(W{VHwpt ubBwXUgzfn~p^WdX@ahr%HnX^n!MqXay6#DbmTp { + describe('Network Policy', () => { before(async () => { await esArchiver.load(archive); // includes a canvas worksheet with an offending image URL }); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/spaces.ts b/x-pack/test/reporting_api_integration/reporting_and_security/spaces.ts new file mode 100644 index 0000000000000..0145ca2a18092 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/spaces.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import * as Rx from 'rxjs'; +import { filter, first, map, switchMap, tap, timeout } from 'rxjs/operators'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const reportingAPI = getService('reportingAPI'); + const supertest = getService('supertest'); + const log = getService('log'); + + const getCompleted$ = (downloadPath: string) => { + return Rx.interval(2000).pipe( + tap(() => log.debug(`checking report status at ${downloadPath}...`)), + switchMap(() => supertest.get(downloadPath)), + filter(({ status: statusCode }) => statusCode === 200), + map((response) => response.text), + first(), + timeout(15000) + ); + }; + + describe('Exports from Non-default Space', () => { + before(async () => { + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana_spaces'); // dashboard in non default space + }); + + after(async () => { + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana_spaces'); + }); + + afterEach(async () => { + await reportingAPI.deleteAllReports(); + }); + + it('should complete a job of CSV saved search export in non-default space', async () => { + const downloadPath = await reportingAPI.postJob( + `/s/non_default_space/api/reporting/generate/csv?jobParams=%28browserTimezone%3AUTC%2CconflictedTypesFields%3A%21%28%29%2Cfields%3A%21%28order_date%2Ccategory%2Ccustomer_first_name%2Ccustomer_full_name%2Ctotal_quantity%2Ctotal_unique_products%2Ctaxless_total_price%2Ctaxful_total_price%2Ccurrency%29%2CindexPatternId%3A%27067dec90-e7ee-11ea-a730-d58e9ea7581b%27%2CmetaFields%3A%21%28_source%2C_id%2C_type%2C_index%2C_score%29%2CobjectType%3Asearch%2CsearchRequest%3A%28body%3A%28_source%3A%28includes%3A%21%28order_date%2Ccategory%2Ccustomer_first_name%2Ccustomer_full_name%2Ctotal_quantity%2Ctotal_unique_products%2Ctaxless_total_price%2Ctaxful_total_price%2Ccurrency%29%29%2Cdocvalue_fields%3A%21%28%28field%3Aorder_date%2Cformat%3Adate_time%29%29%2Cquery%3A%28bool%3A%28filter%3A%21%28%28match_all%3A%28%29%29%2C%28range%3A%28order_date%3A%28format%3Astrict_date_optional_time%2Cgte%3A%272019-06-11T08%3A24%3A16.425Z%27%2Clte%3A%272019-07-13T09%3A31%3A07.520Z%27%29%29%29%29%2Cmust%3A%21%28%29%2Cmust_not%3A%21%28%29%2Cshould%3A%21%28%29%29%29%2Cscript_fields%3A%28%29%2Csort%3A%21%28%28order_date%3A%28order%3Adesc%2Cunmapped_type%3Aboolean%29%29%29%2Cstored_fields%3A%21%28order_date%2Ccategory%2Ccustomer_first_name%2Ccustomer_full_name%2Ctotal_quantity%2Ctotal_unique_products%2Ctaxless_total_price%2Ctaxful_total_price%2Ccurrency%29%2Cversion%3A%21t%29%2Cindex%3A%27ecommerce%2A%27%29%2Ctitle%3A%27Ecom%20Search%27%29` + ); + + // Retry the download URL until a "completed" response status is returned + const completed$ = getCompleted$(downloadPath); + const reportCompleted = await completed$.toPromise(); + expect(reportCompleted).to.match(/^"order_date",/); + }); + + it('should complete a job of PNG export of a dashboard in non-default space', async () => { + const downloadPath = await reportingAPI.postJob( + `/s/non_default_space/api/reporting/generate/png?jobParams=%28browserTimezone%3AUTC%2Clayout%3A%28dimensions%3A%28height%3A512%2Cwidth%3A2402%29%2Cid%3Apng%29%2CobjectType%3Adashboard%2CrelativeUrl%3A%27%2Fs%2Fnon_default_space%2Fapp%2Fdashboards%23%2Fview%2F3c9ee360-e7ee-11ea-a730-d58e9ea7581b%3F_g%3D%28filters%3A%21%21%28%29%2CrefreshInterval%3A%28pause%3A%21%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3A%21%272019-06-10T03%3A17%3A28.800Z%21%27%2Cto%3A%21%272019-07-14T19%3A25%3A06.385Z%21%27%29%29%26_a%3D%28description%3A%21%27%21%27%2Cfilters%3A%21%21%28%29%2CfullScreenMode%3A%21%21f%2Coptions%3A%28hidePanelTitles%3A%21%21f%2CuseMargins%3A%21%21t%29%2Cquery%3A%28language%3Akuery%2Cquery%3A%21%27%21%27%29%2CtimeRestore%3A%21%21t%2Ctitle%3A%21%27Ecom%2520Dashboard%2520Non%2520Default%2520Space%21%27%2CviewMode%3Aview%29%27%2Ctitle%3A%27Ecom%20Dashboard%20Non%20Default%20Space%27%29` + ); + + const completed$: Rx.Observable = getCompleted$(downloadPath); + const reportCompleted = await completed$.toPromise(); + expect(reportCompleted).to.not.be(null); + }); + + it('should complete a job of PDF export of a dashboard in non-default space', async () => { + const downloadPath = await reportingAPI.postJob( + `/s/non_default_space/api/reporting/generate/printablePdf?jobParams=%28browserTimezone%3AUTC%2Clayout%3A%28dimensions%3A%28height%3A512%2Cwidth%3A2402%29%2Cid%3Apreserve_layout%29%2CobjectType%3Adashboard%2CrelativeUrls%3A%21%28%27%2Fs%2Fnon_default_space%2Fapp%2Fdashboards%23%2Fview%2F3c9ee360-e7ee-11ea-a730-d58e9ea7581b%3F_g%3D%28filters%3A%21%21%28%29%2CrefreshInterval%3A%28pause%3A%21%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3A%21%272019-06-10T03%3A17%3A28.800Z%21%27%2Cto%3A%21%272019-07-14T19%3A25%3A06.385Z%21%27%29%29%26_a%3D%28description%3A%21%27%21%27%2Cfilters%3A%21%21%28%29%2CfullScreenMode%3A%21%21f%2Coptions%3A%28hidePanelTitles%3A%21%21f%2CuseMargins%3A%21%21t%29%2Cquery%3A%28language%3Akuery%2Cquery%3A%21%27%21%27%29%2CtimeRestore%3A%21%21t%2Ctitle%3A%21%27Ecom%2520Dashboard%2520Non%2520Default%2520Space%21%27%2CviewMode%3Aview%29%27%29%2Ctitle%3A%27Ecom%20Dashboard%20Non%20Default%20Space%27%29` + ); + + const completed$ = getCompleted$(downloadPath); + const reportCompleted = await completed$.toPromise(); + expect(reportCompleted).to.not.be(null); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts index 24e68b3917d6c..feda5c1386e98 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts @@ -21,7 +21,7 @@ export default function ({ getService }: FtrProviderContext) { const reportingAPI = getService('reportingAPI'); const usageAPI = getService('usageAPI'); - describe('reporting usage', () => { + describe('Usage', () => { before(async () => { await esArchiver.load(OSS_KIBANA_ARCHIVE_PATH); await esArchiver.load(OSS_DATA_ARCHIVE_PATH); From 03002453d3d90831670c5215a613a52bd0306f00 Mon Sep 17 00:00:00 2001 From: spalger Date: Tue, 1 Sep 2020 21:03:43 -0700 Subject: [PATCH 19/53] skip flaky suite (#75724) --- x-pack/test/functional/apps/infra/home_page.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/infra/home_page.ts b/x-pack/test/functional/apps/infra/home_page.ts index 04f289b69bb71..82aba0503fb9d 100644 --- a/x-pack/test/functional/apps/infra/home_page.ts +++ b/x-pack/test/functional/apps/infra/home_page.ts @@ -21,7 +21,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'infraHome']); const supertest = getService('supertest'); - describe('Home page', function () { + // FLAKY: https://github.com/elastic/kibana/issues/75724 + describe.skip('Home page', function () { this.tags('includeFirefox'); before(async () => { await esArchiver.load('empty_kibana'); From 6686cffe01ab5ec667bcb612b27fa9cd3443e19f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Wed, 2 Sep 2020 11:17:30 +0200 Subject: [PATCH 20/53] [ILM] TS conversion of policies table (#76006) * [ILM] TS conversion of policies table * [ILM] Fix type check errors * [ILM] Fix i18n check errors * [ILM] Add types to linkedIndices * [ILM] Add PR review fixes * [ILM] Add PR review fixes (filter fn) * [ILM] Fix i18n errors Co-authored-by: Elastic Machine --- ...est.js.snap => policy_table.test.tsx.snap} | 88 ++- ...cy_table.test.js => policy_table.test.tsx} | 103 ++-- .../public/application/index.tsx | 6 +- .../edit_policy/edit_policy.container.tsx | 40 +- ... add_policy_to_template_confirm_modal.tsx} | 38 +- .../confirm_delete.js => confirm_delete.tsx} | 15 +- .../no_match/components/no_match/index.js | 7 - .../no_match/components/no_match/no_match.js | 17 - .../policy_table/components/no_match/index.js | 7 - .../policy_table/policy_table.container.js | 59 -- .../components/policy_table/policy_table.js | 530 ------------------ .../policy_table/components/table_content.tsx | 376 +++++++++++++ .../sections/policy_table/index.d.ts | 7 - .../sections/policy_table/index.js | 7 - .../policy_table/index.js => index.ts} | 0 .../policy_table/policy_table.container.tsx | 75 +++ .../sections/policy_table/policy_table.tsx | 183 ++++++ .../application/services/filter_items.js | 17 - .../application/services/filter_items.ts | 13 + .../services/flatten_panel_tree.js | 20 - .../services/{index.js => index.ts} | 0 .../application/services/policies/types.ts | 1 + .../public/application/services/sort_table.js | 21 - .../public/application/services/sort_table.ts | 23 + .../public/application/store/actions/index.js | 7 - .../application/store/actions/policies.js | 42 -- .../public/application/store/index.d.ts | 7 - .../public/application/store/index.js | 7 - .../application/store/reducers/index.js | 12 - .../application/store/reducers/policies.js | 76 --- .../application/store/selectors/index.js | 7 - .../application/store/selectors/policies.js | 42 -- .../public/application/store/store.js | 17 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 35 files changed, 820 insertions(+), 1054 deletions(-) rename x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/{policy_table.test.js.snap => policy_table.test.tsx.snap} (70%) rename x-pack/plugins/index_lifecycle_management/__jest__/components/{policy_table.test.js => policy_table.test.tsx} (68%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/{policy_table/add_policy_to_template_confirm_modal.js => add_policy_to_template_confirm_modal.tsx} (89%) rename x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/{policy_table/confirm_delete.js => confirm_delete.tsx} (85%) delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/components/no_match/index.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/components/no_match/no_match.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/index.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.container.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.d.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.js rename x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/{components/policy_table/index.js => index.ts} (100%) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/policy_table.container.tsx create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/policy_table.tsx delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.js create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/flatten_panel_tree.js rename x-pack/plugins/index_lifecycle_management/public/application/services/{index.js => index.ts} (100%) delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.js create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/index.d.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/index.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/store.js diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap b/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.tsx.snap similarity index 70% rename from x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap rename to x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.tsx.snap index ad3e0956fcf25..cbb9f82888701 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.js.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/__snapshots__/policy_table.test.tsx.snap @@ -52,68 +52,60 @@ exports[`policy table should show empty state when there are not any policies 1`
-
+
+ +

+ Create your first index lifecycle policy +

+ class="euiText euiText--medium" + > +

+ An index lifecycle policy helps you manage your indices as they age. +

+
+ +
+
+ -
-
+ +
diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.tsx similarity index 68% rename from x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js rename to x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.tsx index 60e3e9443bec9..d95b4503c266b 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.js +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/policy_table.test.tsx @@ -4,54 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ import moment from 'moment-timezone'; -import React from 'react'; -import { Provider } from 'react-redux'; -// axios has a $http like interface so using it to simulate $http -import axios from 'axios'; -import axiosXhrAdapter from 'axios/lib/adapters/xhr'; -import sinon from 'sinon'; +import React, { ReactElement } from 'react'; +import { ReactWrapper } from 'enzyme'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { findTestSubject, takeMountedSnapshot } from '@elastic/eui/lib/test'; -import { scopedHistoryMock } from '../../../../../src/core/public/mocks'; -import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; -import { fetchedPolicies } from '../../public/application/store/actions'; -import { indexLifecycleManagementStore } from '../../public/application/store'; -import { PolicyTable } from '../../public/application/sections/policy_table'; +import { + fatalErrorsServiceMock, + injectedMetadataServiceMock, + scopedHistoryMock, +} from '../../../../../src/core/public/mocks'; +import { HttpService } from '../../../../../src/core/public/http'; +import { usageCollectionPluginMock } from '../../../../../src/plugins/usage_collection/public/mocks'; + +import { PolicyTable } from '../../public/application/sections/policy_table/policy_table'; import { init as initHttp } from '../../public/application/services/http'; import { init as initUiMetric } from '../../public/application/services/ui_metric'; +import { PolicyFromES } from '../../public/application/services/policies/types'; -initHttp(axios.create({ adapter: axiosXhrAdapter }), (path) => path); -initUiMetric({ reportUiStats: () => {} }); - -let server = null; +initHttp( + new HttpService().setup({ + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + fatalErrors: fatalErrorsServiceMock.createSetupContract(), + }) +); +initUiMetric(usageCollectionPluginMock.createSetupContract()); -let store = null; -const policies = []; +const policies: PolicyFromES[] = []; for (let i = 0; i < 105; i++) { policies.push({ version: i, - modified_date: moment().subtract(i, 'days').valueOf(), - linkedIndices: i % 2 === 0 ? [`index${i}`] : null, + modified_date: moment().subtract(i, 'days').toISOString(), + linkedIndices: i % 2 === 0 ? [`index${i}`] : undefined, name: `testy${i}`, + policy: { + name: `testy${i}`, + phases: {}, + }, }); } jest.mock(''); -let component = null; +let component: ReactElement; -const snapshot = (rendered) => { +const snapshot = (rendered: string[]) => { expect(rendered).toMatchSnapshot(); }; -const mountedSnapshot = (rendered) => { +const mountedSnapshot = (rendered: ReactWrapper) => { expect(takeMountedSnapshot(rendered)).toMatchSnapshot(); }; -const names = (rendered) => { +const names = (rendered: ReactWrapper) => { return findTestSubject(rendered, 'policyTablePolicyNameLink'); }; -const namesText = (rendered) => { - return names(rendered).map((button) => button.text()); +const namesText = (rendered: ReactWrapper): string[] => { + return (names(rendered) as ReactWrapper).map((button) => button.text()); }; -const testSort = (headerName) => { +const testSort = (headerName: string) => { const rendered = mountWithIntl(component); const nameHeader = findTestSubject(rendered, `policyTableHeaderCell-${headerName}`).find( 'button' @@ -63,7 +71,7 @@ const testSort = (headerName) => { rendered.update(); snapshot(namesText(rendered)); }; -const openContextMenu = (buttonIndex) => { +const openContextMenu = (buttonIndex: number) => { const rendered = mountWithIntl(component); const actionsButton = findTestSubject(rendered, 'policyActionsContextMenuButton'); actionsButton.at(buttonIndex).simulate('click'); @@ -73,33 +81,26 @@ const openContextMenu = (buttonIndex) => { describe('policy table', () => { beforeEach(() => { - store = indexLifecycleManagementStore(); component = ( - - {}} /> - + ); - store.dispatch(fetchedPolicies(policies)); - server = sinon.fakeServer.create(); - server.respondWith('/api/index_lifecycle_management/policies', [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(policies), - ]); }); - test('should show spinner when policies are loading', () => { - store = indexLifecycleManagementStore(); + + test('should show empty state when there are not any policies', () => { component = ( - - {}} /> - + ); const rendered = mountWithIntl(component); - expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); - }); - test('should show empty state when there are not any policies', () => { - store.dispatch(fetchedPolicies([])); - const rendered = mountWithIntl(component); mountedSnapshot(rendered); }); test('should change pages when a pagination link is clicked on', () => { @@ -123,7 +124,7 @@ describe('policy table', () => { test('should filter based on content of search input', () => { const rendered = mountWithIntl(component); const searchInput = rendered.find('.euiFieldSearch').first(); - searchInput.instance().value = 'testy0'; + ((searchInput.instance() as unknown) as HTMLInputElement).value = 'testy0'; searchInput.simulate('keyup', { key: 'Enter', keyCode: 13, which: 13 }); rendered.update(); snapshot(namesText(rendered)); @@ -147,7 +148,7 @@ describe('policy table', () => { expect(buttons.at(0).text()).toBe('View indices linked to policy'); expect(buttons.at(1).text()).toBe('Add policy to index template'); expect(buttons.at(2).text()).toBe('Delete policy'); - expect(buttons.at(2).getDOMNode().disabled).toBeTruthy(); + expect((buttons.at(2).getDOMNode() as HTMLButtonElement).disabled).toBeTruthy(); }); test('should have proper actions in context menu when there are not linked indices', () => { const rendered = openContextMenu(1); @@ -155,7 +156,7 @@ describe('policy table', () => { expect(buttons.length).toBe(2); expect(buttons.at(0).text()).toBe('Add policy to index template'); expect(buttons.at(1).text()).toBe('Delete policy'); - expect(buttons.at(1).getDOMNode().disabled).toBeFalsy(); + expect((buttons.at(1).getDOMNode() as HTMLButtonElement).disabled).toBeFalsy(); }); test('confirmation modal should show when delete button is pressed', () => { const rendered = openContextMenu(1); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/index.tsx b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx index 31a9abdc7145e..d7812f186a03f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/index.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx @@ -6,12 +6,10 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { Provider } from 'react-redux'; import { I18nStart, ScopedHistory, ApplicationStart } from 'kibana/public'; import { UnmountCallback } from 'src/core/public'; import { App } from './app'; -import { indexLifecycleManagementStore } from './store'; export const renderApp = ( element: Element, @@ -22,9 +20,7 @@ export const renderApp = ( ): UnmountCallback => { render( - - - + , element ); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx index 359134e015f7f..f4697693b86c6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { EuiButton, EuiCallOut, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useLoadPoliciesList } from '../../services/api'; @@ -50,25 +50,29 @@ export const EditPolicy: React.FunctionComponent +

+ +

} - color="danger" - > -

- {message} ({statusCode}) -

- - - - + body={ +

+ {message} ({statusCode}) +

+ } + actions={ + + + + } + /> ); } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx similarity index 89% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx index 47134ad097720..90ac3c03856de 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/add_policy_to_template_confirm_modal.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx @@ -20,21 +20,35 @@ import { EuiText, } from '@elastic/eui'; -import { toasts } from '../../../../services/notification'; -import { addLifecyclePolicyToTemplate, loadIndexTemplates } from '../../../../services/api'; -import { showApiError } from '../../../../services/api_errors'; -import { LearnMoreLink } from '../../../edit_policy/components'; +import { LearnMoreLink } from '../../edit_policy/components'; +import { PolicyFromES } from '../../../services/policies/types'; +import { addLifecyclePolicyToTemplate, loadIndexTemplates } from '../../../services/api'; +import { toasts } from '../../../services/notification'; +import { showApiError } from '../../../services/api_errors'; -export class AddPolicyToTemplateConfirmModal extends Component { - state = { - templates: [], - }; +interface Props { + policy: PolicyFromES; + onCancel: () => void; +} +interface State { + templates: Array<{ name: string }>; + templateName?: string; + aliasName?: string; + templateError?: string; +} +export class AddPolicyToTemplateConfirmModal extends Component { + constructor(props: Props) { + super(props); + this.state = { + templates: [], + }; + } async componentDidMount() { const templates = await loadIndexTemplates(); this.setState({ templates }); } addPolicyToTemplate = async () => { - const { policy, callback, onCancel } = this.props; + const { policy, onCancel } = this.props; const { templateName, aliasName } = this.state; const policyName = policy.name; if (!templateName) { @@ -71,9 +85,6 @@ export class AddPolicyToTemplateConfirmModal extends Component { ); showApiError(e, title); } - if (callback) { - callback(); - } }; renderTemplateHasPolicyWarning() { const selectedTemplate = this.getSelectedTemplate(); @@ -144,7 +155,7 @@ export class AddPolicyToTemplateConfirmModal extends Component { options={options} value={templateName} onChange={(e) => { - this.setState({ templateError: null, templateName: e.target.value }); + this.setState({ templateError: undefined, templateName: e.target.value }); }} /> @@ -204,7 +215,6 @@ export class AddPolicyToTemplateConfirmModal extends Component { defaultMessage: 'Add policy', } )} - onClose={onCancel} >

diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/confirm_delete.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx similarity index 85% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/confirm_delete.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx index 0ecc9cc13ecd0..8d8e5ac2a2472 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/confirm_delete.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx @@ -9,11 +9,17 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; -import { toasts } from '../../../../services/notification'; -import { deletePolicy } from '../../../../services/api'; -import { showApiError } from '../../../../services/api_errors'; +import { PolicyFromES } from '../../../services/policies/types'; +import { toasts } from '../../../services/notification'; +import { showApiError } from '../../../services/api_errors'; +import { deletePolicy } from '../../../services/api'; -export class ConfirmDelete extends Component { +interface Props { + policyToDelete: PolicyFromES; + callback: () => void; + onCancel: () => void; +} +export class ConfirmDelete extends Component { deletePolicy = async () => { const { policyToDelete, callback } = this.props; const policyName = policyToDelete.name; @@ -61,7 +67,6 @@ export class ConfirmDelete extends Component { /> } buttonColor="danger" - onClose={onCancel} >

( -
- -
-); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/index.js deleted file mode 100644 index 63e8cdebd9771..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/no_match/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export { NoMatch } from './components/no_match'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.container.js deleted file mode 100644 index 8bd78774d2d55..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.container.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { connect } from 'react-redux'; - -import { - fetchPolicies, - policyFilterChanged, - policyPageChanged, - policyPageSizeChanged, - policySortChanged, -} from '../../../../store/actions'; - -import { - getPolicies, - getPageOfPolicies, - getPolicyPager, - getPolicyFilter, - getPolicySort, - isPolicyListLoaded, -} from '../../../../store/selectors'; - -import { PolicyTable as PresentationComponent } from './policy_table'; - -const mapDispatchToProps = (dispatch) => { - return { - policyFilterChanged: (filter) => { - dispatch(policyFilterChanged({ filter })); - }, - policyPageChanged: (pageNumber) => { - dispatch(policyPageChanged({ pageNumber })); - }, - policyPageSizeChanged: (pageSize) => { - dispatch(policyPageSizeChanged({ pageSize })); - }, - policySortChanged: (sortField, isSortAscending) => { - dispatch(policySortChanged({ sortField, isSortAscending })); - }, - fetchPolicies: (withIndices) => { - dispatch(fetchPolicies(withIndices)); - }, - }; -}; - -export const PolicyTable = connect( - (state) => ({ - totalNumberOfPolicies: getPolicies(state).length, - policies: getPageOfPolicies(state), - pager: getPolicyPager(state), - filter: getPolicyFilter(state), - sortField: getPolicySort(state).sortField, - isSortAscending: getPolicySort(state).isSortAscending, - policyListLoaded: isPolicyListLoaded(state), - }), - mapDispatchToProps -)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js deleted file mode 100644 index ec1cdb987f4b3..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js +++ /dev/null @@ -1,530 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Component, Fragment } from 'react'; -import moment from 'moment-timezone'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - EuiButton, - EuiButtonEmpty, - EuiLink, - EuiEmptyPrompt, - EuiFieldSearch, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPopover, - EuiContextMenu, - EuiSpacer, - EuiTable, - EuiTableBody, - EuiTableHeader, - EuiTableHeaderCell, - EuiTablePagination, - EuiTableRow, - EuiTableRowCell, - EuiTitle, - EuiText, - EuiPageBody, - EuiPageContent, - EuiScreenReaderOnly, -} from '@elastic/eui'; - -import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; -import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; -import { getIndexListUri } from '../../../../../../../index_management/public'; -import { UIM_EDIT_CLICK } from '../../../../constants/ui_metric'; -import { getPolicyPath } from '../../../../services/navigation'; -import { flattenPanelTree } from '../../../../services/flatten_panel_tree'; -import { trackUiMetric } from '../../../../services/ui_metric'; -import { NoMatch } from '../no_match'; -import { ConfirmDelete } from './confirm_delete'; -import { AddPolicyToTemplateConfirmModal } from './add_policy_to_template_confirm_modal'; - -const COLUMNS = { - name: { - label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.nameHeader', { - defaultMessage: 'Name', - }), - width: 200, - }, - linkedIndices: { - label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.linkedIndicesHeader', { - defaultMessage: 'Linked indices', - }), - width: 120, - }, - version: { - label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.versionHeader', { - defaultMessage: 'Version', - }), - width: 120, - }, - modified_date: { - label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.modifiedDateHeader', { - defaultMessage: 'Modified date', - }), - width: 200, - }, -}; - -export class PolicyTable extends Component { - constructor(props) { - super(props); - - this.state = { - selectedPoliciesMap: {}, - renderConfirmModal: null, - }; - } - componentDidMount() { - this.props.fetchPolicies(true); - } - renderEmpty() { - return ( - - -

- } - body={ - -

- -

-
- } - actions={this.renderCreatePolicyButton()} - /> - ); - } - renderDeleteConfirmModal = () => { - const { policyToDelete } = this.state; - if (!policyToDelete) { - return null; - } - return ( - this.setState({ renderConfirmModal: null, policyToDelete: null })} - /> - ); - }; - renderAddPolicyToTemplateConfirmModal = () => { - const { policyToAddToTemplate } = this.state; - if (!policyToAddToTemplate) { - return null; - } - return ( - this.setState({ renderConfirmModal: null, policyToAddToTemplate: null })} - /> - ); - }; - handleDelete = () => { - this.props.fetchPolicies(true); - this.setState({ renderDeleteConfirmModal: null, policyToDelete: null }); - }; - onSort = (column) => { - const { sortField, isSortAscending, policySortChanged } = this.props; - const newIsSortAscending = sortField === column ? !isSortAscending : true; - policySortChanged(column, newIsSortAscending); - }; - - buildHeader() { - const { sortField, isSortAscending } = this.props; - const headers = Object.entries(COLUMNS).map(([fieldName, { label, width }]) => { - const isSorted = sortField === fieldName; - return ( - this.onSort(fieldName)} - isSorted={isSorted} - isSortAscending={isSortAscending} - data-test-subj={`policyTableHeaderCell-${fieldName}`} - className={'policyTable__header--' + fieldName} - width={width} - > - {label} - - ); - }); - headers.push( - - ); - return headers; - } - - buildRowCell(fieldName, value) { - if (fieldName === 'name') { - return ( - /* eslint-disable-next-line @elastic/eui/href-or-on-click */ - - trackUiMetric('click', UIM_EDIT_CLICK) - )} - > - {value} - - ); - } else if (fieldName === 'linkedIndices') { - return ( - - {value ? value.length : '0'} - - ); - } else if (fieldName === 'modified_date' && value) { - return moment(value).format('YYYY-MM-DD HH:mm:ss'); - } - return value; - } - renderCreatePolicyButton() { - return ( - - - - ); - } - renderConfirmModal() { - const { renderConfirmModal } = this.state; - if (renderConfirmModal) { - return renderConfirmModal(); - } else { - return null; - } - } - buildActionPanelTree(policy) { - const hasLinkedIndices = Boolean(policy.linkedIndices && policy.linkedIndices.length); - - const viewIndicesLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.viewIndicesButtonText', - { - defaultMessage: 'View indices linked to policy', - } - ); - const addPolicyToTemplateLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.addPolicyToTemplateButtonText', - { - defaultMessage: 'Add policy to index template', - } - ); - const deletePolicyLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonText', - { - defaultMessage: 'Delete policy', - } - ); - const deletePolicyTooltip = hasLinkedIndices - ? i18n.translate('xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonDisabledTooltip', { - defaultMessage: 'You cannot delete a policy that is being used by an index', - }) - : null; - const items = []; - if (hasLinkedIndices) { - items.push({ - name: viewIndicesLabel, - icon: 'list', - onClick: () => { - this.props.navigateToApp('management', { - path: `/data/index_management${getIndexListUri(`ilm.policy:${policy.name}`, true)}`, - }); - }, - }); - } - items.push({ - name: addPolicyToTemplateLabel, - icon: 'plusInCircle', - onClick: () => - this.setState({ - renderConfirmModal: this.renderAddPolicyToTemplateConfirmModal, - policyToAddToTemplate: policy, - }), - }); - items.push({ - name: deletePolicyLabel, - disabled: hasLinkedIndices, - icon: 'trash', - toolTipContent: deletePolicyTooltip, - onClick: () => - this.setState({ - renderConfirmModal: this.renderDeleteConfirmModal, - policyToDelete: policy, - }), - }); - const panelTree = { - id: 0, - title: i18n.translate('xpack.indexLifecycleMgmt.policyTable.policyActionsMenu.panelTitle', { - defaultMessage: 'Policy options', - }), - items, - }; - return flattenPanelTree(panelTree); - } - togglePolicyPopover = (policy) => { - if (this.isPolicyPopoverOpen(policy)) { - this.closePolicyPopover(policy); - } else { - this.openPolicyPopover(policy); - } - }; - isPolicyPopoverOpen = (policy) => { - return this.state.policyPopover === policy.name; - }; - closePolicyPopover = (policy) => { - if (this.isPolicyPopoverOpen(policy)) { - this.setState({ policyPopover: null }); - } - }; - openPolicyPopover = (policy) => { - this.setState({ policyPopover: policy.name }); - }; - buildRowCells(policy) { - const { name } = policy; - const cells = Object.entries(COLUMNS).map(([fieldName, { width }]) => { - const value = policy[fieldName]; - - if (fieldName === 'name') { - return ( - -
- {this.buildRowCell(fieldName, value)} -
- - ); - } - - return ( - - {this.buildRowCell(fieldName, value)} - - ); - }); - const button = ( - this.togglePolicyPopover(policy)} - color="primary" - > - {i18n.translate('xpack.indexLifecycleMgmt.policyTable.actionsButtonText', { - defaultMessage: 'Actions', - })} - - ); - cells.push( - - this.closePolicyPopover(policy)} - panelPaddingSize="none" - withTitle - anchorPosition="rightUp" - repositionOnScroll - > - - - - ); - return cells; - } - - buildRows() { - const { policies = [] } = this.props; - return policies.map((policy) => { - const { name } = policy; - return {this.buildRowCells(policy)}; - }); - } - - renderPager() { - const { pager, policyPageChanged, policyPageSizeChanged } = this.props; - return ( - - ); - } - - onItemSelectionChanged = (selectedPolicies) => { - this.setState({ selectedPolicies }); - }; - - render() { - const { - totalNumberOfPolicies, - policyFilterChanged, - filter, - policyListLoaded, - policies, - } = this.props; - const { selectedPoliciesMap } = this.state; - const numSelected = Object.keys(selectedPoliciesMap).length; - let content; - let tableContent; - if (totalNumberOfPolicies || !policyListLoaded) { - if (!policyListLoaded) { - tableContent = ; - } else if (totalNumberOfPolicies > 0) { - tableContent = ( - - - - - - - {this.buildHeader()} - {this.buildRows()} - - ); - } else { - tableContent = ; - } - content = ( - - - {numSelected > 0 ? ( - - this.setState({ showDeleteConfirmation: true })} - > - - - - ) : null} - - { - policyFilterChanged(event.target.value); - }} - data-test-subj="policyTableFilterInput" - placeholder={i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.systempoliciesSearchInputPlaceholder', - { - defaultMessage: 'Search', - } - )} - aria-label={i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.systempoliciesSearchInputAriaLabel', - { - defaultMessage: 'Search policies', - } - )} - /> - - - - {tableContent} - - ); - } else { - content = this.renderEmpty(); - } - - return ( - - -
- {this.renderConfirmModal()} - {totalNumberOfPolicies || !policyListLoaded ? ( - - - - -

- -

-
-
- {totalNumberOfPolicies ? ( - {this.renderCreatePolicyButton()} - ) : null} -
- - -

- -

-
-
- ) : null} - - {content} - - {totalNumberOfPolicies && totalNumberOfPolicies > 10 ? this.renderPager() : null} -
-
-
- ); - } -} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx new file mode 100644 index 0000000000000..da36ff4df98f5 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx @@ -0,0 +1,376 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ReactElement, useState, Fragment, ReactNode } from 'react'; +import { + EuiButtonEmpty, + EuiContextMenu, + EuiLink, + EuiPopover, + EuiScreenReaderOnly, + EuiSpacer, + EuiTable, + EuiTableBody, + EuiTableHeader, + EuiTableHeaderCell, + EuiTablePagination, + EuiTableRow, + EuiTableRowCell, + EuiText, + Pager, + EuiContextMenuPanelDescriptor, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; + +import moment from 'moment'; +import { ApplicationStart } from 'kibana/public'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { RouteComponentProps } from 'react-router-dom'; +import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; +import { getIndexListUri } from '../../../../../../index_management/public'; +import { PolicyFromES } from '../../../services/policies/types'; +import { getPolicyPath } from '../../../services/navigation'; +import { sortTable } from '../../../services'; +import { trackUiMetric } from '../../../services/ui_metric'; + +import { UIM_EDIT_CLICK } from '../../../constants'; +import { AddPolicyToTemplateConfirmModal } from './add_policy_to_template_confirm_modal'; +import { ConfirmDelete } from './confirm_delete'; + +type PolicyProperty = Extract< + keyof PolicyFromES, + 'version' | 'name' | 'linkedIndices' | 'modified_date' +>; +const COLUMNS: Array<[PolicyProperty, { label: string; width: number }]> = [ + [ + 'name', + { + label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.nameHeader', { + defaultMessage: 'Name', + }), + width: 200, + }, + ], + [ + 'linkedIndices', + { + label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.linkedIndicesHeader', { + defaultMessage: 'Linked indices', + }), + width: 120, + }, + ], + [ + 'version', + { + label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.versionHeader', { + defaultMessage: 'Version', + }), + width: 120, + }, + ], + [ + 'modified_date', + { + label: i18n.translate('xpack.indexLifecycleMgmt.policyTable.headers.modifiedDateHeader', { + defaultMessage: 'Modified date', + }), + width: 200, + }, + ], +]; + +interface Props { + policies: PolicyFromES[]; + totalNumber: number; + navigateToApp: ApplicationStart['navigateToApp']; + setConfirmModal: (modal: ReactElement | null) => void; + handleDelete: () => void; + history: RouteComponentProps['history']; +} +export const TableContent: React.FunctionComponent = ({ + policies, + totalNumber, + navigateToApp, + setConfirmModal, + handleDelete, + history, +}) => { + const [popoverPolicy, setPopoverPolicy] = useState(); + const [sort, setSort] = useState<{ sortField: PolicyProperty; isSortAscending: boolean }>({ + sortField: 'name', + isSortAscending: true, + }); + const [pageSize, setPageSize] = useState(10); + const [currentPage, setCurrentPage] = useState(0); + + let sortedPolicies = sortTable(policies, sort.sortField, sort.isSortAscending); + const pager = new Pager(totalNumber, pageSize, currentPage); + const { firstItemIndex, lastItemIndex } = pager; + sortedPolicies = sortedPolicies.slice(firstItemIndex, lastItemIndex + 1); + + const isPolicyPopoverOpen = (policyName: string): boolean => popoverPolicy === policyName; + const closePolicyPopover = (): void => { + setPopoverPolicy(''); + }; + const openPolicyPopover = (policyName: string): void => { + setPopoverPolicy(policyName); + }; + const togglePolicyPopover = (policyName: string): void => { + if (isPolicyPopoverOpen(policyName)) { + closePolicyPopover(); + } else { + openPolicyPopover(policyName); + } + }; + + const onSort = (column: PolicyProperty) => { + const newIsSortAscending = sort.sortField === column ? !sort.isSortAscending : true; + setSort({ sortField: column, isSortAscending: newIsSortAscending }); + }; + + const headers = []; + COLUMNS.forEach(([fieldName, { label, width }]) => { + const isSorted = sort.sortField === fieldName; + headers.push( + onSort(fieldName)} + isSorted={isSorted} + isSortAscending={sort.isSortAscending} + data-test-subj={`policyTableHeaderCell-${fieldName}`} + className={'policyTable__header--' + fieldName} + width={width} + > + {label} + + ); + }); + headers.push( + + ); + + const buildActionPanelTree = (policy: PolicyFromES): EuiContextMenuPanelDescriptor[] => { + const hasLinkedIndices = Boolean(policy.linkedIndices && policy.linkedIndices.length); + + const viewIndicesLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.viewIndicesButtonText', + { + defaultMessage: 'View indices linked to policy', + } + ); + const addPolicyToTemplateLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.addPolicyToTemplateButtonText', + { + defaultMessage: 'Add policy to index template', + } + ); + const deletePolicyLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonText', + { + defaultMessage: 'Delete policy', + } + ); + const deletePolicyTooltip = hasLinkedIndices + ? i18n.translate('xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonDisabledTooltip', { + defaultMessage: 'You cannot delete a policy that is being used by an index', + }) + : null; + const items = []; + if (hasLinkedIndices) { + items.push({ + name: viewIndicesLabel, + icon: 'list', + onClick: () => { + navigateToApp('management', { + path: `/data/index_management${getIndexListUri(`ilm.policy:${policy.name}`, true)}`, + }); + }, + }); + } + items.push({ + name: addPolicyToTemplateLabel, + icon: 'plusInCircle', + onClick: () => { + setConfirmModal(renderAddPolicyToTemplateConfirmModal(policy)); + }, + }); + items.push({ + name: deletePolicyLabel, + disabled: hasLinkedIndices, + icon: 'trash', + toolTipContent: deletePolicyTooltip, + onClick: () => { + setConfirmModal(renderDeleteConfirmModal(policy)); + }, + }); + const panelTree = { + id: 0, + title: i18n.translate('xpack.indexLifecycleMgmt.policyTable.policyActionsMenu.panelTitle', { + defaultMessage: 'Policy options', + }), + items, + }; + return [panelTree]; + }; + + const renderRowCell = (fieldName: string, value: string | number | string[]): ReactNode => { + if (fieldName === 'name') { + return ( + + trackUiMetric(METRIC_TYPE.CLICK, UIM_EDIT_CLICK) + )} + > + {value} + + ); + } else if (fieldName === 'linkedIndices') { + return ( + + {value ? (value as string[]).length : '0'} + + ); + } else if (fieldName === 'modified_date' && value) { + return moment(value).format('YYYY-MM-DD HH:mm:ss'); + } + return value; + }; + + const renderRowCells = (policy: PolicyFromES): ReactElement[] => { + const { name } = policy; + const cells = []; + COLUMNS.forEach(([fieldName, { width }]) => { + const value: any = policy[fieldName]; + + if (fieldName === 'name') { + cells.push( + +
+ {renderRowCell(fieldName, value)} +
+ + ); + } else { + cells.push( + + {renderRowCell(fieldName, value)} + + ); + } + }); + const button = ( + togglePolicyPopover(policy.name)} + color="primary" + > + {i18n.translate('xpack.indexLifecycleMgmt.policyTable.actionsButtonText', { + defaultMessage: 'Actions', + })} + + ); + cells.push( + + + + + + ); + return cells; + }; + + const rows = sortedPolicies.map((policy) => { + const { name } = policy; + return {renderRowCells(policy)}; + }); + + const renderAddPolicyToTemplateConfirmModal = (policy: PolicyFromES): ReactElement => { + return ( + setConfirmModal(null)} /> + ); + }; + + const renderDeleteConfirmModal = (policy: PolicyFromES): ReactElement => { + return ( + { + setConfirmModal(null); + }} + /> + ); + }; + + const renderPager = (): ReactNode => { + return ( + + ); + }; + + return ( + + + + + + + + {headers} + {rows} + + + {policies.length > 10 ? renderPager() : null} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.d.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.d.ts deleted file mode 100644 index fa1b1129523eb..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export declare const PolicyTable: any; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.js deleted file mode 100644 index c4aa32f1f7dc2..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export { PolicyTable } from './components/policy_table'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/policy_table.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/policy_table.container.tsx new file mode 100644 index 0000000000000..f6471ff2da4d3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/policy_table.container.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ApplicationStart } from 'kibana/public'; +import { RouteComponentProps } from 'react-router-dom'; +import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { PolicyTable as PresentationComponent } from './policy_table'; +import { useLoadPoliciesList } from '../../services/api'; + +interface Props { + navigateToApp: ApplicationStart['navigateToApp']; +} + +export const PolicyTable: React.FunctionComponent = ({ + navigateToApp, + history, +}) => { + const { data: policies, isLoading, error, sendRequest } = useLoadPoliciesList(true); + + if (isLoading) { + return ( + } + body={ + + } + /> + ); + } + if (error) { + const { statusCode, message } = error ? error : { statusCode: '', message: '' }; + return ( + + +

+ } + body={ +

+ {message} ({statusCode}) +

+ } + actions={ + + + + } + /> + ); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/policy_table.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/policy_table.tsx new file mode 100644 index 0000000000000..048ab922a65b5 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/policy_table.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, ReactElement, ReactNode, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiButton, + EuiEmptyPrompt, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiText, + EuiPageBody, + EuiPageContent, +} from '@elastic/eui'; +import { ApplicationStart } from 'kibana/public'; +import { RouteComponentProps } from 'react-router-dom'; +import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; +import { PolicyFromES } from '../../services/policies/types'; +import { filterItems } from '../../services'; +import { TableContent } from './components/table_content'; + +interface Props { + policies: PolicyFromES[]; + history: RouteComponentProps['history']; + navigateToApp: ApplicationStart['navigateToApp']; + updatePolicies: () => void; +} + +export const PolicyTable: React.FunctionComponent = ({ + policies, + history, + navigateToApp, + updatePolicies, +}) => { + const [confirmModal, setConfirmModal] = useState(); + const [filter, setFilter] = useState(''); + + const createPolicyButton = ( + + + + ); + + let content: ReactElement; + + if (policies.length > 0) { + const filteredPolicies = filterItems('name', filter, policies); + let tableContent: ReactElement; + if (filteredPolicies.length > 0) { + tableContent = ( + { + updatePolicies(); + setConfirmModal(null); + }} + history={history} + /> + ); + } else { + tableContent = ( + + ); + } + + content = ( + + + + { + setFilter(event.target.value); + }} + data-test-subj="policyTableFilterInput" + placeholder={i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.systempoliciesSearchInputPlaceholder', + { + defaultMessage: 'Search', + } + )} + aria-label={i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.systempoliciesSearchInputAriaLabel', + { + defaultMessage: 'Search policies', + } + )} + /> + + + + {tableContent} + + ); + } else { + return ( + + + + + + } + body={ + +

+ +

+
+ } + actions={createPolicyButton} + /> +
+
+ ); + } + + return ( + + + {confirmModal} + + + + +

+ +

+
+
+ {createPolicyButton} +
+ + +

+ +

+
+ + + {content} +
+
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.js b/x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.js deleted file mode 100644 index dcc9036463b82..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -export const filterItems = (fields, filter = '', items = []) => { - const lowerFilter = filter.toLowerCase(); - return items.filter((item) => { - const actualFields = fields || Object.keys(item); - const indexOfMatch = actualFields.findIndex((field) => { - const normalizedField = String(item[field]).toLowerCase(); - return normalizedField.includes(lowerFilter); - }); - return indexOfMatch !== -1; - }); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.ts new file mode 100644 index 0000000000000..237ce567707bb --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/filter_items.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const filterItems = (field: keyof T, filter: string, items: T[] = []): T[] => { + const lowerFilter = filter.toLowerCase(); + return items.filter((item: T) => { + const normalizedValue = String(item[field]).toLowerCase(); + return normalizedValue.includes(lowerFilter); + }); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/flatten_panel_tree.js b/x-pack/plugins/index_lifecycle_management/public/application/services/flatten_panel_tree.js deleted file mode 100644 index 2bb3903a6ef45..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/flatten_panel_tree.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export const flattenPanelTree = (tree, array = []) => { - array.push(tree); - - if (tree.items) { - tree.items.forEach((item) => { - if (item.panel) { - flattenPanelTree(item.panel, array); - item.panel = item.panel.id; - } - }); - } - - return array; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/index.js b/x-pack/plugins/index_lifecycle_management/public/application/services/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/services/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts index 3d4c73cf4a82c..c191f82cf05cc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts @@ -22,6 +22,7 @@ export interface PolicyFromES { name: string; policy: SerializedPolicy; version: number; + linkedIndices?: string[]; } export interface SerializedPhase { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.js b/x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.js deleted file mode 100644 index 1b1446bb735c1..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { sortBy } from 'lodash'; - -const stringSort = (fieldName) => (item) => item[fieldName]; -const arraySort = (fieldName) => (item) => (item[fieldName] || []).length; - -const sorters = { - version: stringSort('version'), - name: stringSort('name'), - linkedIndices: arraySort('linkedIndices'), - modified_date: stringSort('modified_date'), -}; -export const sortTable = (array = [], sortField, isSortAscending) => { - const sorted = sortBy(array, sorters[sortField]); - return isSortAscending ? sorted : sorted.reverse(); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.ts new file mode 100644 index 0000000000000..6b41d671b673f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/sort_table.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sortBy } from 'lodash'; +import { PolicyFromES } from './policies/types'; + +export const sortTable = ( + array: PolicyFromES[] = [], + sortField: Extract, + isSortAscending: boolean +): PolicyFromES[] => { + let sorter; + if (sortField === 'linkedIndices') { + sorter = (item: PolicyFromES) => (item[sortField] || []).length; + } else { + sorter = (item: PolicyFromES) => item[sortField]; + } + const sorted = sortBy(array, sorter); + return isSortAscending ? sorted : sorted.reverse(); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js deleted file mode 100644 index fef79c7782bb0..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export * from './policies'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js deleted file mode 100644 index d47136679604f..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { createAction } from 'redux-actions'; - -import { showApiError } from '../../services/api_errors'; -import { loadPolicies } from '../../services/api'; - -export const fetchedPolicies = createAction('FETCHED_POLICIES'); -export const setSelectedPolicy = createAction('SET_SELECTED_POLICY'); -export const unsetSelectedPolicy = createAction('UNSET_SELECTED_POLICY'); -export const setSelectedPolicyName = createAction('SET_SELECTED_POLICY_NAME'); -export const setSaveAsNewPolicy = createAction('SET_SAVE_AS_NEW_POLICY'); -export const policySortChanged = createAction('POLICY_SORT_CHANGED'); -export const policyPageSizeChanged = createAction('POLICY_PAGE_SIZE_CHANGED'); -export const policyPageChanged = createAction('POLICY_PAGE_CHANGED'); -export const policySortDirectionChanged = createAction('POLICY_SORT_DIRECTION_CHANGED'); -export const policyFilterChanged = createAction('POLICY_FILTER_CHANGED'); - -export const fetchPolicies = (withIndices, callback) => async (dispatch) => { - let policies; - try { - policies = await loadPolicies(withIndices); - } catch (err) { - const title = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.loadPolicyErrorMessage', { - defaultMessage: 'Error loading policies', - }); - showApiError(err, title); - return false; - } - - dispatch(fetchedPolicies(policies)); - if (policies.length === 0) { - dispatch(setSelectedPolicy()); - } - callback && callback(); - return policies; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/index.d.ts b/x-pack/plugins/index_lifecycle_management/public/application/store/index.d.ts deleted file mode 100644 index 8617a7045a5c3..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/index.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export declare const indexLifecycleManagementStore: any; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/index.js deleted file mode 100644 index 808eb489bf913..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export { indexLifecycleManagementStore } from './store'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js deleted file mode 100644 index 7fe7134f5f5db..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { combineReducers } from 'redux'; -import { policies } from './policies'; - -export const indexLifecycleManagement = combineReducers({ - policies, -}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js deleted file mode 100644 index ca9d59e295a29..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { handleActions } from 'redux-actions'; -import { - fetchedPolicies, - policyFilterChanged, - policyPageChanged, - policyPageSizeChanged, - policySortChanged, -} from '../actions'; - -const defaultState = { - isLoading: false, - isLoaded: false, - originalPolicyName: undefined, - selectedPolicySet: false, - policies: [], - sort: { - sortField: 'name', - isSortAscending: true, - }, - pageSize: 10, - currentPage: 0, - filter: '', -}; - -export const policies = handleActions( - { - [fetchedPolicies](state, { payload: policies }) { - return { - ...state, - isLoading: false, - isLoaded: true, - policies, - }; - }, - [policyFilterChanged](state, action) { - const { filter } = action.payload; - return { - ...state, - filter, - currentPage: 0, - }; - }, - [policySortChanged](state, action) { - const { sortField, isSortAscending } = action.payload; - - return { - ...state, - sort: { - sortField, - isSortAscending, - }, - }; - }, - [policyPageChanged](state, action) { - const { pageNumber } = action.payload; - return { - ...state, - currentPage: pageNumber, - }; - }, - [policyPageSizeChanged](state, action) { - const { pageSize } = action.payload; - return { - ...state, - pageSize, - }; - }, - }, - defaultState -); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js deleted file mode 100644 index fef79c7782bb0..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export * from './policies'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js deleted file mode 100644 index e1c89314a2ec5..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { createSelector } from 'reselect'; -import { Pager } from '@elastic/eui'; - -import { filterItems, sortTable } from '../../services'; - -export const getPolicies = (state) => state.policies.policies; -export const getPolicyFilter = (state) => state.policies.filter; -export const getPolicySort = (state) => state.policies.sort; -export const getPolicyCurrentPage = (state) => state.policies.currentPage; -export const getPolicyPageSize = (state) => state.policies.pageSize; -export const isPolicyListLoaded = (state) => state.policies.isLoaded; - -const getFilteredPolicies = createSelector(getPolicies, getPolicyFilter, (policies, filter) => { - return filterItems(['name'], filter, policies); -}); -export const getTotalPolicies = createSelector(getFilteredPolicies, (filteredPolicies) => { - return filteredPolicies.length; -}); -export const getPolicyPager = createSelector( - getPolicyCurrentPage, - getPolicyPageSize, - getTotalPolicies, - (currentPage, pageSize, totalPolicies) => { - return new Pager(totalPolicies, pageSize, currentPage); - } -); -export const getPageOfPolicies = createSelector( - getFilteredPolicies, - getPolicySort, - getPolicyPager, - (filteredPolicies, sort, pager) => { - const sortedPolicies = sortTable(filteredPolicies, sort.sortField, sort.isSortAscending); - const { firstItemIndex, lastItemIndex } = pager; - return sortedPolicies.slice(firstItemIndex, lastItemIndex + 1); - } -); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/store.js b/x-pack/plugins/index_lifecycle_management/public/application/store/store.js deleted file mode 100644 index c5774a3da238a..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/store.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { createStore, applyMiddleware, compose } from 'redux'; -import thunk from 'redux-thunk'; - -import { indexLifecycleManagement } from './reducers/'; - -export const indexLifecycleManagementStore = (initialState = {}) => { - const enhancers = [applyMiddleware(thunk)]; - - window.__REDUX_DEVTOOLS_EXTENSION__ && enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__()); - return createStore(indexLifecycleManagement, initialState, compose(...enhancers)); -}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index df78975d21b07..093ef4d6f1873 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8100,7 +8100,6 @@ "xpack.indexLifecycleMgmt.editPolicy.learnAboutShardAllocationLink": "シャード割り当ての詳細をご覧ください", "xpack.indexLifecycleMgmt.editPolicy.learnAboutTimingText": "タイミングの詳細をご覧ください", "xpack.indexLifecycleMgmt.editPolicy.lifecyclePolicyDescriptionText": "インデックスへのアクティブな書き込みから削除までの、インデックスライフサイクルの 4 つのフェーズを自動化するには、インデックスポリシーを使用します。", - "xpack.indexLifecycleMgmt.editPolicy.loadPolicyErrorMessage": "ポリシーの読み込み中にエラーが発生しました", "xpack.indexLifecycleMgmt.editPolicy.maximumAgeMissingError": "最高年齢が必要です。", "xpack.indexLifecycleMgmt.editPolicy.maximumDocumentsMissingError": "最高ドキュメント数が必要です。", "xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError": "最大インデックスサイズが必要です。", @@ -8239,7 +8238,6 @@ "xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.title": "インデックステンプレートにポリシー「{name}」 を追加", "xpack.indexLifecycleMgmt.policyTable.addPolicyToTemplateButtonText": "インデックステンプレートにポリシーを追加", "xpack.indexLifecycleMgmt.policyTable.captionText": "以下は {total} 列中 {count, plural, one {# 列} other {# 列}} を含むインデックスライフサイクルポリシー表です。", - "xpack.indexLifecycleMgmt.policyTable.deletedPoliciesText": "{numSelected} 件の{numSelected, plural, one {ポリシー} other {ポリシー}}が削除されました", "xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonDisabledTooltip": "インデックスが使用中のポリシーは削除できません", "xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonText": "ポリシーを削除", "xpack.indexLifecycleMgmt.policyTable.emptyPrompt.createButtonLabel": "ポリシーを作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cfee565a1da93..ea04b1e14d959 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8103,7 +8103,6 @@ "xpack.indexLifecycleMgmt.editPolicy.learnAboutShardAllocationLink": "了解分片分配", "xpack.indexLifecycleMgmt.editPolicy.learnAboutTimingText": "了解计时", "xpack.indexLifecycleMgmt.editPolicy.lifecyclePolicyDescriptionText": "使用索引策略自动化索引生命周期的四个阶段,从频繁地写入到索引到删除索引。", - "xpack.indexLifecycleMgmt.editPolicy.loadPolicyErrorMessage": "加载策略时出错", "xpack.indexLifecycleMgmt.editPolicy.maximumAgeMissingError": "最大存在时间必填。", "xpack.indexLifecycleMgmt.editPolicy.maximumDocumentsMissingError": "最大文档数必填。", "xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError": "最大索引大小必填。", @@ -8242,7 +8241,6 @@ "xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.title": "将策略 “{name}” 添加到索引模板", "xpack.indexLifecycleMgmt.policyTable.addPolicyToTemplateButtonText": "将策略添加到索引模板", "xpack.indexLifecycleMgmt.policyTable.captionText": "下面是包含 {count, plural, one {# 行} other {# 行}}(共 {total} 行)的索引生命周期策略表。", - "xpack.indexLifecycleMgmt.policyTable.deletedPoliciesText": "已删除 {numSelected} 个{numSelected, plural, one {策略} other {策略}}", "xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonDisabledTooltip": "您无法删除索引正在使用的策略", "xpack.indexLifecycleMgmt.policyTable.deletePolicyButtonText": "删除策略", "xpack.indexLifecycleMgmt.policyTable.emptyPrompt.createButtonLabel": "创建策略", From febeb478757204da7b2088f91fa1c3be43e28af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Wed, 2 Sep 2020 11:30:17 +0200 Subject: [PATCH 21/53] [Security Solution] Refactor Network HTTP to use Search Strategy (#76243) --- .../security_solution/index.ts | 17 +- .../security_solution/network/http/index.ts | 58 ++++ .../security_solution/network/index.ts | 2 + .../network/containers/network_http/index.tsx | 286 ++++++++++-------- .../containers/network_http/translations.ts | 21 ++ .../ip_details/network_http_query_table.tsx | 70 ++--- .../pages/navigation/http_query_tab_body.tsx | 68 ++--- .../factory/network/http/helpers.ts | 34 +++ .../factory/network/http/index.ts | 59 ++++ .../network/http/query.http_network.dsl.ts | 116 +++++++ .../factory/network/index.ts | 2 + 11 files changed, 532 insertions(+), 201 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/network/http/index.ts create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_http/translations.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/query.http_network.dsl.ts diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index 175784bc5ade5..6905f2be38966 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -15,7 +15,13 @@ import { HostsRequestOptions, HostsStrategyResponse, } from './hosts'; -import { NetworkQueries, NetworkTlsStrategyResponse, NetworkTlsRequestOptions } from './network'; +import { + NetworkQueries, + NetworkTlsStrategyResponse, + NetworkTlsRequestOptions, + NetworkHttpStrategyResponse, + NetworkHttpRequestOptions, +} from './network'; export * from './hosts'; export * from './network'; @@ -116,6 +122,8 @@ export type StrategyResponseType = T extends HostsQ ? HostFirstLastSeenStrategyResponse : T extends NetworkQueries.tls ? NetworkTlsStrategyResponse + : T extends NetworkQueries.http + ? NetworkHttpStrategyResponse : never; export type StrategyRequestType = T extends HostsQueries.hosts @@ -126,4 +134,11 @@ export type StrategyRequestType = T extends HostsQu ? HostFirstLastSeenRequestOptions : T extends NetworkQueries.tls ? NetworkTlsRequestOptions + : T extends NetworkQueries.http + ? NetworkHttpRequestOptions : never; + +export interface GenericBuckets { + key: string; + doc_count: number; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/http/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/http/index.ts new file mode 100644 index 0000000000000..c42b3d2ab8db3 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/http/index.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { + Maybe, + CursorType, + Inspect, + RequestOptionsPaginated, + PageInfoPaginated, + GenericBuckets, +} from '../..'; + +export interface NetworkHttpRequestOptions extends RequestOptionsPaginated { + ip?: string; + defaultIndex: string[]; +} + +export interface NetworkHttpStrategyResponse extends IEsSearchResponse { + edges: NetworkHttpEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface NetworkHttpEdges { + node: NetworkHttpItem; + cursor: CursorType; +} + +export interface NetworkHttpItem { + _id?: Maybe; + domains: string[]; + lastHost?: Maybe; + lastSourceIp?: Maybe; + methods: string[]; + path?: Maybe; + requestCount?: Maybe; + statuses: string[]; +} + +export interface NetworkHttpBuckets { + key: string; + doc_count: number; + domains: { + buckets: GenericBuckets[]; + }; + methods: { + buckets: GenericBuckets[]; + }; + source: object; + status: { + buckets: GenericBuckets[]; + }; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts index 680a3697ef0bd..194bb5d057e3f 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts @@ -5,7 +5,9 @@ */ export * from './tls'; +export * from './http'; export enum NetworkQueries { + http = 'http', tls = 'tls', } diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx index 60845d452d69e..ae50f6919dce1 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx @@ -4,29 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOr } from 'lodash/fp'; -import React from 'react'; -import { Query } from 'react-apollo'; -import { connect } from 'react-redux'; -import { compose } from 'redux'; +import { noop } from 'lodash/fp'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; +import { ESTermQuery } from '../../../../common/typed_json'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; -import { - GetNetworkHttpQuery, - NetworkHttpEdges, - NetworkHttpSortField, - PageInfoPaginated, -} from '../../../graphql/types'; -import { inputsModel, inputsSelectors, State } from '../../../common/store'; -import { withKibana, WithKibanaProps } from '../../../common/lib/kibana'; +import { inputsModel, State } from '../../../common/store'; +import { useKibana } from '../../../common/lib/kibana'; +import { createFilter } from '../../../common/containers/helpers'; +import { NetworkHttpEdges, PageInfoPaginated } from '../../../graphql/types'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; -import { createFilter, getDefaultFetchPolicy } from '../../../common/containers/helpers'; -import { - QueryTemplatePaginated, - QueryTemplatePaginatedProps, -} from '../../../common/containers/query_template_paginated'; import { networkModel, networkSelectors } from '../../store'; -import { networkHttpQuery } from './index.gql_query'; +import { + NetworkQueries, + NetworkHttpRequestOptions, + NetworkHttpStrategyResponse, + SortField, +} from '../../../../common/search_strategy/security_solution'; +import { AbortError } from '../../../../../../../src/plugins/data/common'; +import * as i18n from './translations'; const ID = 'networkHttpQuery'; @@ -35,7 +33,6 @@ export interface NetworkHttpArgs { ip?: string; inspect: inputsModel.InspectQuery; isInspected: boolean; - loading: boolean; loadPage: (newActivePage: number) => void; networkHttp: NetworkHttpEdges[]; pageInfo: PageInfoPaginated; @@ -43,118 +40,161 @@ export interface NetworkHttpArgs { totalCount: number; } -export interface OwnProps extends QueryTemplatePaginatedProps { - children: (args: NetworkHttpArgs) => React.ReactNode; +interface UseNetworkHttp { + id?: string; ip?: string; type: networkModel.NetworkType; + filterQuery?: ESTermQuery | string; + endDate: string; + startDate: string; + skip: boolean; } -export interface NetworkHttpComponentReduxProps { - activePage: number; - isInspected: boolean; - limit: number; - sort: NetworkHttpSortField; -} +export const useNetworkHttp = ({ + endDate, + filterQuery, + id = ID, + ip, + skip, + startDate, + type, +}: UseNetworkHttp): [boolean, NetworkHttpArgs] => { + const getHttpSelector = networkSelectors.httpSelector(); + const { activePage, limit, sort } = useSelector( + (state: State) => getHttpSelector(state, type), + shallowEqual + ); + const { data, notifications, uiSettings } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const defaultIndex = uiSettings.get(DEFAULT_INDEX_KEY); + const [loading, setLoading] = useState(false); + + const [networkHttpRequest, setHostRequest] = useState({ + defaultIndex, + factoryQueryType: NetworkQueries.http, + filterQuery: createFilter(filterQuery), + ip, + pagination: generateTablePaginationOptions(activePage, limit), + sort: sort as SortField, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + }); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setHostRequest((prevRequest) => { + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); -type NetworkHttpProps = OwnProps & NetworkHttpComponentReduxProps & WithKibanaProps; + const [networkHttpResponse, setNetworkHttpResponse] = useState({ + networkHttp: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + totalCount: -1, + }); -class NetworkHttpComponentQuery extends QueryTemplatePaginated< - NetworkHttpProps, - GetNetworkHttpQuery.Query, - GetNetworkHttpQuery.Variables -> { - public render() { - const { - activePage, - children, - endDate, - filterQuery, - id = ID, - ip, - isInspected, - kibana, - limit, - skip, - sourceId, - sort, - startDate, - } = this.props; - const variables: GetNetworkHttpQuery.Variables = { - defaultIndex: kibana.services.uiSettings.get(DEFAULT_INDEX_KEY), - filterQuery: createFilter(filterQuery), - inspect: isInspected, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - sourceId, - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - }; - return ( - - fetchPolicy={getDefaultFetchPolicy()} - notifyOnNetworkStatusChange - query={networkHttpQuery} - skip={skip} - variables={variables} - > - {({ data, loading, fetchMore, networkStatus, refetch }) => { - const networkHttp = getOr([], `source.NetworkHttp.edges`, data); - this.setFetchMore(fetchMore); - this.setFetchMoreOptions((newActivePage: number) => ({ - variables: { - pagination: generateTablePaginationOptions(newActivePage, limit), + const networkHttpSearch = useCallback( + (request: NetworkHttpRequestOptions) => { + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + signal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (!response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + setNetworkHttpResponse((prevResponse) => ({ + ...prevResponse, + networkHttp: response.edges, + inspect: response.inspect ?? prevResponse.inspect, + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_NETWORK_HTTP); + searchSubscription$.unsubscribe(); + } }, - updateQuery: (prev, { fetchMoreResult }) => { - if (!fetchMoreResult) { - return prev; + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ + title: i18n.FAIL_NETWORK_HTTP, + text: msg.message, + }); } - return { - ...fetchMoreResult, - source: { - ...fetchMoreResult.source, - NetworkHttp: { - ...fetchMoreResult.source.NetworkHttp, - edges: [...fetchMoreResult.source.NetworkHttp.edges], - }, - }, - }; }, - })); - const isLoading = this.isItAValidLoading(loading, variables, networkStatus); - return children({ - id, - inspect: getOr(null, 'source.NetworkHttp.inspect', data), - isInspected, - loading: isLoading, - loadPage: this.wrappedLoadMore, - networkHttp, - pageInfo: getOr({}, 'source.NetworkHttp.pageInfo', data), - refetch: this.memoizedRefetchQuery(variables, limit, refetch), - totalCount: getOr(-1, 'source.NetworkHttp.totalCount', data), }); - }} - - ); - } -} + }; + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts] + ); -const makeMapStateToProps = () => { - const getHttpSelector = networkSelectors.httpSelector(); - const getQuery = inputsSelectors.globalQueryByIdSelector(); - return (state: State, { id = ID, type }: OwnProps) => { - const { isInspected } = getQuery(state, id); - return { - ...getHttpSelector(state, type), - isInspected, - }; - }; -}; + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...prevRequest, + defaultIndex, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + sort: sort as SortField, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }; + if (!skip && !deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [activePage, defaultIndex, endDate, filterQuery, limit, startDate, sort, skip]); + + useEffect(() => { + networkHttpSearch(networkHttpRequest); + }, [networkHttpRequest, networkHttpSearch]); -export const NetworkHttpQuery = compose>( - connect(makeMapStateToProps), - withKibana -)(NetworkHttpComponentQuery); + return [loading, networkHttpResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/translations.ts b/x-pack/plugins/security_solution/public/network/containers/network_http/translations.ts new file mode 100644 index 0000000000000..7909a5e48b8c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_NETWORK_HTTP = i18n.translate( + 'xpack.securitySolution.networkHttp.errorSearchDescription', + { + defaultMessage: `An error has occurred on network http search`, + } +); + +export const FAIL_NETWORK_HTTP = i18n.translate( + 'xpack.securitySolution.networkHttp.failSearchDescription', + { + defaultMessage: `Failed to run search on network http`, + } +); diff --git a/x-pack/plugins/security_solution/public/network/pages/ip_details/network_http_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/ip_details/network_http_query_table.tsx index 551de698cfa08..1b1b2b5f4f46e 100644 --- a/x-pack/plugins/security_solution/public/network/pages/ip_details/network_http_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/ip_details/network_http_query_table.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; import { OwnProps } from './types'; -import { NetworkHttpQuery } from '../../containers/network_http'; +import { useNetworkHttp } from '../../containers/network_http'; import { NetworkHttpTable } from '../../components/network_http_table'; const NetworkHttpTableManage = manageQuery(NetworkHttpTable); @@ -21,43 +21,35 @@ export const NetworkHttpQueryTable = ({ skip, startDate, type, -}: OwnProps) => ( - - {({ - id, - inspect, - isInspected, - loading, - loadPage, - networkHttp, - pageInfo, - refetch, - totalCount, - }) => ( - - )} - -); +}: OwnProps) => { + const [ + loading, + { id, inspect, isInspected, loadPage, networkHttp, pageInfo, refetch, totalCount }, + ] = useNetworkHttp({ + endDate, + filterQuery, + ip, + skip, + startDate, + type, + }); + + return ( + + ); +}; NetworkHttpQueryTable.displayName = 'NetworkHttpQueryTable'; diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx index 7e0c4025d6cac..3caff05734c1e 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { getOr } from 'lodash/fp'; import { NetworkHttpTable } from '../../components/network_http_table'; -import { NetworkHttpQuery } from '../../containers/network_http'; +import { useNetworkHttp } from '../../containers/network_http'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; @@ -22,42 +22,34 @@ export const HttpQueryTabBody = ({ skip, startDate, setQuery, -}: HttpQueryTabBodyProps) => ( - - {({ - id, - inspect, - isInspected, - loading, - loadPage, - networkHttp, - pageInfo, - refetch, - totalCount, - }) => ( - - )} - -); +}: HttpQueryTabBodyProps) => { + const [ + loading, + { id, inspect, isInspected, loadPage, networkHttp, pageInfo, refetch, totalCount }, + ] = useNetworkHttp({ + endDate, + filterQuery, + skip, + startDate, + type: networkModel.NetworkType.page, + }); + + return ( + + ); +}; HttpQueryTabBody.displayName = 'HttpQueryTabBody'; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/helpers.ts new file mode 100644 index 0000000000000..b8a28441337c7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/helpers.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, getOr } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { + NetworkHttpBuckets, + NetworkHttpEdges, +} from '../../../../../../common/search_strategy/security_solution/network'; + +export const getHttpEdges = (response: IEsSearchResponse): NetworkHttpEdges[] => + formatHttpEdges(getOr([], `aggregations.url.buckets`, response.rawResponse)); + +const formatHttpEdges = (buckets: NetworkHttpBuckets[]): NetworkHttpEdges[] => + buckets.map((bucket: NetworkHttpBuckets) => ({ + node: { + _id: bucket.key, + domains: bucket.domains.buckets.map(({ key }) => key), + methods: bucket.methods.buckets.map(({ key }) => key), + statuses: bucket.status.buckets.map(({ key }) => `${key}`), + lastHost: get('source.hits.hits[0]._source.host.name', bucket), + lastSourceIp: get('source.hits.hits[0]._source.source.ip', bucket), + path: bucket.key, + requestCount: bucket.doc_count, + }, + cursor: { + value: bucket.key, + tiebreaker: null, + }, + })); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/index.ts new file mode 100644 index 0000000000000..b6c26cd533de2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/index.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOr } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; + +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { + NetworkHttpStrategyResponse, + NetworkQueries, + NetworkHttpRequestOptions, + NetworkHttpEdges, +} from '../../../../../../common/search_strategy/security_solution/network'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { SecuritySolutionFactory } from '../../types'; + +import { getHttpEdges } from './helpers'; +import { buildHttpQuery } from './query.http_network.dsl'; + +export const networkHttp: SecuritySolutionFactory = { + buildDsl: (options: NetworkHttpRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildHttpQuery(options); + }, + parse: async ( + options: NetworkHttpRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.http_count.value', response.rawResponse); + const networkHttpEdges: NetworkHttpEdges[] = getHttpEdges(response); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + const edges = networkHttpEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildHttpQuery(options))], + response: [inspectStringifyObject(response)], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + + return { + ...response, + edges, + inspect, + pageInfo: { + activePage: activePage ? activePage : 0, + fakeTotalCount, + showMorePagesIndicator, + }, + totalCount, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/query.http_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/query.http_network.dsl.ts new file mode 100644 index 0000000000000..31d695d6a0591 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/query.http_network.dsl.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +import { + NetworkHttpRequestOptions, + SortField, +} from '../../../../../../common/search_strategy/security_solution'; + +const getCountAgg = () => ({ + http_count: { + cardinality: { + field: 'url.path', + }, + }, +}); + +export const buildHttpQuery = ({ + defaultIndex, + filterQuery, + sort, + pagination: { querySize }, + timerange: { from, to }, + ip, +}: NetworkHttpRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { gte: from, lte: to, format: 'strict_date_optional_time' }, + }, + }, + { exists: { field: 'http.request.method' } }, + ]; + + const dslQuery = { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + body: { + aggregations: { + ...getCountAgg(), + ...getHttpAggs(sort, querySize), + }, + query: { + bool: ip + ? { + filter, + should: [ + { + term: { + 'source.ip': ip, + }, + }, + { + term: { + 'destination.ip': ip, + }, + }, + ], + minimum_should_match: 1, + } + : { + filter, + }, + }, + }, + size: 0, + track_total_hits: false, + }; + return dslQuery; +}; + +const getHttpAggs = (sortField: SortField, querySize: number) => ({ + url: { + terms: { + field: `url.path`, + size: querySize, + order: { + _count: sortField.direction, + }, + }, + aggs: { + methods: { + terms: { + field: 'http.request.method', + size: 4, + }, + }, + domains: { + terms: { + field: 'url.domain', + size: 4, + }, + }, + status: { + terms: { + field: 'http.response.status_code', + size: 4, + }, + }, + source: { + top_hits: { + size: 1, + _source: { + includes: ['host.name', 'source.ip'], + }, + }, + }, + }, + }, +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts index 2c21d9741d648..7d40b034c66bb 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts @@ -8,8 +8,10 @@ import { FactoryQueryTypes } from '../../../../../common/search_strategy/securit import { NetworkQueries } from '../../../../../common/search_strategy/security_solution/network'; import { SecuritySolutionFactory } from '../types'; +import { networkHttp } from './http'; import { networkTls } from './tls'; export const networkFactory: Record> = { + [NetworkQueries.http]: networkHttp, [NetworkQueries.tls]: networkTls, }; From a656b96e25be33c17620d99425777f7ff4a4ad1f Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Wed, 2 Sep 2020 06:22:20 -0400 Subject: [PATCH 22/53] [Ingest Manager] support multiple kibana urls (#75712) * let the user specify multiple kibana urls * add validation to kibana urls so paths and protocols cannot differ * update i18n message * only send the first url to the instructions * udpate all agent configs' revision when settings is updated * fix jest test * update endpoint full agent policy test * fix type * dont add settings if standalone mode * fix ui not handling errors from /{agentPolicyId}/full endpoint * fix formatted message id * only return needed fields * fill in updated_by and updated_at attributes of the ingest-agent-policies when revision is bumped * throw error if kibana_urls not set and update tests * change ingest_manager_settings SO attribute kibana_url: string to kibana_urls: string[] and add migration * leave instructions single kibana url * make kibana_url and other attributes created during setup required, fix types --- .../ingest_manager/common/services/index.ts | 1 + .../services/is_diff_path_protocol.test.ts | 39 ++++++++++++++ .../common/services/is_diff_path_protocol.ts | 24 +++++++++ .../ingest_manager/common/types/index.ts | 2 +- .../common/types/models/agent_policy.ts | 5 ++ .../common/types/models/settings.ts | 8 +-- .../components/settings_flyout.tsx | 37 ++++++++----- .../components/agent_policy_yaml_flyout.tsx | 32 +++++++---- .../managed_instructions.tsx | 7 ++- x-pack/plugins/ingest_manager/server/index.ts | 7 ++- .../server/routes/install_script/index.ts | 8 +-- .../server/routes/settings/index.ts | 6 ++- .../server/saved_objects/index.ts | 6 ++- .../saved_objects/migrations/to_v7_10_0.ts | 22 +++++++- .../server/services/agent_policy.test.ts | 35 +++++++++++- .../server/services/agent_policy.ts | 39 +++++++++++++- .../server/services/settings.ts | 44 ++++++++++++--- .../ingest_manager/server/services/setup.ts | 26 ++------- .../server/types/rest_spec/settings.ts | 11 +++- .../apis/index.js | 3 ++ .../apis/settings/index.js | 11 ++++ .../apis/settings/update.ts | 53 +++++++++++++++++++ .../apps/endpoint/policy_details.ts | 15 ++++++ 23 files changed, 371 insertions(+), 70 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/common/services/is_diff_path_protocol.test.ts create mode 100644 x-pack/plugins/ingest_manager/common/services/is_diff_path_protocol.ts create mode 100644 x-pack/test/ingest_manager_api_integration/apis/settings/index.js create mode 100644 x-pack/test/ingest_manager_api_integration/apis/settings/update.ts diff --git a/x-pack/plugins/ingest_manager/common/services/index.ts b/x-pack/plugins/ingest_manager/common/services/index.ts index ad739bf9ff844..46a1c65872d1b 100644 --- a/x-pack/plugins/ingest_manager/common/services/index.ts +++ b/x-pack/plugins/ingest_manager/common/services/index.ts @@ -11,3 +11,4 @@ export { fullAgentPolicyToYaml } from './full_agent_policy_to_yaml'; export { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from './limited_package'; export { decodeCloudId } from './decode_cloud_id'; export { isValidNamespace } from './is_valid_namespace'; +export { isDiffPathProtocol } from './is_diff_path_protocol'; diff --git a/x-pack/plugins/ingest_manager/common/services/is_diff_path_protocol.test.ts b/x-pack/plugins/ingest_manager/common/services/is_diff_path_protocol.test.ts new file mode 100644 index 0000000000000..c488d552d7676 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/is_diff_path_protocol.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isDiffPathProtocol } from './is_diff_path_protocol'; + +describe('Ingest Manager - isDiffPathProtocol', () => { + it('returns true for different paths', () => { + expect( + isDiffPathProtocol([ + 'http://localhost:8888/abc', + 'http://localhost:8888/abc', + 'http://localhost:8888/efg', + ]) + ).toBe(true); + }); + it('returns true for different protocols', () => { + expect( + isDiffPathProtocol([ + 'http://localhost:8888/abc', + 'https://localhost:8888/abc', + 'http://localhost:8888/abc', + ]) + ).toBe(true); + }); + it('returns false for same paths and protocols and different host or port', () => { + expect( + isDiffPathProtocol([ + 'http://localhost:8888/abc', + 'http://localhost2:8888/abc', + 'http://localhost:8883/abc', + ]) + ).toBe(false); + }); + it('returns false for one url', () => { + expect(isDiffPathProtocol(['http://localhost:8888/abc'])).toBe(false); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/services/is_diff_path_protocol.ts b/x-pack/plugins/ingest_manager/common/services/is_diff_path_protocol.ts new file mode 100644 index 0000000000000..666e886d745b1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/is_diff_path_protocol.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// validates an array of urls have the same path and protocol +export function isDiffPathProtocol(kibanaUrls: string[]) { + const urlCompare = new URL(kibanaUrls[0]); + const compareProtocol = urlCompare.protocol; + const comparePathname = urlCompare.pathname; + return kibanaUrls.some((v) => { + const url = new URL(v); + const protocol = url.protocol; + const pathname = url.pathname; + return compareProtocol !== protocol || comparePathname !== pathname; + }); +} diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index cafd0f03f66e2..d62f4fbb023dc 100644 --- a/x-pack/plugins/ingest_manager/common/types/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/index.ts @@ -15,7 +15,7 @@ export interface IngestManagerConfigType { pollingRequestTimeout: number; maxConcurrentConnections: number; kibana: { - host?: string; + host?: string[] | string; ca_sha256?: string; }; elasticsearch: { diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_policy.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_policy.ts index c626c85d3fb24..263e10e9d34b1 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent_policy.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent_policy.ts @@ -60,6 +60,11 @@ export interface FullAgentPolicy { [key: string]: any; }; }; + fleet?: { + kibana: { + hosts: string[]; + }; + }; inputs: FullAgentPolicyInput[]; revision?: number; agent?: { diff --git a/x-pack/plugins/ingest_manager/common/types/models/settings.ts b/x-pack/plugins/ingest_manager/common/types/models/settings.ts index 98d99911f1b3f..f554f4b392ad6 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/settings.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/settings.ts @@ -5,10 +5,10 @@ */ import { SavedObjectAttributes } from 'src/core/public'; -interface BaseSettings { - agent_auto_upgrade?: boolean; - package_auto_upgrade?: boolean; - kibana_url?: string; +export interface BaseSettings { + agent_auto_upgrade: boolean; + package_auto_upgrade: boolean; + kibana_urls: string[]; kibana_ca_sha256?: string; has_seen_add_data_notice?: boolean; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/settings_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/settings_flyout.tsx index 9a9557f77c40c..e0d843ad773b8 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/settings_flyout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/settings_flyout.tsx @@ -18,14 +18,14 @@ import { EuiFlyoutFooter, EuiForm, EuiFormRow, - EuiFieldText, EuiRadioGroup, EuiComboBox, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText } from '@elastic/eui'; -import { useInput, useComboInput, useCore, useGetSettings, sendPutSettings } from '../hooks'; +import { useComboInput, useCore, useGetSettings, sendPutSettings } from '../hooks'; import { useGetOutputs, sendPutOutput } from '../hooks/use_request/outputs'; +import { isDiffPathProtocol } from '../../../../common/'; const URL_REGEX = /^(https?):\/\/[^\s$.?#].[^\s]*$/gm; @@ -36,14 +36,28 @@ interface Props { function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { const [isLoading, setIsloading] = React.useState(false); const { notifications } = useCore(); - const kibanaUrlInput = useInput('', (value) => { - if (!value.match(URL_REGEX)) { + const kibanaUrlsInput = useComboInput([], (value) => { + if (value.length === 0) { + return [ + i18n.translate('xpack.ingestManager.settings.kibanaUrlEmptyError', { + defaultMessage: 'At least one URL is required', + }), + ]; + } + if (value.some((v) => !v.match(URL_REGEX))) { return [ i18n.translate('xpack.ingestManager.settings.kibanaUrlError', { defaultMessage: 'Invalid URL', }), ]; } + if (isDiffPathProtocol(value)) { + return [ + i18n.translate('xpack.ingestManager.settings.kibanaUrlDifferentPathOrProtocolError', { + defaultMessage: 'Protocol and path must be the same for each URL', + }), + ]; + } }); const elasticsearchUrlInput = useComboInput([], (value) => { if (value.some((v) => !v.match(URL_REGEX))) { @@ -58,7 +72,7 @@ function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { return { isLoading, onSubmit: async () => { - if (!kibanaUrlInput.validate() || !elasticsearchUrlInput.validate()) { + if (!kibanaUrlsInput.validate() || !elasticsearchUrlInput.validate()) { return; } @@ -74,7 +88,7 @@ function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { throw outputResponse.error; } const settingsResponse = await sendPutSettings({ - kibana_url: kibanaUrlInput.value, + kibana_urls: kibanaUrlsInput.value, }); if (settingsResponse.error) { throw settingsResponse.error; @@ -94,14 +108,13 @@ function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { } }, inputs: { - kibanaUrl: kibanaUrlInput, + kibanaUrls: kibanaUrlsInput, elasticsearchUrl: elasticsearchUrlInput, }, }; } export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { - const core = useCore(); const settingsRequest = useGetSettings(); const settings = settingsRequest?.data?.item; const outputsRequest = useGetOutputs(); @@ -117,9 +130,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { useEffect(() => { if (settings) { - inputs.kibanaUrl.setValue( - settings.kibana_url || `${window.location.origin}${core.http.basePath.get()}` - ); + inputs.kibanaUrls.setValue(settings.kibana_urls); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [settings]); @@ -220,9 +231,9 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { label={i18n.translate('xpack.ingestManager.settings.kibanaUrlLabel', { defaultMessage: 'Kibana URL', })} - {...inputs.kibanaUrl.formRowProps} + {...inputs.kibanaUrls.formRowProps} > - + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/agent_policy_yaml_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/agent_policy_yaml_flyout.tsx index 919bb49f69aae..5d485a6e21086 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/agent_policy_yaml_flyout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/components/agent_policy_yaml_flyout.tsx @@ -18,6 +18,7 @@ import { EuiFlyoutFooter, EuiButtonEmpty, EuiButton, + EuiCallOut, } from '@elastic/eui'; import { useGetOneAgentPolicyFull, useGetOneAgentPolicy, useCore } from '../../../hooks'; import { Loading } from '../../../components'; @@ -32,17 +33,28 @@ const FlyoutBody = styled(EuiFlyoutBody)` export const AgentPolicyYamlFlyout = memo<{ policyId: string; onClose: () => void }>( ({ policyId, onClose }) => { const core = useCore(); - const { isLoading: isLoadingYaml, data: yamlData } = useGetOneAgentPolicyFull(policyId); + const { isLoading: isLoadingYaml, data: yamlData, error } = useGetOneAgentPolicyFull(policyId); const { data: agentPolicyData } = useGetOneAgentPolicy(policyId); - - const body = - isLoadingYaml && !yamlData ? ( - - ) : ( - - {fullAgentPolicyToYaml(yamlData!.item)} - - ); + const body = isLoadingYaml ? ( + + ) : error ? ( + + } + color="danger" + iconType="alert" + > + {error.message} + + ) : ( + + {fullAgentPolicyToYaml(yamlData!.item)} + + ); const downloadLink = core.http.basePath.prepend( agentPolicyRouteService.getInfoFullDownloadPath(policyId) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx index b02893057c9c3..9307229cdc258 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx @@ -34,8 +34,11 @@ export const ManagedInstructions: React.FunctionComponent = ({ agentPolic const settings = useGetSettings(); const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); - const kibanaUrl = - settings.data?.item?.kibana_url ?? `${window.location.origin}${core.http.basePath.get()}`; + const kibanaUrlsSettings = settings.data?.item?.kibana_urls; + const kibanaUrl = kibanaUrlsSettings + ? kibanaUrlsSettings[0] + : `${window.location.origin}${core.http.basePath.get()}`; + const kibanaCASha256 = settings.data?.item?.kibana_ca_sha256; const steps: EuiContainedStepProps[] = [ diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 962cddb2e411e..f7b923aebb48b 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -32,7 +32,12 @@ export const config = { pollingRequestTimeout: schema.number({ defaultValue: 60000 }), maxConcurrentConnections: schema.number({ defaultValue: 0 }), kibana: schema.object({ - host: schema.maybe(schema.string()), + host: schema.maybe( + schema.oneOf([ + schema.uri({ scheme: ['http', 'https'] }), + schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { minSize: 1 }), + ]) + ), ca_sha256: schema.maybe(schema.string()), }), elasticsearch: schema.object({ diff --git a/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts b/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts index c2a5d77a39eb1..c767d3e80d2b7 100644 --- a/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts @@ -38,16 +38,16 @@ export const registerRoutes = ({ const http = appContextService.getHttpSetup(); const serverInfo = http.getServerInfo(); const basePath = http.basePath; - const kibanaUrl = - (await settingsService.getSettings(soClient)).kibana_url || + const kibanaUrls = (await settingsService.getSettings(soClient)).kibana_urls || [ url.format({ protocol: serverInfo.protocol, hostname: serverInfo.hostname, port: serverInfo.port, pathname: basePath.serverBasePath, - }); + }), + ]; - const script = getScript(request.params.osType, kibanaUrl); + const script = getScript(request.params.osType, kibanaUrls[0]); return response.ok({ body: script }); } diff --git a/x-pack/plugins/ingest_manager/server/routes/settings/index.ts b/x-pack/plugins/ingest_manager/server/routes/settings/index.ts index 56e666056e8d0..aabb85dadabc2 100644 --- a/x-pack/plugins/ingest_manager/server/routes/settings/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/settings/index.ts @@ -8,7 +8,7 @@ import { TypeOf } from '@kbn/config-schema'; import { PLUGIN_ID, SETTINGS_API_ROUTES } from '../../constants'; import { PutSettingsRequestSchema, GetSettingsRequestSchema } from '../../types'; -import { settingsService } from '../../services'; +import { settingsService, agentPolicyService, appContextService } from '../../services'; export const getSettingsHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; @@ -40,8 +40,12 @@ export const putSettingsHandler: RequestHandler< TypeOf > = async (context, request, response) => { const soClient = context.core.savedObjects.client; + const user = await appContextService.getSecurity()?.authc.getCurrentUser(request); try { const settings = await settingsService.saveSettings(soClient, request.body); + await agentPolicyService.bumpAllAgentPolicies(soClient, { + user: user || undefined, + }); const body = { success: true, item: settings, diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index 1bbe3b71bf919..aff8e607622d4 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -23,6 +23,7 @@ import { migrateAgentPolicyToV7100, migrateEnrollmentApiKeysToV7100, migratePackagePolicyToV7100, + migrateSettingsToV7100, } from './migrations/to_v7_10_0'; /* @@ -43,11 +44,14 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { properties: { agent_auto_upgrade: { type: 'keyword' }, package_auto_upgrade: { type: 'keyword' }, - kibana_url: { type: 'keyword' }, + kibana_urls: { type: 'keyword' }, kibana_ca_sha256: { type: 'keyword' }, has_seen_add_data_notice: { type: 'boolean', index: false }, }, }, + migrations: { + '7.10.0': migrateSettingsToV7100, + }, }, [AGENT_SAVED_OBJECT_TYPE]: { name: AGENT_SAVED_OBJECT_TYPE, diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/migrations/to_v7_10_0.ts b/x-pack/plugins/ingest_manager/server/saved_objects/migrations/to_v7_10_0.ts index b60903dbd2bd0..5e36ce46c099b 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/migrations/to_v7_10_0.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/migrations/to_v7_10_0.ts @@ -5,7 +5,14 @@ */ import { SavedObjectMigrationFn } from 'kibana/server'; -import { Agent, AgentEvent, AgentPolicy, PackagePolicy, EnrollmentAPIKey } from '../../types'; +import { + Agent, + AgentEvent, + AgentPolicy, + PackagePolicy, + EnrollmentAPIKey, + Settings, +} from '../../types'; export const migrateAgentToV7100: SavedObjectMigrationFn< Exclude & { @@ -72,3 +79,16 @@ export const migratePackagePolicyToV7100: SavedObjectMigrationFn< return packagePolicyDoc; }; + +export const migrateSettingsToV7100: SavedObjectMigrationFn< + Exclude & { + kibana_url: string; + }, + Settings +> = (settingsDoc) => { + settingsDoc.attributes.kibana_urls = [settingsDoc.attributes.kibana_url]; + // @ts-expect-error + delete settingsDoc.attributes.kibana_url; + + return settingsDoc; +}; diff --git a/x-pack/plugins/ingest_manager/server/services/agent_policy.test.ts b/x-pack/plugins/ingest_manager/server/services/agent_policy.test.ts index dc2a89c661ac3..d9dffa03b2290 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_policy.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_policy.test.ts @@ -19,6 +19,24 @@ function getSavedObjectMock(agentPolicyAttributes: any) { attributes: agentPolicyAttributes, }; }); + mock.find.mockImplementation(async (options) => { + return { + saved_objects: [ + { + id: '93f74c0-e876-11ea-b7d3-8b2acec6f75c', + attributes: { + kibana_urls: ['http://localhost:5603'], + }, + type: 'ingest_manager_settings', + score: 1, + references: [], + }, + ], + total: 1, + page: 1, + per_page: 1, + }; + }); return mock; } @@ -43,7 +61,7 @@ jest.mock('./output', () => { describe('agent policy', () => { describe('getFullAgentPolicy', () => { - it('should return a policy without monitoring if not monitoring is not enabled', async () => { + it('should return a policy without monitoring if monitoring is not enabled', async () => { const soClient = getSavedObjectMock({ revision: 1, }); @@ -61,6 +79,11 @@ describe('agent policy', () => { }, inputs: [], revision: 1, + fleet: { + kibana: { + hosts: ['http://localhost:5603'], + }, + }, agent: { monitoring: { enabled: false, @@ -90,6 +113,11 @@ describe('agent policy', () => { }, inputs: [], revision: 1, + fleet: { + kibana: { + hosts: ['http://localhost:5603'], + }, + }, agent: { monitoring: { use_output: 'default', @@ -120,6 +148,11 @@ describe('agent policy', () => { }, inputs: [], revision: 1, + fleet: { + kibana: { + hosts: ['http://localhost:5603'], + }, + }, agent: { monitoring: { use_output: 'default', diff --git a/x-pack/plugins/ingest_manager/server/services/agent_policy.ts b/x-pack/plugins/ingest_manager/server/services/agent_policy.ts index 21bc7b021e83a..2c97bba0cac45 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_policy.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_policy.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { uniq } from 'lodash'; -import { SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsClientContract, SavedObjectsBulkUpdateResponse } from 'src/core/server'; import { AuthenticatedUser } from '../../../security/server'; import { DEFAULT_AGENT_POLICY, @@ -25,6 +25,7 @@ import { listAgents } from './agents'; import { packagePolicyService } from './package_policy'; import { outputService } from './output'; import { agentPolicyUpdateEventHandler } from './agent_policy_update'; +import { getSettings } from './settings'; const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; @@ -260,6 +261,25 @@ class AgentPolicyService { ): Promise { return this._update(soClient, id, {}, options?.user); } + public async bumpAllAgentPolicies( + soClient: SavedObjectsClientContract, + options?: { user?: AuthenticatedUser } + ): Promise>> { + const currentPolicies = await soClient.find({ + type: SAVED_OBJECT_TYPE, + fields: ['revision'], + }); + const bumpedPolicies = currentPolicies.saved_objects.map((policy) => { + policy.attributes = { + ...policy.attributes, + revision: policy.attributes.revision + 1, + updated_at: new Date().toISOString(), + updated_by: options?.user ? options.user.username : 'system', + }; + return policy; + }); + return soClient.bulkUpdate(bumpedPolicies); + } public async assignPackagePolicies( soClient: SavedObjectsClientContract, @@ -370,6 +390,7 @@ class AgentPolicyService { options?: { standalone: boolean } ): Promise { let agentPolicy; + const standalone = options?.standalone; try { agentPolicy = await this.get(soClient, id); @@ -435,6 +456,22 @@ class AgentPolicyService { }), }; + // only add settings if not in standalone + if (!standalone) { + let settings; + try { + settings = await getSettings(soClient); + } catch (error) { + throw new Error('Default settings is not setup'); + } + if (!settings.kibana_urls) throw new Error('kibana_urls is missing'); + fullAgentPolicy.fleet = { + kibana: { + hosts: settings.kibana_urls, + }, + }; + } + return fullAgentPolicy; } } diff --git a/x-pack/plugins/ingest_manager/server/services/settings.ts b/x-pack/plugins/ingest_manager/server/services/settings.ts index f1c09746d9abd..25223fbc08535 100644 --- a/x-pack/plugins/ingest_manager/server/services/settings.ts +++ b/x-pack/plugins/ingest_manager/server/services/settings.ts @@ -5,7 +5,15 @@ */ import Boom from 'boom'; import { SavedObjectsClientContract } from 'kibana/server'; -import { GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, SettingsSOAttributes, Settings } from '../../common'; +import url from 'url'; +import { + GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + SettingsSOAttributes, + Settings, + decodeCloudId, + BaseSettings, +} from '../../common'; +import { appContextService } from './app_context'; export async function getSettings(soClient: SavedObjectsClientContract): Promise { const res = await soClient.find({ @@ -25,7 +33,7 @@ export async function getSettings(soClient: SavedObjectsClientContract): Promise export async function saveSettings( soClient: SavedObjectsClientContract, newData: Partial> -): Promise { +): Promise & Pick> { try { const settings = await getSettings(soClient); @@ -41,10 +49,11 @@ export async function saveSettings( }; } catch (e) { if (e.isBoom && e.output.statusCode === 404) { - const res = await soClient.create( - GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, - newData - ); + const defaultSettings = createDefaultSettings(); + const res = await soClient.create(GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, { + ...defaultSettings, + ...newData, + }); return { id: res.id, @@ -55,3 +64,26 @@ export async function saveSettings( throw e; } } + +export function createDefaultSettings(): BaseSettings { + const http = appContextService.getHttpSetup(); + const serverInfo = http.getServerInfo(); + const basePath = http.basePath; + + const cloud = appContextService.getCloud(); + const cloudId = cloud?.isCloudEnabled && cloud.cloudId; + const cloudUrl = cloudId && decodeCloudId(cloudId)?.kibanaUrl; + const flagsUrl = appContextService.getConfig()?.fleet?.kibana?.host; + const defaultUrl = url.format({ + protocol: serverInfo.protocol, + hostname: serverInfo.hostname, + port: serverInfo.port, + pathname: basePath.serverBasePath, + }); + + return { + agent_auto_upgrade: true, + package_auto_upgrade: true, + kibana_urls: [cloudUrl || flagsUrl || defaultUrl].flat(), + }; +} diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index fd5d94a71d672..ec3a05a4fa390 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import url from 'url'; import uuid from 'uuid'; import { SavedObjectsClientContract } from 'src/core/server'; import { CallESAsCurrentUser } from '../types'; @@ -22,14 +21,13 @@ import { Installation, Output, DEFAULT_AGENT_POLICIES_PACKAGES, - decodeCloudId, } from '../../common'; import { getPackageInfo } from './epm/packages'; import { packagePolicyService } from './package_policy'; import { generateEnrollmentAPIKey } from './api_keys'; import { settingsService } from '.'; -import { appContextService } from './app_context'; import { awaitIfPending } from './setup_utils'; +import { createDefaultSettings } from './settings'; const FLEET_ENROLL_USERNAME = 'fleet_enroll'; const FLEET_ENROLL_ROLE = 'fleet_enroll'; @@ -58,26 +56,8 @@ async function createSetupSideEffects( ensureDefaultIndices(callCluster), settingsService.getSettings(soClient).catch((e: any) => { if (e.isBoom && e.output.statusCode === 404) { - const http = appContextService.getHttpSetup(); - const serverInfo = http.getServerInfo(); - const basePath = http.basePath; - - const cloud = appContextService.getCloud(); - const cloudId = cloud?.isCloudEnabled && cloud.cloudId; - const cloudUrl = cloudId && decodeCloudId(cloudId)?.kibanaUrl; - const flagsUrl = appContextService.getConfig()?.fleet?.kibana?.host; - const defaultUrl = url.format({ - protocol: serverInfo.protocol, - hostname: serverInfo.hostname, - port: serverInfo.port, - pathname: basePath.serverBasePath, - }); - - return settingsService.saveSettings(soClient, { - agent_auto_upgrade: true, - package_auto_upgrade: true, - kibana_url: cloudUrl || flagsUrl || defaultUrl, - }); + const defaultSettings = createDefaultSettings(); + return settingsService.saveSettings(soClient, defaultSettings); } return Promise.reject(e); diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/settings.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/settings.ts index baee9f79d9317..35718491c9224 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/settings.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/settings.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { schema } from '@kbn/config-schema'; +import { isDiffPathProtocol } from '../../../common'; export const GetSettingsRequestSchema = {}; @@ -11,7 +12,15 @@ export const PutSettingsRequestSchema = { body: schema.object({ agent_auto_upgrade: schema.maybe(schema.boolean()), package_auto_upgrade: schema.maybe(schema.boolean()), - kibana_url: schema.maybe(schema.uri({ scheme: ['http', 'https'] })), + kibana_urls: schema.maybe( + schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { + validate: (value) => { + if (isDiffPathProtocol(value)) { + return 'Protocol and path must be the same for each URL'; + } + }, + }) + ), kibana_ca_sha256: schema.maybe(schema.string()), has_seen_add_data_notice: schema.maybe(schema.boolean()), }), diff --git a/x-pack/test/ingest_manager_api_integration/apis/index.js b/x-pack/test/ingest_manager_api_integration/apis/index.js index fac8a26fd6aec..7c1ebef337baa 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/index.js +++ b/x-pack/test/ingest_manager_api_integration/apis/index.js @@ -22,5 +22,8 @@ export default function ({ loadTestFile }) { // Agent policies loadTestFile(require.resolve('./agent_policy/index')); + + // Settings + loadTestFile(require.resolve('./settings/index')); }); } diff --git a/x-pack/test/ingest_manager_api_integration/apis/settings/index.js b/x-pack/test/ingest_manager_api_integration/apis/settings/index.js new file mode 100644 index 0000000000000..99346fcabeff4 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/settings/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function loadTests({ loadTestFile }) { + describe('Settings Endpoints', () => { + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/test/ingest_manager_api_integration/apis/settings/update.ts b/x-pack/test/ingest_manager_api_integration/apis/settings/update.ts new file mode 100644 index 0000000000000..86292b535db2d --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/settings/update.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('Settings - update', async function () { + skipIfNoDockerRegistry(providerContext); + + it("should bump all agent policy's revision", async function () { + const { body: testPolicy1PostRes } = await supertest + .post(`/api/ingest_manager/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'test', + description: '', + namespace: 'default', + }); + const { body: testPolicy2PostRes } = await supertest + .post(`/api/ingest_manager/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'test2', + description: '', + namespace: 'default', + }); + await supertest + .put(`/api/ingest_manager/settings`) + .set('kbn-xsrf', 'xxxx') + .send({ kibana_urls: ['http://localhost:1232/abc', 'http://localhost:1232/abc'] }); + + const getTestPolicy1Res = await kibanaServer.savedObjects.get({ + type: 'ingest-agent-policies', + id: testPolicy1PostRes.item.id, + }); + const getTestPolicy2Res = await kibanaServer.savedObjects.get({ + type: 'ingest-agent-policies', + id: testPolicy2PostRes.item.id, + }); + expect(getTestPolicy1Res.attributes.revision).equal(2); + expect(getTestPolicy2Res.attributes.revision).equal(2); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index a0998f1a838ba..9a3489e9309bf 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import Url from 'url'; import { FtrProviderContext } from '../../ftr_provider_context'; import { PolicyTestResourceInfo } from '../../services/endpoint_policy'; @@ -18,6 +19,15 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ]); const testSubjects = getService('testSubjects'); const policyTestResources = getService('policyTestResources'); + const config = getService('config'); + const kbnTestServer = config.get('servers.kibana'); + const { protocol, hostname, port } = kbnTestServer; + + const kibanaUrl = Url.format({ + protocol, + hostname, + port, + }); describe('When on the Endpoint Policy Details Page', function () { this.tags(['ciGroup7']); @@ -222,6 +232,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { type: 'elasticsearch', }, }, + fleet: { + kibana: { + hosts: [kibanaUrl], + }, + }, revision: 3, agent: { monitoring: { From 4a4d7ce5a414891f9b46879d4dfdb5cc088772cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20H=C3=BCbertz?= Date: Wed, 2 Sep 2020 13:15:32 +0200 Subject: [PATCH 23/53] [APM] Minor style improvements to stacktraces (#76375) - Added some more spacing for the indention inside a nested accordion - Added additional spacing to the CausedBy accordions that created some space between the content and the containers. --- .../public/components/shared/Stacktrace/CauseStacktrace.tsx | 3 ++- .../apm/public/components/shared/Stacktrace/Stackframe.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx index 977bf2dd09cc0..8cabb820ed7f3 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx @@ -8,13 +8,14 @@ import React from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { EuiAccordion, EuiTitle } from '@elastic/eui'; -import { px, unit } from '../../../style/variables'; +import { px, unit, units } from '../../../style/variables'; import { Stacktrace } from '.'; import { Stackframe } from '../../../../typings/es_schemas/raw/fields/stackframe'; // @ts-ignore Styled Components has trouble inferring the types of the default props here. const Accordion = styled(EuiAccordion)` border-top: ${({ theme }) => theme.eui.euiBorderThin}; + margin-top: ${px(units.half)}; `; const CausedByContainer = styled('h5')` diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx index a552491297d55..f72eb209f2329 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx @@ -35,7 +35,7 @@ const ContextContainer = styled.div<{ isLibraryFrame: boolean }>` // Indent the non-context frames the same amount as the accordion control const NoContextFrameHeadingWrapper = styled.div` - margin-left: ${px(units.unit + units.half + units.eighth)}; + margin-left: ${px(units.unit + units.half + units.quarter)}; `; interface Props { From 946e9f09140ac7b5683eb3c85e78c2a39851212f Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Wed, 2 Sep 2020 08:56:23 -0400 Subject: [PATCH 24/53] [Maps] Remove obsolete link (#76419) --- .../routing/routes/list/maps_list_view.js | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.js b/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.js index e9229883d708d..8fe6866cd7834 100644 --- a/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.js +++ b/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.js @@ -12,6 +12,7 @@ import { getUiSettings, getToasts, getCoreChrome, + getNavigateToApp, } from '../../../kibana_services'; import { EuiTitle, @@ -32,11 +33,18 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { addHelpMenuToAppChrome } from '../../../help_menu_util'; -import { Link } from 'react-router-dom'; import { goToSpecifiedPath } from '../../maps_router'; +import { APP_ID, MAP_PATH } from '../../../../common/constants'; export const EMPTY_FILTER = ''; +function navigateToNewMap() { + const navigateToApp = getNavigateToApp(); + navigateToApp(APP_ID, { + path: MAP_PATH, + }); +} + export class MapsListView extends React.Component { state = { hasInitialFetchReturned: false, @@ -388,14 +396,12 @@ export class MapsListView extends React.Component { let createButton; if (!this.state.readOnly) { createButton = ( - - - - - + + + ); } return ( From 4762cf56f54b5f6fba17dbe4cdc8b18607b2dc61 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 2 Sep 2020 14:07:57 +0100 Subject: [PATCH 25/53] [ML] Switching to new es client (#74965) * [ML] Switching to new es client * further conversions * fixing tests * updating responses * test commit * refactoring shared services to removed context parameter * removing last scoped clients * removing ml js client * udating file data viz errors * fixing jest tests * fixing types after merge with master * error response changes * adding default sizes to some requests * adding custom error types for license checks * tidying up shared function checks * removing test data * removing need for DummyKibanaRequest * updating comment * fixing functional api tests * removing comments * fixing types after master merge * throw error rather than return it * removing placeholder error * changes based on review comments * fixing types after merge with master * fixing missing return Co-authored-by: Elastic Machine --- ...transaction_duration_anomaly_alert_type.ts | 7 +- .../apm/server/lib/helpers/setup_request.ts | 11 +- x-pack/plugins/infra/server/plugin.ts | 5 +- .../components/import_errors/errors.tsx | 18 +- .../common/chart_loader/chart_loader.ts | 8 +- .../ml/server/client/elasticsearch_ml.test.ts | 60 -- .../ml/server/client/elasticsearch_ml.ts | 929 ------------------ .../capabilities/check_capabilities.test.ts | 30 +- .../lib/capabilities/check_capabilities.ts | 6 +- .../ml/server/lib/capabilities/upgrade.ts | 8 +- .../ml/server/lib/check_annotations/index.ts | 12 +- .../server/lib/license/ml_server_license.ts | 3 - .../annotation_service/annotation.test.ts | 43 +- .../models/annotation_service/annotation.ts | 24 +- .../server/models/annotation_service/index.ts | 6 +- .../bucket_span_estimator.d.ts | 8 +- .../bucket_span_estimator.js | 90 +- .../bucket_span_estimator.test.ts | 17 +- .../polled_data_checker.js | 11 +- .../single_series_checker.js | 11 +- .../calculate_model_memory_limit.ts | 23 +- .../models/calendar/calendar_manager.ts | 39 +- .../server/models/calendar/event_manager.ts | 29 +- .../analytics_audit_messages.ts | 38 +- .../data_recognizer/data_recognizer.test.ts | 11 +- .../models/data_recognizer/data_recognizer.ts | 105 +- .../models/data_visualizer/data_visualizer.ts | 100 +- .../models/fields_service/fields_service.ts | 46 +- .../file_data_visualizer.ts | 12 +- .../file_data_visualizer/import_data.ts | 19 +- .../ml/server/models/filter/filter_manager.ts | 47 +- .../job_audit_messages.d.ts | 4 +- .../job_audit_messages/job_audit_messages.js | 248 +++-- .../ml/server/models/job_service/datafeeds.ts | 41 +- .../server/models/job_service/error_utils.ts | 6 +- .../ml/server/models/job_service/groups.ts | 19 +- .../ml/server/models/job_service/index.ts | 18 +- .../ml/server/models/job_service/jobs.ts | 135 ++- .../models/job_service/model_snapshots.ts | 34 +- .../new_job/categorization/examples.ts | 16 +- .../new_job/categorization/top_categories.ts | 19 +- .../categorization/validation_results.ts | 4 +- .../models/job_service/new_job/charts.ts | 8 +- .../models/job_service/new_job/line_chart.ts | 8 +- .../job_service/new_job/population_chart.ts | 18 +- .../job_service/new_job_caps/field_service.ts | 15 +- .../new_job_caps/new_job_caps.test.ts | 31 +- .../job_service/new_job_caps/new_job_caps.ts | 11 +- .../models/job_service/new_job_caps/rollup.ts | 8 +- .../job_validation/job_validation.test.ts | 47 +- .../models/job_validation/job_validation.ts | 18 +- .../job_validation/validate_bucket_span.js | 4 +- .../validate_bucket_span.test.ts | 6 +- .../validate_cardinality.test.ts | 28 +- .../job_validation/validate_cardinality.ts | 17 +- .../validate_model_memory_limit.test.ts | 44 +- .../validate_model_memory_limit.ts | 14 +- .../validate_time_range.test.ts | 19 +- .../job_validation/validate_time_range.ts | 11 +- .../get_partition_fields_values.ts | 12 +- .../models/results_service/results_service.ts | 48 +- x-pack/plugins/ml/server/plugin.ts | 38 +- .../plugins/ml/server/routes/annotations.ts | 16 +- .../ml/server/routes/anomaly_detectors.ts | 159 ++- x-pack/plugins/ml/server/routes/calendars.ts | 52 +- .../ml/server/routes/data_frame_analytics.ts | 133 +-- .../ml/server/routes/data_visualizer.ts | 26 +- x-pack/plugins/ml/server/routes/datafeeds.ts | 99 +- .../ml/server/routes/fields_service.ts | 18 +- .../ml/server/routes/file_data_visualizer.ts | 22 +- x-pack/plugins/ml/server/routes/filters.ts | 54 +- x-pack/plugins/ml/server/routes/indices.ts | 6 +- .../ml/server/routes/job_audit_messages.ts | 8 +- .../plugins/ml/server/routes/job_service.ts | 80 +- .../ml/server/routes/job_validation.ts | 22 +- x-pack/plugins/ml/server/routes/modules.ts | 38 +- .../ml/server/routes/notification_settings.ts | 13 +- .../ml/server/routes/results_service.ts | 66 +- x-pack/plugins/ml/server/routes/system.ts | 64 +- .../ml/server/shared_services/errors.ts | 12 + .../shared_services/license_checks/errors.ts | 21 + .../shared_services/license_checks/index.ts | 8 + .../{ => license_checks}/license_checks.ts | 7 +- .../providers/anomaly_detectors.ts | 35 +- .../shared_services/providers/job_service.ts | 30 +- .../shared_services/providers/modules.ts | 103 +- .../providers/results_service.ts | 31 +- .../shared_services/providers/system.ts | 95 +- .../server/shared_services/shared_services.ts | 105 +- .../signals/find_ml_signals.ts | 6 +- .../signals/signal_rule_alert_type.ts | 9 +- .../server/lib/machine_learning/authz.ts | 3 +- .../server/lib/machine_learning/index.ts | 5 +- .../usage/detections/detections_helpers.ts | 14 +- .../server/routes/api/field_histograms.ts | 2 +- .../server/lib/alerts/duration_anomaly.ts | 23 +- .../apis/ml/data_frame_analytics/update.ts | 2 +- .../data_visualizer/get_field_histograms.ts | 3 +- .../ml/data_visualizer/get_field_stats.ts | 3 +- .../ml/data_visualizer/get_overall_stats.ts | 3 +- .../ml/fields_service/field_cardinality.ts | 3 +- .../ml/fields_service/time_field_range.ts | 3 +- .../apis/ml/filters/create_filters.ts | 2 +- .../apis/ml/filters/get_filters.ts | 2 +- .../apis/ml/filters/update_filters.ts | 2 +- .../apis/ml/jobs/close_jobs.ts | 8 +- 106 files changed, 1573 insertions(+), 2568 deletions(-) delete mode 100644 x-pack/plugins/ml/server/client/elasticsearch_ml.test.ts delete mode 100644 x-pack/plugins/ml/server/client/elasticsearch_ml.ts create mode 100644 x-pack/plugins/ml/server/shared_services/errors.ts create mode 100644 x-pack/plugins/ml/server/shared_services/license_checks/errors.ts create mode 100644 x-pack/plugins/ml/server/shared_services/license_checks/index.ts rename x-pack/plugins/ml/server/shared_services/{ => license_checks}/license_checks.ts (66%) diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 3abc89c470b21..e7eb7b8de65e3 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -73,10 +73,9 @@ export function registerTransactionDurationAnomalyAlertType({ return; } const alertParams = params as TypeOf; - const mlClient = services.getLegacyScopedClusterClient(ml.mlClient); - const request = { params: 'DummyKibanaRequest' } as KibanaRequest; - const { mlAnomalySearch } = ml.mlSystemProvider(mlClient, request); - const anomalyDetectors = ml.anomalyDetectorsProvider(mlClient, request); + const request = {} as KibanaRequest; + const { mlAnomalySearch } = ml.mlSystemProvider(request); + const anomalyDetectors = ml.anomalyDetectorsProvider(request); const mlJobIds = await getMLJobIds( anomalyDetectors, diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index a242a0adb6d4c..6b69e57389dff 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -122,13 +122,10 @@ function getMlSetup( if (!ml) { return; } - const mlClient = ml.mlClient.asScoped(request); - const mlSystem = ml.mlSystemProvider(mlClient, request); + return { - mlClient, - mlSystem, - modules: ml.modulesProvider(mlClient, request, savedObjectsClient), - anomalyDetectors: ml.anomalyDetectorsProvider(mlClient, request), - mlAnomalySearch: mlSystem.mlAnomalySearch, + mlSystem: ml.mlSystemProvider(request), + anomalyDetectors: ml.anomalyDetectorsProvider(request), + modules: ml.modulesProvider(request, savedObjectsClient), }; } diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 7cd6383a9b2e5..51f91d7189db7 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -152,9 +152,8 @@ export class InfraServerPlugin { core.http.registerRouteHandlerContext( 'infra', (context, request): InfraRequestHandlerContext => { - const mlSystem = context.ml && plugins.ml?.mlSystemProvider(context.ml?.mlClient, request); - const mlAnomalyDetectors = - context.ml && plugins.ml?.anomalyDetectorsProvider(context.ml?.mlClient, request); + const mlSystem = plugins.ml?.mlSystemProvider(request); + const mlAnomalyDetectors = plugins.ml?.anomalyDetectorsProvider(request); const spaceId = plugins.spaces?.spacesService.getSpaceId(request) || 'default'; return { diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_errors/errors.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_errors/errors.tsx index f723ad1a752bf..bcbba67e9cc60 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_errors/errors.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_errors/errors.tsx @@ -93,11 +93,11 @@ function title(statuses: Statuses) { } } -function ImportError(error: any, key: number) { +const ImportError: FC<{ error: any }> = ({ error }) => { const errorObj = toString(error); return ( - -

{errorObj.msg}

+ <> +

{errorObj.msg}

{errorObj.more !== undefined && ( )} -
+ ); -} +}; function toString(error: any): ImportError { if (typeof error === 'string') { @@ -127,11 +127,11 @@ function toString(error: any): ImportError { return { msg: error.msg }; } else if (error.error !== undefined) { if (typeof error.error === 'object') { - if (error.error.msg !== undefined) { + if (error.error.reason !== undefined) { // this will catch a bulk ingest failure - const errorObj: ImportError = { msg: error.error.msg }; - if (error.error.body !== undefined) { - errorObj.more = error.error.response; + const errorObj: ImportError = { msg: error.error.reason }; + if (error.error.root_cause !== undefined) { + errorObj.more = JSON.stringify(error.error.root_cause); } return errorObj; } 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 328cd1a5ef8d7..d61e75fd21b5a 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 @@ -71,9 +71,7 @@ export class ChartLoader { splitFieldName, splitFieldValue ); - if (resp.error !== undefined) { - throw resp.error; - } + return resp.results; } return {}; @@ -105,9 +103,7 @@ export class ChartLoader { aggFieldPairNames, splitFieldName ); - if (resp.error !== undefined) { - throw resp.error; - } + return resp.results; } return {}; diff --git a/x-pack/plugins/ml/server/client/elasticsearch_ml.test.ts b/x-pack/plugins/ml/server/client/elasticsearch_ml.test.ts deleted file mode 100644 index 5ad0db3c58ce4..0000000000000 --- a/x-pack/plugins/ml/server/client/elasticsearch_ml.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { elasticsearchJsPlugin } from './elasticsearch_ml'; - -interface Endpoint { - fmt: string; -} - -interface ClientAction { - urls?: Endpoint[]; - url: Endpoint; -} - -describe('ML - Endpoints', () => { - // Check all paths in the ML elasticsearchJsPlugin start with a leading forward slash - // so they work if Kibana is run behind a reverse proxy - const PATH_START: string = '/'; - const urls: string[] = []; - - // Stub objects - const Client = { - prototype: {}, - }; - - const components = { - clientAction: { - factory(obj: ClientAction) { - // add each endpoint URL to a list - if (obj.urls) { - obj.urls.forEach((url) => { - urls.push(url.fmt); - }); - } - if (obj.url) { - urls.push(obj.url.fmt); - } - }, - namespaceFactory() { - return { - prototype: {}, - }; - }, - }, - }; - - // Stub elasticsearchJsPlugin - elasticsearchJsPlugin(Client, null, components); - - describe('paths', () => { - it(`should start with ${PATH_START}`, () => { - urls.forEach((url) => { - expect(url[0]).toEqual(PATH_START); - }); - }); - }); -}); diff --git a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts b/x-pack/plugins/ml/server/client/elasticsearch_ml.ts deleted file mode 100644 index 63153d18cb10b..0000000000000 --- a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts +++ /dev/null @@ -1,929 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export const elasticsearchJsPlugin = (Client: any, config: any, components: any) => { - const ca = components.clientAction.factory; - - Client.prototype.ml = components.clientAction.namespaceFactory(); - const ml = Client.prototype.ml.prototype; - - /** - * Perform a [ml.authenticate](Retrieve details about the currently authenticated user) request - * - * @param {Object} params - An object with parameters used to carry out this action - */ - ml.jobs = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>', - req: { - jobId: { - type: 'list', - }, - }, - }, - { - fmt: '/_ml/anomaly_detectors/', - }, - ], - method: 'GET', - }); - - ml.jobStats = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/_stats', - req: { - jobId: { - type: 'list', - }, - }, - }, - { - fmt: '/_ml/anomaly_detectors/_stats', - }, - ], - method: 'GET', - }); - - ml.addJob = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>', - req: { - jobId: { - type: 'string', - }, - }, - }, - ], - needBody: true, - method: 'PUT', - }); - - ml.openJob = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/_open', - req: { - jobId: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - }); - - ml.closeJob = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/_close?force=<%=force%>', - req: { - jobId: { - type: 'string', - }, - force: { - type: 'boolean', - }, - }, - }, - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/_close', - req: { - jobId: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - }); - - // Currently the endpoint uses a default size of 100 unless a size is supplied. - // So until paging is supported in the UI, explicitly supply a size of 1000 - // to match the max number of docs that the endpoint can return. - ml.getDataFrameAnalytics = ca({ - urls: [ - { - fmt: '/_ml/data_frame/analytics/<%=analyticsId%>', - req: { - analyticsId: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/data_frame/analytics/_all?size=1000', - }, - ], - method: 'GET', - }); - - ml.getDataFrameAnalyticsStats = ca({ - urls: [ - { - fmt: '/_ml/data_frame/analytics/<%=analyticsId%>/_stats', - req: { - analyticsId: { - type: 'string', - }, - }, - }, - { - // Currently the endpoint uses a default size of 100 unless a size is supplied. - // So until paging is supported in the UI, explicitly supply a size of 1000 - // to match the max number of docs that the endpoint can return. - fmt: '/_ml/data_frame/analytics/_all/_stats?size=1000', - }, - ], - method: 'GET', - }); - - ml.createDataFrameAnalytics = ca({ - urls: [ - { - fmt: '/_ml/data_frame/analytics/<%=analyticsId%>', - req: { - analyticsId: { - type: 'string', - }, - }, - }, - ], - needBody: true, - method: 'PUT', - }); - - ml.evaluateDataFrameAnalytics = ca({ - urls: [ - { - fmt: '/_ml/data_frame/_evaluate', - }, - ], - needBody: true, - method: 'POST', - }); - - ml.explainDataFrameAnalytics = ca({ - urls: [ - { - fmt: '/_ml/data_frame/analytics/_explain', - }, - ], - needBody: true, - method: 'POST', - }); - - ml.deleteDataFrameAnalytics = ca({ - urls: [ - { - fmt: '/_ml/data_frame/analytics/<%=analyticsId%>', - req: { - analyticsId: { - type: 'string', - }, - }, - }, - ], - method: 'DELETE', - }); - - ml.startDataFrameAnalytics = ca({ - urls: [ - { - fmt: '/_ml/data_frame/analytics/<%=analyticsId%>/_start', - req: { - analyticsId: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - }); - - ml.stopDataFrameAnalytics = ca({ - urls: [ - { - fmt: '/_ml/data_frame/analytics/<%=analyticsId%>/_stop?&force=<%=force%>', - req: { - analyticsId: { - type: 'string', - }, - force: { - type: 'boolean', - }, - }, - }, - ], - method: 'POST', - }); - - ml.updateDataFrameAnalytics = ca({ - urls: [ - { - fmt: '/_ml/data_frame/analytics/<%=analyticsId%>/_update', - req: { - analyticsId: { - type: 'string', - }, - }, - }, - ], - needBody: true, - method: 'POST', - }); - - ml.deleteJob = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>?&force=<%=force%>&wait_for_completion=false', - req: { - jobId: { - type: 'string', - }, - force: { - type: 'boolean', - }, - }, - }, - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>?&wait_for_completion=false', - req: { - jobId: { - type: 'string', - }, - }, - }, - ], - method: 'DELETE', - }); - - ml.updateJob = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/_update', - req: { - jobId: { - type: 'string', - }, - }, - }, - ], - needBody: true, - method: 'POST', - }); - - ml.datafeeds = ca({ - urls: [ - { - fmt: '/_ml/datafeeds/<%=datafeedId%>', - req: { - datafeedId: { - type: 'list', - }, - }, - }, - { - fmt: '/_ml/datafeeds/', - }, - ], - method: 'GET', - }); - - ml.datafeedStats = ca({ - urls: [ - { - fmt: '/_ml/datafeeds/<%=datafeedId%>/_stats', - req: { - datafeedId: { - type: 'list', - }, - }, - }, - { - fmt: '/_ml/datafeeds/_stats', - }, - ], - method: 'GET', - }); - - ml.addDatafeed = ca({ - urls: [ - { - fmt: '/_ml/datafeeds/<%=datafeedId%>', - req: { - datafeedId: { - type: 'string', - }, - }, - }, - ], - needBody: true, - method: 'PUT', - }); - - ml.updateDatafeed = ca({ - urls: [ - { - fmt: '/_ml/datafeeds/<%=datafeedId%>/_update', - req: { - datafeedId: { - type: 'string', - }, - }, - }, - ], - needBody: true, - method: 'POST', - }); - - ml.deleteDatafeed = ca({ - urls: [ - { - fmt: '/_ml/datafeeds/<%=datafeedId%>?force=<%=force%>', - req: { - datafeedId: { - type: 'string', - }, - force: { - type: 'boolean', - }, - }, - }, - { - fmt: '/_ml/datafeeds/<%=datafeedId%>', - req: { - datafeedId: { - type: 'string', - }, - }, - }, - ], - method: 'DELETE', - }); - - ml.startDatafeed = ca({ - urls: [ - { - fmt: '/_ml/datafeeds/<%=datafeedId%>/_start?&start=<%=start%>&end=<%=end%>', - req: { - datafeedId: { - type: 'string', - }, - start: { - type: 'string', - }, - end: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/datafeeds/<%=datafeedId%>/_start?&start=<%=start%>', - req: { - datafeedId: { - type: 'string', - }, - start: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/datafeeds/<%=datafeedId%>/_start', - req: { - datafeedId: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - }); - - ml.stopDatafeed = ca({ - urls: [ - { - fmt: '/_ml/datafeeds/<%=datafeedId%>/_stop?force=<%=force%>', - req: { - datafeedId: { - type: 'string', - }, - force: { - type: 'boolean', - }, - }, - }, - { - fmt: '/_ml/datafeeds/<%=datafeedId%>/_stop', - req: { - datafeedId: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - }); - - ml.validateDetector = ca({ - url: { - fmt: '/_ml/anomaly_detectors/_validate/detector', - }, - needBody: true, - method: 'POST', - }); - - ml.estimateModelMemory = ca({ - url: { - fmt: '/_ml/anomaly_detectors/_estimate_model_memory', - }, - needBody: true, - method: 'POST', - }); - - ml.datafeedPreview = ca({ - url: { - fmt: '/_ml/datafeeds/<%=datafeedId%>/_preview', - req: { - datafeedId: { - type: 'string', - }, - }, - }, - method: 'GET', - }); - - ml.forecast = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/_forecast?&duration=<%=duration%>', - req: { - jobId: { - type: 'string', - }, - duration: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/_forecast', - req: { - jobId: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - }); - - ml.records = ca({ - url: { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/records', - req: { - jobId: { - type: 'string', - }, - }, - }, - method: 'POST', - }); - - ml.buckets = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/buckets', - req: { - jobId: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/buckets/<%=timestamp%>', - req: { - jobId: { - type: 'string', - }, - timestamp: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - }); - - ml.overallBuckets = ca({ - url: { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/overall_buckets', - req: { - jobId: { - type: 'string', - }, - }, - }, - method: 'POST', - }); - - ml.privilegeCheck = ca({ - url: { - fmt: '/_security/user/_has_privileges', - }, - needBody: true, - method: 'POST', - }); - // Currently the endpoint uses a default size of 100 unless a size is supplied. So until paging is supported in the UI, explicitly supply a size of 1000 - ml.calendars = ca({ - urls: [ - { - fmt: '/_ml/calendars/<%=calendarId%>', - req: { - calendarId: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/calendars?size=1000', - }, - ], - method: 'GET', - }); - - ml.deleteCalendar = ca({ - url: { - fmt: '/_ml/calendars/<%=calendarId%>', - req: { - calendarId: { - type: 'string', - }, - }, - }, - method: 'DELETE', - }); - - ml.addCalendar = ca({ - url: { - fmt: '/_ml/calendars/<%=calendarId%>', - req: { - calendarId: { - type: 'string', - }, - }, - }, - needBody: true, - method: 'PUT', - }); - - ml.addJobToCalendar = ca({ - url: { - fmt: '/_ml/calendars/<%=calendarId%>/jobs/<%=jobId%>', - req: { - calendarId: { - type: 'string', - }, - jobId: { - type: 'string', - }, - }, - }, - method: 'PUT', - }); - - ml.removeJobFromCalendar = ca({ - url: { - fmt: '/_ml/calendars/<%=calendarId%>/jobs/<%=jobId%>', - req: { - calendarId: { - type: 'string', - }, - jobId: { - type: 'string', - }, - }, - }, - method: 'DELETE', - }); - - ml.events = ca({ - urls: [ - { - fmt: '/_ml/calendars/<%=calendarId%>/events', - req: { - calendarId: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/calendars/<%=calendarId%>/events?&job_id=<%=jobId%>', - req: { - calendarId: { - type: 'string', - }, - jobId: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/calendars/<%=calendarId%>/events?&after=<%=start%>&before=<%=end%>', - req: { - calendarId: { - type: 'string', - }, - start: { - type: 'string', - }, - end: { - type: 'string', - }, - }, - }, - { - fmt: - '/_ml/calendars/<%=calendarId%>/events?&after=<%=start%>&before=<%=end%>&job_id=<%=jobId%>', - req: { - calendarId: { - type: 'string', - }, - start: { - type: 'string', - }, - end: { - type: 'string', - }, - jobId: { - type: 'string', - }, - }, - }, - ], - method: 'GET', - }); - - ml.addEvent = ca({ - url: { - fmt: '/_ml/calendars/<%=calendarId%>/events', - req: { - calendarId: { - type: 'string', - }, - }, - }, - needBody: true, - method: 'POST', - }); - - ml.deleteEvent = ca({ - url: { - fmt: '/_ml/calendars/<%=calendarId%>/events/<%=eventId%>', - req: { - calendarId: { - type: 'string', - }, - eventId: { - type: 'string', - }, - }, - }, - method: 'DELETE', - }); - // Currently the endpoint uses a default size of 100 unless a size is supplied. So until paging is supported in the UI, explicitly supply a size of 1000 - ml.filters = ca({ - urls: [ - { - fmt: '/_ml/filters/<%=filterId%>', - req: { - filterId: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/filters?size=1000', - }, - ], - method: 'GET', - }); - - ml.addFilter = ca({ - url: { - fmt: '/_ml/filters/<%=filterId%>', - req: { - filterId: { - type: 'string', - }, - }, - }, - needBody: true, - method: 'PUT', - }); - - ml.updateFilter = ca({ - urls: [ - { - fmt: '/_ml/filters/<%=filterId%>/_update', - req: { - filterId: { - type: 'string', - }, - }, - }, - ], - needBody: true, - method: 'POST', - }); - - ml.deleteFilter = ca({ - url: { - fmt: '/_ml/filters/<%=filterId%>', - req: { - filterId: { - type: 'string', - }, - }, - }, - method: 'DELETE', - }); - - ml.info = ca({ - url: { - fmt: '/_ml/info', - }, - method: 'GET', - }); - - ml.fileStructure = ca({ - urls: [ - { - fmt: - '/_ml/find_file_structure?&explain=true&charset=<%=charset%>&format=<%=format%>&has_header_row=<%=has_header_row%>&column_names=<%=column_names%>&delimiter=<%=delimiter%>"e=<%=quote%>&should_trim_fields=<%=should_trim_fields%>&grok_pattern=<%=grok_pattern%>×tamp_field=<%=timestamp_field%>×tamp_format=<%=timestamp_format%>&lines_to_sample=<%=lines_to_sample%>', - req: { - charset: { - type: 'string', - }, - format: { - type: 'string', - }, - has_header_row: { - type: 'string', - }, - column_names: { - type: 'string', - }, - delimiter: { - type: 'string', - }, - quote: { - type: 'string', - }, - should_trim_fields: { - type: 'string', - }, - grok_pattern: { - type: 'string', - }, - timestamp_field: { - type: 'string', - }, - timestamp_format: { - type: 'string', - }, - lines_to_sample: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/find_file_structure?&explain=true', - }, - ], - needBody: true, - method: 'POST', - }); - - ml.rollupIndexCapabilities = ca({ - urls: [ - { - fmt: '/<%=indexPattern%>/_rollup/data', - req: { - indexPattern: { - type: 'string', - }, - }, - }, - ], - method: 'GET', - }); - - ml.categories = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/categories/<%=categoryId%>', - req: { - jobId: { - type: 'string', - }, - categoryId: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/categories', - req: { - jobId: { - type: 'string', - }, - }, - }, - ], - method: 'GET', - }); - - ml.modelSnapshots = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>', - req: { - jobId: { - type: 'string', - }, - snapshotId: { - type: 'string', - }, - }, - }, - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/model_snapshots', - req: { - jobId: { - type: 'string', - }, - }, - }, - ], - method: 'GET', - }); - - ml.updateModelSnapshot = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>/_update', - req: { - jobId: { - type: 'string', - }, - snapshotId: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - needBody: true, - }); - - ml.deleteModelSnapshot = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>', - req: { - jobId: { - type: 'string', - }, - snapshotId: { - type: 'string', - }, - }, - }, - ], - method: 'DELETE', - }); - - ml.revertModelSnapshot = ca({ - urls: [ - { - fmt: '/_ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>/_revert', - req: { - jobId: { - type: 'string', - }, - snapshotId: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - }); -}; diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts index 21d32813c0d51..4dd17f8cf4889 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { getAdminCapabilities, getUserCapabilities } from './__mocks__/ml_capabilities'; import { capabilitiesProvider } from './check_capabilities'; import { MlLicense } from '../../../common/license'; @@ -24,16 +24,28 @@ const mlIsEnabled = async () => true; const mlIsNotEnabled = async () => false; const mlClusterClientNonUpgrade = ({ - callAsInternalUser: async () => ({ - upgrade_mode: false, - }), -} as unknown) as ILegacyScopedClusterClient; + asInternalUser: { + ml: { + info: async () => ({ + body: { + upgrade_mode: false, + }, + }), + }, + }, +} as unknown) as IScopedClusterClient; const mlClusterClientUpgrade = ({ - callAsInternalUser: async () => ({ - upgrade_mode: true, - }), -} as unknown) as ILegacyScopedClusterClient; + asInternalUser: { + ml: { + info: async () => ({ + body: { + upgrade_mode: true, + }, + }), + }, + }, +} as unknown) as IScopedClusterClient; describe('check_capabilities', () => { describe('getCapabilities() - right number of capabilities', () => { diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.ts index c976ab598b28c..c591ec07c7c3b 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; +import { IScopedClusterClient, KibanaRequest } from 'kibana/server'; import { mlLog } from '../../client/log'; import { MlCapabilities, @@ -22,12 +22,12 @@ import { } from './errors'; export function capabilitiesProvider( - mlClusterClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, capabilities: MlCapabilities, mlLicense: MlLicense, isMlEnabledInSpace: () => Promise ) { - const { isUpgradeInProgress } = upgradeCheckProvider(mlClusterClient); + const { isUpgradeInProgress } = upgradeCheckProvider(client); async function getCapabilities(): Promise { const upgradeInProgress = await isUpgradeInProgress(); const isPlatinumOrTrialLicense = mlLicense.isFullLicense(); diff --git a/x-pack/plugins/ml/server/lib/capabilities/upgrade.ts b/x-pack/plugins/ml/server/lib/capabilities/upgrade.ts index 6df4d0c87ecf5..defb70429fa0c 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/upgrade.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/upgrade.ts @@ -4,17 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { mlLog } from '../../client/log'; -export function upgradeCheckProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { +export function upgradeCheckProvider({ asInternalUser }: IScopedClusterClient) { async function isUpgradeInProgress(): Promise { let upgradeInProgress = false; try { - const info = await callAsInternalUser('ml.info'); + const { body } = await asInternalUser.ml.info(); // if ml indices are currently being migrated, upgrade_mode will be set to true // pass this back with the privileges to allow for the disabling of UI controls. - upgradeInProgress = info.upgrade_mode === true; + upgradeInProgress = body.upgrade_mode === true; } catch (error) { // if the ml.info check fails, it could be due to the user having insufficient privileges // most likely they do not have the ml_user role and therefore will be blocked from using diff --git a/x-pack/plugins/ml/server/lib/check_annotations/index.ts b/x-pack/plugins/ml/server/lib/check_annotations/index.ts index de19f0ead6791..964f9d0b92261 100644 --- a/x-pack/plugins/ml/server/lib/check_annotations/index.ts +++ b/x-pack/plugins/ml/server/lib/check_annotations/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { mlLog } from '../../client/log'; import { @@ -17,18 +17,16 @@ import { // - ML_ANNOTATIONS_INDEX_PATTERN index is present // - ML_ANNOTATIONS_INDEX_ALIAS_READ alias is present // - ML_ANNOTATIONS_INDEX_ALIAS_WRITE alias is present -export async function isAnnotationsFeatureAvailable({ - callAsInternalUser, -}: ILegacyScopedClusterClient) { +export async function isAnnotationsFeatureAvailable({ asInternalUser }: IScopedClusterClient) { try { const indexParams = { index: ML_ANNOTATIONS_INDEX_PATTERN }; - const annotationsIndexExists = await callAsInternalUser('indices.exists', indexParams); + const { body: annotationsIndexExists } = await asInternalUser.indices.exists(indexParams); if (!annotationsIndexExists) { return false; } - const annotationsReadAliasExists = await callAsInternalUser('indices.existsAlias', { + const { body: annotationsReadAliasExists } = await asInternalUser.indices.existsAlias({ index: ML_ANNOTATIONS_INDEX_ALIAS_READ, name: ML_ANNOTATIONS_INDEX_ALIAS_READ, }); @@ -37,7 +35,7 @@ export async function isAnnotationsFeatureAvailable({ return false; } - const annotationsWriteAliasExists = await callAsInternalUser('indices.existsAlias', { + const { body: annotationsWriteAliasExists } = await asInternalUser.indices.existsAlias({ index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, name: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, }); diff --git a/x-pack/plugins/ml/server/lib/license/ml_server_license.ts b/x-pack/plugins/ml/server/lib/license/ml_server_license.ts index bd0a29721248a..6e3019b303b88 100644 --- a/x-pack/plugins/ml/server/lib/license/ml_server_license.ts +++ b/x-pack/plugins/ml/server/lib/license/ml_server_license.ts @@ -7,7 +7,6 @@ import { KibanaRequest, KibanaResponseFactory, RequestHandlerContext, - ILegacyScopedClusterClient, IScopedClusterClient, RequestHandler, } from 'kibana/server'; @@ -15,7 +14,6 @@ import { import { MlLicense } from '../../../common/license'; type Handler = (handlerParams: { - legacyClient: ILegacyScopedClusterClient; client: IScopedClusterClient; request: KibanaRequest; response: KibanaResponseFactory; @@ -42,7 +40,6 @@ function guard(check: () => boolean, handler: Handler) { } return handler({ - legacyClient: context.ml!.mlClient, client: context.core.elasticsearch.client, request, response, diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts index 5be443266ffe1..4c511b567615d 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts @@ -22,19 +22,15 @@ describe('annotation_service', () => { let mlClusterClientSpy = {} as any; beforeEach(() => { - const callAs = jest.fn((action: string) => { - switch (action) { - case 'delete': - case 'index': - return Promise.resolve(acknowledgedResponseMock); - case 'search': - return Promise.resolve(getAnnotationsResponseMock); - } - }); + const callAs = { + delete: jest.fn(() => Promise.resolve({ body: acknowledgedResponseMock })), + index: jest.fn(() => Promise.resolve({ body: acknowledgedResponseMock })), + search: jest.fn(() => Promise.resolve({ body: getAnnotationsResponseMock })), + }; mlClusterClientSpy = { - callAsCurrentUser: callAs, - callAsInternalUser: callAs, + asCurrentUser: callAs, + asInternalUser: callAs, }; }); @@ -52,8 +48,7 @@ describe('annotation_service', () => { const response = await deleteAnnotation(annotationMockId); - expect(mockFunct.callAsInternalUser.mock.calls[0][0]).toBe('delete'); - expect(mockFunct.callAsInternalUser.mock.calls[0][1]).toEqual(deleteParamsMock); + expect(mockFunct.asInternalUser.delete.mock.calls[0][0]).toStrictEqual(deleteParamsMock); expect(response).toBe(acknowledgedResponseMock); done(); }); @@ -73,8 +68,9 @@ describe('annotation_service', () => { const response: GetResponse = await getAnnotations(indexAnnotationArgsMock); - expect(mockFunct.callAsInternalUser.mock.calls[0][0]).toBe('search'); - expect(mockFunct.callAsInternalUser.mock.calls[0][1]).toEqual(getAnnotationsRequestMock); + expect(mockFunct.asInternalUser.search.mock.calls[0][0]).toStrictEqual( + getAnnotationsRequestMock + ); expect(Object.keys(response.annotations)).toHaveLength(1); expect(response.annotations[jobIdMock]).toHaveLength(2); expect(isAnnotations(response.annotations[jobIdMock])).toBeTruthy(); @@ -89,9 +85,9 @@ describe('annotation_service', () => { }; const mlClusterClientSpyError: any = { - callAsInternalUser: jest.fn(() => { - return Promise.resolve(mockEsError); - }), + asInternalUser: { + search: jest.fn(() => Promise.resolve({ body: mockEsError })), + }, }; const { getAnnotations } = annotationServiceProvider(mlClusterClientSpyError); @@ -124,10 +120,8 @@ describe('annotation_service', () => { const response = await indexAnnotation(annotationMock, usernameMock); - expect(mockFunct.callAsInternalUser.mock.calls[0][0]).toBe('index'); - // test if the annotation has been correctly augmented - const indexParamsCheck = mockFunct.callAsInternalUser.mock.calls[0][1]; + const indexParamsCheck = mockFunct.asInternalUser.index.mock.calls[0][0]; const annotation = indexParamsCheck.body; expect(annotation.create_username).toBe(usernameMock); expect(annotation.modified_username).toBe(usernameMock); @@ -154,10 +148,8 @@ describe('annotation_service', () => { const response = await indexAnnotation(annotationMock, usernameMock); - expect(mockFunct.callAsInternalUser.mock.calls[0][0]).toBe('index'); - // test if the annotation has been correctly augmented - const indexParamsCheck = mockFunct.callAsInternalUser.mock.calls[0][1]; + const indexParamsCheck = mockFunct.asInternalUser.index.mock.calls[0][0]; const annotation = indexParamsCheck.body; expect(annotation.create_username).toBe(usernameMock); expect(annotation.modified_username).toBe(usernameMock); @@ -196,9 +188,8 @@ describe('annotation_service', () => { await indexAnnotation(annotation, modifiedUsernameMock); - expect(mockFunct.callAsInternalUser.mock.calls[1][0]).toBe('index'); // test if the annotation has been correctly updated - const indexParamsCheck = mockFunct.callAsInternalUser.mock.calls[1][1]; + const indexParamsCheck = mockFunct.asInternalUser.index.mock.calls[0][0]; const modifiedAnnotation = indexParamsCheck.body; expect(modifiedAnnotation.annotation).toBe(modifiedAnnotationText); expect(modifiedAnnotation.create_username).toBe(originalUsernameMock); diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index a585449db0a25..24f1d6951c940 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -7,7 +7,7 @@ import Boom from 'boom'; import each from 'lodash/each'; import get from 'lodash/get'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { ANNOTATION_EVENT_USER, ANNOTATION_TYPE } from '../../../common/constants/annotations'; import { PARTITION_FIELDS } from '../../../common/constants/anomalies'; @@ -67,17 +67,17 @@ export interface GetResponse { export interface IndexParams { index: string; body: Annotation; - refresh?: string; + refresh: boolean | 'wait_for' | undefined; id?: string; } export interface DeleteParams { index: string; - refresh?: string; + refresh: boolean | 'wait_for' | undefined; id: string; } -export function annotationProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { +export function annotationProvider({ asInternalUser }: IScopedClusterClient) { async function indexAnnotation(annotation: Annotation, username: string) { if (isAnnotation(annotation) === false) { // No need to translate, this will not be exposed in the UI. @@ -104,7 +104,8 @@ export function annotationProvider({ callAsInternalUser }: ILegacyScopedClusterC delete params.body.key; } - return await callAsInternalUser('index', params); + const { body } = await asInternalUser.index(params); + return body; } async function getAnnotations({ @@ -287,14 +288,14 @@ export function annotationProvider({ callAsInternalUser }: ILegacyScopedClusterC }; try { - const resp = await callAsInternalUser('search', params); + const { body } = await asInternalUser.search(params); - if (resp.error !== undefined && resp.message !== undefined) { + if (body.error !== undefined && body.message !== undefined) { // No need to translate, this will not be exposed in the UI. throw new Error(`Annotations couldn't be retrieved from Elasticsearch.`); } - const docs: Annotations = get(resp, ['hits', 'hits'], []).map((d: EsResult) => { + const docs: Annotations = get(body, ['hits', 'hits'], []).map((d: EsResult) => { // get the original source document and the document id, we need it // to identify the annotation when editing/deleting it. // if original `event` is undefined then substitute with 'user` by default @@ -306,7 +307,7 @@ export function annotationProvider({ callAsInternalUser }: ILegacyScopedClusterC } as Annotation; }); - const aggregations = get(resp, ['aggregations'], {}) as EsAggregationResult; + const aggregations = get(body, ['aggregations'], {}) as EsAggregationResult; if (fields) { obj.aggregations = aggregations; } @@ -330,13 +331,14 @@ export function annotationProvider({ callAsInternalUser }: ILegacyScopedClusterC } async function deleteAnnotation(id: string) { - const param: DeleteParams = { + const params: DeleteParams = { index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, id, refresh: 'wait_for', }; - return await callAsInternalUser('delete', param); + const { body } = await asInternalUser.delete(params); + return body; } return { diff --git a/x-pack/plugins/ml/server/models/annotation_service/index.ts b/x-pack/plugins/ml/server/models/annotation_service/index.ts index e17af2a154b87..9fcb84e2938ae 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/index.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/index.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { annotationProvider } from './annotation'; -export function annotationServiceProvider(mlClusterClient: ILegacyScopedClusterClient) { +export function annotationServiceProvider(client: IScopedClusterClient) { return { - ...annotationProvider(mlClusterClient), + ...annotationProvider(client), }; } 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 eeabb24d9be3b..5b52414d6753a 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; export interface BucketSpanEstimatorData { @@ -21,6 +21,6 @@ export interface BucketSpanEstimatorData { } export function estimateBucketSpanFactory({ - callAsCurrentUser, - callAsInternalUser, -}: ILegacyScopedClusterClient): (config: BucketSpanEstimatorData) => Promise; + asCurrentUser, + asInternalUser, +}: IScopedClusterClient): (config: BucketSpanEstimatorData) => Promise; 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 381c615051e3b..1d59db8fa564f 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 @@ -16,10 +16,10 @@ import { INTERVALS } from './intervals'; import { singleSeriesCheckerFactory } from './single_series_checker'; import { polledDataCheckerFactory } from './polled_data_checker'; -export function estimateBucketSpanFactory(mlClusterClient) { - const { callAsCurrentUser, callAsInternalUser } = mlClusterClient; - const PolledDataChecker = polledDataCheckerFactory(mlClusterClient); - const SingleSeriesChecker = singleSeriesCheckerFactory(mlClusterClient); +export function estimateBucketSpanFactory(client) { + const { asCurrentUser, asInternalUser } = client; + const PolledDataChecker = polledDataCheckerFactory(client); + const SingleSeriesChecker = singleSeriesCheckerFactory(client); class BucketSpanEstimator { constructor( @@ -246,21 +246,22 @@ export function estimateBucketSpanFactory(mlClusterClient) { const getFieldCardinality = function (index, field) { return new Promise((resolve, reject) => { - callAsCurrentUser('search', { - index, - size: 0, - body: { - aggs: { - field_count: { - cardinality: { - field, + asCurrentUser + .search({ + index, + size: 0, + body: { + aggs: { + field_count: { + cardinality: { + field, + }, }, }, }, - }, - }) - .then((resp) => { - const value = get(resp, ['aggregations', 'field_count', 'value'], 0); + }) + .then(({ body }) => { + const value = get(body, ['aggregations', 'field_count', 'value'], 0); resolve(value); }) .catch((resp) => { @@ -278,28 +279,29 @@ export function estimateBucketSpanFactory(mlClusterClient) { getFieldCardinality(index, field) .then((value) => { const numPartitions = Math.floor(value / NUM_PARTITIONS) || 1; - callAsCurrentUser('search', { - index, - size: 0, - body: { - query, - aggs: { - fields_bucket_counts: { - terms: { - field, - include: { - partition: 0, - num_partitions: numPartitions, + asCurrentUser + .search({ + index, + size: 0, + body: { + query, + aggs: { + fields_bucket_counts: { + terms: { + field, + include: { + partition: 0, + num_partitions: numPartitions, + }, }, }, }, }, - }, - }) - .then((partitionResp) => { + }) + .then(({ body }) => { // eslint-disable-next-line camelcase - if (partitionResp.aggregations?.fields_bucket_counts?.buckets !== undefined) { - const buckets = partitionResp.aggregations.fields_bucket_counts.buckets; + if (body.aggregations?.fields_bucket_counts?.buckets !== undefined) { + const buckets = body.aggregations.fields_bucket_counts.buckets; fieldValues = buckets.map((b) => b.key); } resolve(fieldValues); @@ -338,21 +340,21 @@ export function estimateBucketSpanFactory(mlClusterClient) { return new Promise((resolve, reject) => { // fetch the `search.max_buckets` cluster setting so we're able to // adjust aggregations to not exceed that limit. - callAsInternalUser('cluster.getSettings', { - flatSettings: true, - includeDefaults: true, - filterPath: '*.*max_buckets', - }) - .then((settings) => { - if (typeof settings !== 'object') { + asInternalUser.cluster + .getSettings({ + flat_settings: true, + include_defaults: true, + filter_path: '*.*max_buckets', + }) + .then(({ body }) => { + if (typeof body !== 'object') { reject('Unable to retrieve cluster settings'); } // search.max_buckets could exist in default, persistent or transient cluster settings - const maxBucketsSetting = (settings.defaults || - settings.persistent || - settings.transient || - {})['search.max_buckets']; + const maxBucketsSetting = (body.defaults || body.persistent || body.transient || {})[ + 'search.max_buckets' + ]; if (maxBucketsSetting === undefined) { reject('Unable to retrieve cluster setting search.max_buckets'); 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 f7c7dd8172ea5..35c4f1a0a741b 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 @@ -4,22 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; import { estimateBucketSpanFactory, BucketSpanEstimatorData } from './bucket_span_estimator'; -const callAs = () => { - return new Promise((resolve) => { - resolve({}); - }) as Promise; +const callAs = { + search: () => Promise.resolve({ body: {} }), + cluster: { getSettings: () => Promise.resolve({ body: {} }) }, }; -const mlClusterClient: ILegacyScopedClusterClient = { - callAsCurrentUser: callAs, - callAsInternalUser: callAs, -}; +const mlClusterClient = ({ + asCurrentUser: callAs, + asInternalUser: callAs, +} as unknown) as IScopedClusterClient; // mock configuration to be passed to the estimator const formConfig: BucketSpanEstimatorData = { 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 d3bbd59f3cf9b..fd0cab7c0625d 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 @@ -12,7 +12,7 @@ import get from 'lodash/get'; -export function polledDataCheckerFactory({ callAsCurrentUser }) { +export function polledDataCheckerFactory({ asCurrentUser }) { class PolledDataChecker { constructor(index, timeField, duration, query) { this.index = index; @@ -65,14 +65,15 @@ export function polledDataCheckerFactory({ callAsCurrentUser }) { return search; } - performSearch(intervalMs) { - const body = this.createSearch(intervalMs); + async performSearch(intervalMs) { + const searchBody = this.createSearch(intervalMs); - return callAsCurrentUser('search', { + const { body } = await asCurrentUser.search({ index: this.index, size: 0, - body, + body: searchBody, }); + return body; } // test that the coefficient of variation of time difference between non-empty buckets is small 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 a5449395501dc..750f0cfc0b4a8 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 @@ -13,7 +13,7 @@ import { mlLog } from '../../client/log'; import { INTERVALS, LONG_INTERVALS } from './intervals'; -export function singleSeriesCheckerFactory({ callAsCurrentUser }) { +export function singleSeriesCheckerFactory({ asCurrentUser }) { const REF_DATA_INTERVAL = { name: '1h', ms: 3600000 }; class SingleSeriesChecker { @@ -184,14 +184,15 @@ export function singleSeriesCheckerFactory({ callAsCurrentUser }) { return search; } - performSearch(intervalMs) { - const body = this.createSearch(intervalMs); + async performSearch(intervalMs) { + const searchBody = this.createSearch(intervalMs); - return callAsCurrentUser('search', { + const { body } = await asCurrentUser.search({ index: this.index, size: 0, - body, + body: searchBody, }); + return body; } getFullBuckets(buckets) { diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts index bc3c326e7d070..2a2c30601e2ca 100644 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -5,13 +5,13 @@ */ import numeral from '@elastic/numeral'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { MLCATEGORY } from '../../../common/constants/field_types'; import { AnalysisConfig } from '../../../common/types/anomaly_detection_jobs'; import { fieldsServiceProvider } from '../fields_service'; import { MlInfoResponse } from '../../../common/types/ml_server_info'; -interface ModelMemoryEstimationResult { +export interface ModelMemoryEstimationResult { /** * Result model memory limit */ @@ -29,15 +29,15 @@ interface ModelMemoryEstimationResult { /** * Response of the _estimate_model_memory endpoint. */ -export interface ModelMemoryEstimate { +export interface ModelMemoryEstimateResponse { model_memory_estimate: string; } /** * Retrieves overall and max bucket cardinalities. */ -const cardinalityCheckProvider = (mlClusterClient: ILegacyScopedClusterClient) => { - const fieldsService = fieldsServiceProvider(mlClusterClient); +const cardinalityCheckProvider = (client: IScopedClusterClient) => { + const fieldsService = fieldsServiceProvider(client); return async ( analysisConfig: AnalysisConfig, @@ -123,9 +123,9 @@ const cardinalityCheckProvider = (mlClusterClient: ILegacyScopedClusterClient) = }; }; -export function calculateModelMemoryLimitProvider(mlClusterClient: ILegacyScopedClusterClient) { - const { callAsInternalUser } = mlClusterClient; - const getCardinalities = cardinalityCheckProvider(mlClusterClient); +export function calculateModelMemoryLimitProvider(client: IScopedClusterClient) { + const { asInternalUser } = client; + const getCardinalities = cardinalityCheckProvider(client); /** * Retrieves an estimated size of the model memory limit used in the job config @@ -141,7 +141,7 @@ export function calculateModelMemoryLimitProvider(mlClusterClient: ILegacyScoped latestMs: number, allowMMLGreaterThanMax = false ): Promise { - const info = (await callAsInternalUser('ml.info')) as MlInfoResponse; + const { body: info } = await asInternalUser.ml.info(); const maxModelMemoryLimit = info.limits.max_model_memory_limit?.toUpperCase(); const effectiveMaxModelMemoryLimit = info.limits.effective_max_model_memory_limit?.toUpperCase(); @@ -154,13 +154,14 @@ export function calculateModelMemoryLimitProvider(mlClusterClient: ILegacyScoped latestMs ); - const estimatedModelMemoryLimit = ((await callAsInternalUser('ml.estimateModelMemory', { + const { body } = await asInternalUser.ml.estimateModelMemory({ body: { analysis_config: analysisConfig, overall_cardinality: overallCardinality, max_bucket_cardinality: maxBucketCardinality, }, - })) as ModelMemoryEstimate).model_memory_estimate.toUpperCase(); + }); + const estimatedModelMemoryLimit = body.model_memory_estimate.toUpperCase(); let modelMemoryLimit = estimatedModelMemoryLimit; let mmlCappedAtMax = false; diff --git a/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts b/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts index b461e71c006ff..95684e790636f 100644 --- a/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts +++ b/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts @@ -5,7 +5,7 @@ */ import { difference } from 'lodash'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { EventManager, CalendarEvent } from './event_manager'; interface BasicCalendar { @@ -23,30 +23,30 @@ export interface FormCalendar extends BasicCalendar { } export class CalendarManager { - private _callAsInternalUser: ILegacyScopedClusterClient['callAsInternalUser']; + private _asInternalUser: IScopedClusterClient['asInternalUser']; private _eventManager: EventManager; - constructor(mlClusterClient: ILegacyScopedClusterClient) { - this._callAsInternalUser = mlClusterClient.callAsInternalUser; - this._eventManager = new EventManager(mlClusterClient); + constructor(client: IScopedClusterClient) { + this._asInternalUser = client.asInternalUser; + this._eventManager = new EventManager(client); } async getCalendar(calendarId: string) { - const resp = await this._callAsInternalUser('ml.calendars', { - calendarId, + const { body } = await this._asInternalUser.ml.getCalendars({ + calendar_id: calendarId, }); - const calendars = resp.calendars; + const calendars = body.calendars; const calendar = calendars[0]; // Endpoint throws a 404 if calendar is not found. calendar.events = await this._eventManager.getCalendarEvents(calendarId); return calendar; } async getAllCalendars() { - const calendarsResp = await this._callAsInternalUser('ml.calendars'); + const { body } = await this._asInternalUser.ml.getCalendars({ size: 1000 }); const events: CalendarEvent[] = await this._eventManager.getAllEvents(); - const calendars: Calendar[] = calendarsResp.calendars; + const calendars: Calendar[] = body.calendars; calendars.forEach((cal) => (cal.events = [])); // loop events and combine with related calendars @@ -71,8 +71,8 @@ export class CalendarManager { async newCalendar(calendar: FormCalendar) { const { calendarId, events, ...newCalendar } = calendar; - await this._callAsInternalUser('ml.addCalendar', { - calendarId, + await this._asInternalUser.ml.putCalendar({ + calendar_id: calendarId, body: newCalendar, }); @@ -106,17 +106,17 @@ export class CalendarManager { // add all new jobs if (jobsToAdd.length) { - await this._callAsInternalUser('ml.addJobToCalendar', { - calendarId, - jobId: jobsToAdd.join(','), + await this._asInternalUser.ml.putCalendarJob({ + calendar_id: calendarId, + job_id: jobsToAdd.join(','), }); } // remove all removed jobs if (jobsToRemove.length) { - await this._callAsInternalUser('ml.removeJobFromCalendar', { - calendarId, - jobId: jobsToRemove.join(','), + await this._asInternalUser.ml.deleteCalendarJob({ + calendar_id: calendarId, + job_id: jobsToRemove.join(','), }); } @@ -137,6 +137,7 @@ export class CalendarManager { } async deleteCalendar(calendarId: string) { - return this._callAsInternalUser('ml.deleteCalendar', { calendarId }); + const { body } = await this._asInternalUser.ml.deleteCalendar({ calendar_id: calendarId }); + return body; } } diff --git a/x-pack/plugins/ml/server/models/calendar/event_manager.ts b/x-pack/plugins/ml/server/models/calendar/event_manager.ts index b670bbe187544..b42068e748394 100644 --- a/x-pack/plugins/ml/server/models/calendar/event_manager.ts +++ b/x-pack/plugins/ml/server/models/calendar/event_manager.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { GLOBAL_CALENDAR } from '../../../common/constants/calendars'; export interface CalendarEvent { @@ -16,39 +16,42 @@ export interface CalendarEvent { } export class EventManager { - private _callAsInternalUser: ILegacyScopedClusterClient['callAsInternalUser']; - constructor({ callAsInternalUser }: ILegacyScopedClusterClient) { - this._callAsInternalUser = callAsInternalUser; + private _asInternalUser: IScopedClusterClient['asInternalUser']; + constructor({ asInternalUser }: IScopedClusterClient) { + this._asInternalUser = asInternalUser; } async getCalendarEvents(calendarId: string) { - const resp = await this._callAsInternalUser('ml.events', { calendarId }); + const { body } = await this._asInternalUser.ml.getCalendarEvents({ calendar_id: calendarId }); - return resp.events; + return body.events; } // jobId is optional async getAllEvents(jobId?: string) { const calendarId = GLOBAL_CALENDAR; - const resp = await this._callAsInternalUser('ml.events', { - calendarId, - jobId, + const { body } = await this._asInternalUser.ml.getCalendarEvents({ + calendar_id: calendarId, + job_id: jobId, }); - return resp.events; + return body.events; } async addEvents(calendarId: string, events: CalendarEvent[]) { const body = { events }; - return await this._callAsInternalUser('ml.addEvent', { - calendarId, + return await this._asInternalUser.ml.postCalendarEvents({ + calendar_id: calendarId, body, }); } async deleteEvent(calendarId: string, eventId: string) { - return this._callAsInternalUser('ml.deleteEvent', { calendarId, eventId }); + return this._asInternalUser.ml.deleteCalendarEvent({ + calendar_id: calendarId, + event_id: eventId, + }); } isEqual(ev1: CalendarEvent, ev2: CalendarEvent) { diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts index 1cb0656e88a0b..0f4cac37d2e8f 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { ML_NOTIFICATION_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { JobMessage } from '../../../common/types/audit_message'; @@ -23,7 +23,7 @@ interface BoolQuery { bool: { [key: string]: any }; } -export function analyticsAuditMessagesProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { +export function analyticsAuditMessagesProvider({ asInternalUser }: IScopedClusterClient) { // search for audit messages, // analyticsId is optional. without it, all analytics will be listed. async function getAnalyticsAuditMessages(analyticsId: string) { @@ -68,27 +68,23 @@ export function analyticsAuditMessagesProvider({ callAsInternalUser }: ILegacySc }); } - try { - const resp = await callAsInternalUser('search', { - index: ML_NOTIFICATION_INDEX_PATTERN, - ignore_unavailable: true, - rest_total_hits_as_int: true, - size: SIZE, - body: { - sort: [{ timestamp: { order: 'desc' } }, { job_id: { order: 'asc' } }], - query, - }, - }); + const { body } = await asInternalUser.search({ + index: ML_NOTIFICATION_INDEX_PATTERN, + ignore_unavailable: true, + rest_total_hits_as_int: true, + size: SIZE, + body: { + sort: [{ timestamp: { order: 'desc' } }, { job_id: { order: 'asc' } }], + query, + }, + }); - let messages = []; - if (resp.hits.total !== 0) { - messages = resp.hits.hits.map((hit: Message) => hit._source); - messages.reverse(); - } - return messages; - } catch (e) { - throw e; + let messages = []; + if (body.hits.total !== 0) { + messages = body.hits.hits.map((hit: Message) => hit._source); + messages.reverse(); } + return messages; } return { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts index 82d7707464308..ab879d4c93b29 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts @@ -4,13 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract, KibanaRequest } from 'kibana/server'; +import { SavedObjectsClientContract, KibanaRequest, IScopedClusterClient } from 'kibana/server'; import { Module } from '../../../common/types/modules'; import { DataRecognizer } from '../data_recognizer'; +const callAs = () => Promise.resolve({ body: {} }); + +const mlClusterClient = ({ + asCurrentUser: callAs, + asInternalUser: callAs, +} as unknown) as IScopedClusterClient; + describe('ML - data recognizer', () => { const dr = new DataRecognizer( - { callAsCurrentUser: jest.fn(), callAsInternalUser: jest.fn() }, + mlClusterClient, ({ find: jest.fn(), bulkCreate: jest.fn(), diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index 206baacd98322..820fcfa9253b6 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -7,15 +7,11 @@ import fs from 'fs'; import Boom from 'boom'; import numeral from '@elastic/numeral'; -import { - KibanaRequest, - ILegacyScopedClusterClient, - SavedObjectsClientContract, -} from 'kibana/server'; +import { KibanaRequest, IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; import moment from 'moment'; import { IndexPatternAttributes } from 'src/plugins/data/server'; import { merge } from 'lodash'; -import { AnalysisLimits, CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; +import { AnalysisLimits } from '../../../common/types/anomaly_detection_jobs'; import { getAuthorizationHeader } from '../../lib/request_authorization'; import { MlInfoResponse } from '../../../common/types/ml_server_info'; import { @@ -46,6 +42,7 @@ import { fieldsServiceProvider } from '../fields_service'; import { jobServiceProvider } from '../job_service'; import { resultsServiceProvider } from '../results_service'; import { JobExistResult, JobStat } from '../../../common/types/data_recognizer'; +import { MlJobsStatsResponse } from '../job_service/jobs'; const ML_DIR = 'ml'; const KIBANA_DIR = 'kibana'; @@ -74,10 +71,6 @@ interface RawModuleConfig { }; } -interface MlJobStats { - jobs: CombinedJobWithStats[]; -} - interface Config { dirName: any; json: RawModuleConfig; @@ -111,9 +104,9 @@ interface SaveResults { } export class DataRecognizer { - private _callAsCurrentUser: ILegacyScopedClusterClient['callAsCurrentUser']; - private _callAsInternalUser: ILegacyScopedClusterClient['callAsInternalUser']; - private _mlClusterClient: ILegacyScopedClusterClient; + private _asCurrentUser: IScopedClusterClient['asCurrentUser']; + private _asInternalUser: IScopedClusterClient['asInternalUser']; + private _client: IScopedClusterClient; private _authorizationHeader: object; private _modulesDir = `${__dirname}/modules`; private _indexPatternName: string = ''; @@ -124,13 +117,13 @@ export class DataRecognizer { jobsForModelMemoryEstimation: Array<{ job: ModuleJob; query: any }> = []; constructor( - mlClusterClient: ILegacyScopedClusterClient, + mlClusterClient: IScopedClusterClient, private savedObjectsClient: SavedObjectsClientContract, request: KibanaRequest ) { - this._mlClusterClient = mlClusterClient; - this._callAsCurrentUser = mlClusterClient.callAsCurrentUser; - this._callAsInternalUser = mlClusterClient.callAsInternalUser; + this._client = mlClusterClient; + this._asCurrentUser = mlClusterClient.asCurrentUser; + this._asInternalUser = mlClusterClient.asInternalUser; this._authorizationHeader = getAuthorizationHeader(request); } @@ -249,18 +242,18 @@ export class DataRecognizer { const index = indexPattern; const size = 0; - const body = { + const searchBody = { query: moduleConfig.query, }; - const resp = await this._callAsCurrentUser('search', { + const { body } = await this._asCurrentUser.search({ index, rest_total_hits_as_int: true, size, - body, + body: searchBody, }); - return resp.hits.total !== 0; + return body.hits.total !== 0; } async listModules() { @@ -518,7 +511,7 @@ export class DataRecognizer { // Add a wildcard at the front of each of the job IDs in the module, // as a prefix may have been supplied when creating the jobs in the module. const jobIds = module.jobs.map((job) => `*${job.id}`); - const { jobsExist } = jobServiceProvider(this._mlClusterClient); + const { jobsExist } = jobServiceProvider(this._client); const jobInfo = await jobsExist(jobIds); // Check if the value for any of the jobs is false. @@ -527,16 +520,16 @@ export class DataRecognizer { if (doJobsExist === true) { // Get the IDs of the jobs created from the module, and their earliest / latest timestamps. - const jobStats: MlJobStats = await this._callAsInternalUser('ml.jobStats', { - jobId: jobIds, + const { body } = await this._asInternalUser.ml.getJobStats({ + job_id: jobIds.join(), }); const jobStatsJobs: JobStat[] = []; - if (jobStats.jobs && jobStats.jobs.length > 0) { - const foundJobIds = jobStats.jobs.map((job) => job.job_id); - const { getLatestBucketTimestampByJob } = resultsServiceProvider(this._mlClusterClient); + if (body.jobs && body.jobs.length > 0) { + const foundJobIds = body.jobs.map((job) => job.job_id); + const { getLatestBucketTimestampByJob } = resultsServiceProvider(this._client); const latestBucketTimestampsByJob = await getLatestBucketTimestampByJob(foundJobIds); - jobStats.jobs.forEach((job) => { + body.jobs.forEach((job) => { const jobStat = { id: job.job_id, } as JobStat; @@ -704,16 +697,15 @@ export class DataRecognizer { job.id = jobId; await this.saveJob(job); return { id: jobId, success: true }; - } catch (error) { - return { id: jobId, success: false, error }; + } catch ({ body }) { + return { id: jobId, success: false, error: body }; } }) ); } async saveJob(job: ModuleJob) { - const { id: jobId, config: body } = job; - return this._callAsInternalUser('ml.addJob', { jobId, body }); + return this._asInternalUser.ml.putJob({ job_id: job.id, body: job.config }); } // save the datafeeds. @@ -725,20 +717,21 @@ export class DataRecognizer { try { await this.saveDatafeed(datafeed); return { id: datafeed.id, success: true, started: false }; - } catch (error) { - return { id: datafeed.id, success: false, started: false, error }; + } catch ({ body }) { + return { id: datafeed.id, success: false, started: false, error: body }; } }) ); } async saveDatafeed(datafeed: ModuleDataFeed) { - const { id: datafeedId, config: body } = datafeed; - return this._callAsInternalUser('ml.addDatafeed', { - datafeedId, - body, - ...this._authorizationHeader, - }); + return this._asInternalUser.ml.putDatafeed( + { + datafeed_id: datafeed.id, + body: datafeed.config, + }, + this._authorizationHeader + ); } async startDatafeeds( @@ -761,10 +754,10 @@ export class DataRecognizer { const result = { started: false } as DatafeedResponse; let opened = false; try { - const openResult = await this._callAsInternalUser('ml.openJob', { - jobId: datafeed.config.job_id, + const { body } = await this._asInternalUser.ml.openJob({ + job_id: datafeed.config.job_id, }); - opened = openResult.opened; + opened = body.opened; } catch (error) { // if the job is already open, a 409 will be returned. if (error.statusCode === 409) { @@ -772,27 +765,27 @@ export class DataRecognizer { } else { opened = false; result.started = false; - result.error = error; + result.error = error.body; } } if (opened) { try { - const duration: { start: number; end?: number } = { start: 0 }; + const duration: { start: string; end?: string } = { start: '0' }; if (start !== undefined) { - duration.start = start; + duration.start = (start as unknown) as string; } if (end !== undefined) { - duration.end = end; + duration.end = (end as unknown) as string; } - await this._callAsInternalUser('ml.startDatafeed', { - datafeedId: datafeed.id, + await this._asInternalUser.ml.startDatafeed({ + datafeed_id: datafeed.id, ...duration, }); result.started = true; - } catch (error) { + } catch ({ body }) { result.started = false; - result.error = error; + result.error = body; } } return result; @@ -995,7 +988,7 @@ export class DataRecognizer { timeField: string, query?: any ): Promise<{ start: number; end: number }> { - const fieldsService = fieldsServiceProvider(this._mlClusterClient); + const fieldsService = fieldsServiceProvider(this._client); const timeFieldRange = await fieldsService.getTimeFieldRange( this._indexPatternName, @@ -1025,7 +1018,7 @@ export class DataRecognizer { if (estimateMML && this.jobsForModelMemoryEstimation.length > 0) { try { - const calculateModelMemoryLimit = calculateModelMemoryLimitProvider(this._mlClusterClient); + const calculateModelMemoryLimit = calculateModelMemoryLimitProvider(this._client); // Checks if all jobs in the module have the same time field configured const firstJobTimeField = this.jobsForModelMemoryEstimation[0].job.config.data_description @@ -1074,11 +1067,13 @@ export class DataRecognizer { job.config.analysis_limits.model_memory_limit = modelMemoryLimit; } } catch (error) { - mlLog.warn(`Data recognizer could not estimate model memory limit ${error}`); + mlLog.warn(`Data recognizer could not estimate model memory limit ${error.body}`); } } - const { limits } = (await this._callAsInternalUser('ml.info')) as MlInfoResponse; + const { + body: { limits }, + } = await this._asInternalUser.ml.info(); const maxMml = limits.max_model_memory_limit; if (!maxMml) { diff --git a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts index 838315d8d272c..dbfa4b5656e5f 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import get from 'lodash/get'; import each from 'lodash/each'; import last from 'lodash/last'; @@ -183,7 +183,7 @@ type BatchStats = | FieldExamples; const getAggIntervals = async ( - { callAsCurrentUser }: ILegacyScopedClusterClient, + { asCurrentUser }: IScopedClusterClient, indexPatternTitle: string, query: any, fields: HistogramField[], @@ -207,7 +207,7 @@ const getAggIntervals = async ( return aggs; }, {} as Record); - const respStats = await callAsCurrentUser('search', { + const { body } = await asCurrentUser.search({ index: indexPatternTitle, size: 0, body: { @@ -218,8 +218,7 @@ const getAggIntervals = async ( }); const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); - const aggregations = - aggsPath.length > 0 ? get(respStats.aggregations, aggsPath) : respStats.aggregations; + const aggregations = aggsPath.length > 0 ? get(body.aggregations, aggsPath) : body.aggregations; return Object.keys(aggregations).reduce((p, aggName) => { const stats = [aggregations[aggName].min, aggregations[aggName].max]; @@ -241,15 +240,15 @@ const getAggIntervals = async ( // export for re-use by transforms plugin export const getHistogramsForFields = async ( - mlClusterClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, indexPatternTitle: string, query: any, fields: HistogramField[], samplerShardSize: number ) => { - const { callAsCurrentUser } = mlClusterClient; + const { asCurrentUser } = client; const aggIntervals = await getAggIntervals( - mlClusterClient, + client, indexPatternTitle, query, fields, @@ -291,7 +290,7 @@ export const getHistogramsForFields = async ( return []; } - const respChartsData = await callAsCurrentUser('search', { + const { body } = await asCurrentUser.search({ index: indexPatternTitle, size: 0, body: { @@ -302,8 +301,7 @@ export const getHistogramsForFields = async ( }); const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); - const aggregations = - aggsPath.length > 0 ? get(respChartsData.aggregations, aggsPath) : respChartsData.aggregations; + const aggregations = aggsPath.length > 0 ? get(body.aggregations, aggsPath) : body.aggregations; const chartsData: ChartData[] = fields.map( (field): ChartData => { @@ -350,12 +348,12 @@ export const getHistogramsForFields = async ( }; export class DataVisualizer { - private _mlClusterClient: ILegacyScopedClusterClient; - private _callAsCurrentUser: ILegacyScopedClusterClient['callAsCurrentUser']; + private _client: IScopedClusterClient; + private _asCurrentUser: IScopedClusterClient['asCurrentUser']; - constructor(mlClusterClient: ILegacyScopedClusterClient) { - this._callAsCurrentUser = mlClusterClient.callAsCurrentUser; - this._mlClusterClient = mlClusterClient; + constructor(client: IScopedClusterClient) { + this._asCurrentUser = client.asCurrentUser; + this._client = client; } // Obtains overall stats on the fields in the supplied index pattern, returning an object @@ -451,7 +449,7 @@ export class DataVisualizer { samplerShardSize: number ): Promise { return await getHistogramsForFields( - this._mlClusterClient, + this._client, indexPatternTitle, query, fields, @@ -621,7 +619,7 @@ export class DataVisualizer { }; }); - const body = { + const searchBody = { query: { bool: { filter: filterCriteria, @@ -630,14 +628,14 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this._callAsCurrentUser('search', { + const { body } = await this._asCurrentUser.search({ index, rest_total_hits_as_int: true, size, - body, + body: searchBody, }); - const aggregations = resp.aggregations; - const totalCount = get(resp, ['hits', 'total'], 0); + const aggregations = body.aggregations; + const totalCount = get(body, ['hits', 'total'], 0); const stats = { totalCount, aggregatableExistsFields: [] as FieldData[], @@ -688,7 +686,7 @@ export class DataVisualizer { const size = 0; const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, query); - const body = { + const searchBody = { query: { bool: { filter: filterCriteria, @@ -697,13 +695,13 @@ export class DataVisualizer { }; filterCriteria.push({ exists: { field } }); - const resp = await this._callAsCurrentUser('search', { + const { body } = await this._asCurrentUser.search({ index, rest_total_hits_as_int: true, size, - body, + body: searchBody, }); - return resp.hits.total > 0; + return body.hits.total > 0; } async getDocumentCountStats( @@ -730,7 +728,7 @@ export class DataVisualizer { }, }; - const body = { + const searchBody = { query: { bool: { filter: filterCriteria, @@ -739,15 +737,15 @@ export class DataVisualizer { aggs, }; - const resp = await this._callAsCurrentUser('search', { + const { body } = await this._asCurrentUser.search({ index, size, - body, + body: searchBody, }); const buckets: { [key: string]: number } = {}; const dataByTimeBucket: Array<{ key: string; doc_count: number }> = get( - resp, + body, ['aggregations', 'eventRate', 'buckets'], [] ); @@ -833,7 +831,7 @@ export class DataVisualizer { } }); - const body = { + const searchBody = { query: { bool: { filter: filterCriteria, @@ -842,12 +840,12 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this._callAsCurrentUser('search', { + const { body } = await this._asCurrentUser.search({ index, size, - body, + body: searchBody, }); - const aggregations = resp.aggregations; + const aggregations = body.aggregations; const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); const batchStats: NumericFieldStats[] = []; fields.forEach((field, i) => { @@ -954,7 +952,7 @@ export class DataVisualizer { } }); - const body = { + const searchBody = { query: { bool: { filter: filterCriteria, @@ -963,12 +961,12 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this._callAsCurrentUser('search', { + const { body } = await this._asCurrentUser.search({ index, size, - body, + body: searchBody, }); - const aggregations = resp.aggregations; + const aggregations = body.aggregations; const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); const batchStats: StringFieldStats[] = []; fields.forEach((field, i) => { @@ -1028,7 +1026,7 @@ export class DataVisualizer { }; }); - const body = { + const searchBody = { query: { bool: { filter: filterCriteria, @@ -1037,12 +1035,12 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this._callAsCurrentUser('search', { + const { body } = await this._asCurrentUser.search({ index, size, - body, + body: searchBody, }); - const aggregations = resp.aggregations; + const aggregations = body.aggregations; const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); const batchStats: DateFieldStats[] = []; fields.forEach((field, i) => { @@ -1095,7 +1093,7 @@ export class DataVisualizer { }; }); - const body = { + const searchBody = { query: { bool: { filter: filterCriteria, @@ -1104,12 +1102,12 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this._callAsCurrentUser('search', { + const { body } = await this._asCurrentUser.search({ index, size, - body, + body: searchBody, }); - const aggregations = resp.aggregations; + const aggregations = body.aggregations; const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); const batchStats: BooleanFieldStats[] = []; fields.forEach((field, i) => { @@ -1157,7 +1155,7 @@ export class DataVisualizer { exists: { field }, }); - const body = { + const searchBody = { _source: field, query: { bool: { @@ -1166,18 +1164,18 @@ export class DataVisualizer { }, }; - const resp = await this._callAsCurrentUser('search', { + const { body } = await this._asCurrentUser.search({ index, rest_total_hits_as_int: true, size, - body, + body: searchBody, }); const stats = { fieldName: field, examples: [] as any[], }; - if (resp.hits.total !== 0) { - const hits = resp.hits.hits; + if (body.hits.total !== 0) { + const hits = body.hits.hits; for (let i = 0; i < hits.length; i++) { // Look in the _source for the field value. // If the field is not in the _source (as will happen if the 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 43a6876f76c49..68427e98750eb 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 @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { duration } from 'moment'; import { parseInterval } from '../../../common/util/parse_interval'; import { initCardinalityFieldsCache } from './fields_aggs_cache'; @@ -14,7 +14,7 @@ import { initCardinalityFieldsCache } from './fields_aggs_cache'; * Service for carrying out queries to obtain data * specific to fields in Elasticsearch indices. */ -export function fieldsServiceProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { +export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { const fieldsAggsCache = initCardinalityFieldsCache(); /** @@ -37,13 +37,13 @@ export function fieldsServiceProvider({ callAsCurrentUser }: ILegacyScopedCluste index: string | string[], fieldNames: string[] ): Promise { - const fieldCapsResp = await callAsCurrentUser('fieldCaps', { + const { body } = await asCurrentUser.fieldCaps({ index, fields: fieldNames, }); const aggregatableFields: string[] = []; fieldNames.forEach((fieldName) => { - const fieldInfo = fieldCapsResp.fields[fieldName]; + const fieldInfo = body.fields[fieldName]; const typeKeys = fieldInfo !== undefined ? Object.keys(fieldInfo) : []; if (typeKeys.length > 0) { const fieldType = typeKeys[0]; @@ -130,12 +130,12 @@ export function fieldsServiceProvider({ callAsCurrentUser }: ILegacyScopedCluste aggs, }; - const aggregations = ( - await callAsCurrentUser('search', { - index, - body, - }) - )?.aggregations; + const { + body: { aggregations }, + } = await asCurrentUser.search({ + index, + body, + }); if (!aggregations) { return {}; @@ -170,7 +170,9 @@ export function fieldsServiceProvider({ callAsCurrentUser }: ILegacyScopedCluste }> { const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; - const resp = await callAsCurrentUser('search', { + const { + body: { aggregations }, + } = await asCurrentUser.search({ index, size: 0, body: { @@ -190,12 +192,12 @@ export function fieldsServiceProvider({ callAsCurrentUser }: ILegacyScopedCluste }, }); - if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { - obj.start.epoch = resp.aggregations.earliest.value; - obj.start.string = resp.aggregations.earliest.value_as_string; + if (aggregations && aggregations.earliest && aggregations.latest) { + obj.start.epoch = aggregations.earliest.value; + obj.start.string = aggregations.earliest.value_as_string; - obj.end.epoch = resp.aggregations.latest.value; - obj.end.string = resp.aggregations.latest.value_as_string; + obj.end.epoch = aggregations.latest.value; + obj.end.string = aggregations.latest.value_as_string; } return obj; } @@ -338,12 +340,12 @@ export function fieldsServiceProvider({ callAsCurrentUser }: ILegacyScopedCluste }, }; - const aggregations = ( - await callAsCurrentUser('search', { - index, - body, - }) - )?.aggregations; + const { + body: { aggregations }, + } = await asCurrentUser.search({ + index, + body, + }); if (!aggregations) { return cachedValues; diff --git a/x-pack/plugins/ml/server/models/file_data_visualizer/file_data_visualizer.ts b/x-pack/plugins/ml/server/models/file_data_visualizer/file_data_visualizer.ts index 9cd71c046b66c..9e502c04fbb7b 100644 --- a/x-pack/plugins/ml/server/models/file_data_visualizer/file_data_visualizer.ts +++ b/x-pack/plugins/ml/server/models/file_data_visualizer/file_data_visualizer.ts @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { AnalysisResult, FormattedOverrides, InputOverrides, + FindFileStructureResponse, } from '../../../common/types/file_datavisualizer'; export type InputData = any[]; -export function fileDataVisualizerProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { - async function analyzeFile(data: any, overrides: any): Promise { - const results = await callAsInternalUser('ml.fileStructure', { +export function fileDataVisualizerProvider({ asInternalUser }: IScopedClusterClient) { + async function analyzeFile(data: InputData, overrides: InputOverrides): Promise { + overrides.explain = overrides.explain === undefined ? 'true' : overrides.explain; + const { body } = await asInternalUser.ml.findFileStructure({ body: data, ...overrides, }); @@ -24,7 +26,7 @@ export function fileDataVisualizerProvider({ callAsInternalUser }: ILegacyScoped return { ...(hasOverrides && { overrides: reducedOverrides }), - results, + results: body, }; } diff --git a/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts b/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts index fc9b333298c9d..6108454c08aa7 100644 --- a/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts +++ b/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { INDEX_META_DATA_CREATED_BY } from '../../../common/constants/file_datavisualizer'; import { ImportResponse, @@ -15,7 +15,7 @@ import { } from '../../../common/types/file_datavisualizer'; import { InputData } from './file_data_visualizer'; -export function importDataProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { +export function importDataProvider({ asCurrentUser }: IScopedClusterClient) { async function importData( id: string, index: string, @@ -40,9 +40,9 @@ export function importDataProvider({ callAsCurrentUser }: ILegacyScopedClusterCl // create the pipeline if one has been supplied if (pipelineId !== undefined) { - const success = await createPipeline(pipelineId, pipeline); - if (success.acknowledged !== true) { - throw success; + const resp = await createPipeline(pipelineId, pipeline); + if (resp.acknowledged !== true) { + throw resp; } } createdPipelineId = pipelineId; @@ -80,7 +80,7 @@ export function importDataProvider({ callAsCurrentUser }: ILegacyScopedClusterCl id, index: createdIndex, pipelineId: createdPipelineId, - error: error.error !== undefined ? error.error : error, + error: error.body !== undefined ? error.body : error, docCount, ingestError: error.ingestError, failures: error.failures || [], @@ -102,7 +102,7 @@ export function importDataProvider({ callAsCurrentUser }: ILegacyScopedClusterCl body.settings = settings; } - await callAsCurrentUser('indices.create', { index, body }); + await asCurrentUser.indices.create({ index, body }); } async function indexData(index: string, pipelineId: string, data: InputData) { @@ -118,7 +118,7 @@ export function importDataProvider({ callAsCurrentUser }: ILegacyScopedClusterCl settings.pipeline = pipelineId; } - const resp = await callAsCurrentUser('bulk', settings); + const { body: resp } = await asCurrentUser.bulk(settings); if (resp.errors) { throw resp; } else { @@ -151,7 +151,8 @@ export function importDataProvider({ callAsCurrentUser }: ILegacyScopedClusterCl } async function createPipeline(id: string, pipeline: any) { - return await callAsCurrentUser('ingest.putPipeline', { id, body: pipeline }); + const { body } = await asCurrentUser.ingest.putPipeline({ id, body: pipeline }); + return body; } function getFailures(items: any[], data: InputData): ImportFailure[] { diff --git a/x-pack/plugins/ml/server/models/filter/filter_manager.ts b/x-pack/plugins/ml/server/models/filter/filter_manager.ts index 768ca1f893b68..19ba1b76f8a60 100644 --- a/x-pack/plugins/ml/server/models/filter/filter_manager.ts +++ b/x-pack/plugins/ml/server/models/filter/filter_manager.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { DetectorRule, DetectorRuleScope } from '../../../common/types/detector_rules'; @@ -58,26 +58,26 @@ interface PartialJob { } export class FilterManager { - private _callAsInternalUser: ILegacyScopedClusterClient['callAsInternalUser']; - constructor({ callAsInternalUser }: ILegacyScopedClusterClient) { - this._callAsInternalUser = callAsInternalUser; + private _asInternalUser: IScopedClusterClient['asInternalUser']; + constructor({ asInternalUser }: IScopedClusterClient) { + this._asInternalUser = asInternalUser; } async getFilter(filterId: string) { try { const [JOBS, FILTERS] = [0, 1]; const results = await Promise.all([ - this._callAsInternalUser('ml.jobs'), - this._callAsInternalUser('ml.filters', { filterId }), + this._asInternalUser.ml.getJobs(), + this._asInternalUser.ml.getFilters({ filter_id: filterId }), ]); - if (results[FILTERS] && results[FILTERS].filters.length) { + if (results[FILTERS] && results[FILTERS].body.filters.length) { let filtersInUse: FiltersInUse = {}; - if (results[JOBS] && results[JOBS].jobs) { - filtersInUse = this.buildFiltersInUse(results[JOBS].jobs); + if (results[JOBS] && results[JOBS].body.jobs) { + filtersInUse = this.buildFiltersInUse(results[JOBS].body.jobs); } - const filter = results[FILTERS].filters[0]; + const filter = results[FILTERS].body.filters[0]; filter.used_by = filtersInUse[filter.filter_id]; return filter; } else { @@ -90,8 +90,8 @@ export class FilterManager { async getAllFilters() { try { - const filtersResp = await this._callAsInternalUser('ml.filters'); - return filtersResp.filters; + const { body } = await this._asInternalUser.ml.getFilters({ size: 1000 }); + return body.filters; } catch (error) { throw Boom.badRequest(error); } @@ -101,14 +101,14 @@ export class FilterManager { try { const [JOBS, FILTERS] = [0, 1]; const results = await Promise.all([ - this._callAsInternalUser('ml.jobs'), - this._callAsInternalUser('ml.filters'), + this._asInternalUser.ml.getJobs(), + this._asInternalUser.ml.getFilters({ size: 1000 }), ]); // Build a map of filter_ids against jobs and detectors using that filter. let filtersInUse: FiltersInUse = {}; - if (results[JOBS] && results[JOBS].jobs) { - filtersInUse = this.buildFiltersInUse(results[JOBS].jobs); + if (results[JOBS] && results[JOBS].body.jobs) { + filtersInUse = this.buildFiltersInUse(results[JOBS].body.jobs); } // For each filter, return just @@ -117,8 +117,8 @@ export class FilterManager { // item_count // jobs using the filter const filterStats: FilterStats[] = []; - if (results[FILTERS] && results[FILTERS].filters) { - results[FILTERS].filters.forEach((filter: Filter) => { + if (results[FILTERS] && results[FILTERS].body.filters) { + results[FILTERS].body.filters.forEach((filter: Filter) => { const stats: FilterStats = { filter_id: filter.filter_id, description: filter.description, @@ -139,7 +139,8 @@ export class FilterManager { const { filterId, ...body } = filter; try { // Returns the newly created filter. - return await this._callAsInternalUser('ml.addFilter', { filterId, body }); + const { body: resp } = await this._asInternalUser.ml.putFilter({ filter_id: filterId, body }); + return resp; } catch (error) { throw Boom.badRequest(error); } @@ -159,17 +160,19 @@ export class FilterManager { } // Returns the newly updated filter. - return await this._callAsInternalUser('ml.updateFilter', { - filterId, + const { body: resp } = await this._asInternalUser.ml.updateFilter({ + filter_id: filterId, body, }); + return resp; } catch (error) { throw Boom.badRequest(error); } } async deleteFilter(filterId: string) { - return this._callAsInternalUser('ml.deleteFilter', { filterId }); + const { body } = await this._asInternalUser.ml.deleteFilter({ filter_id: filterId }); + return body; } buildFiltersInUse(jobsList: PartialJob[]) { diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts index d72552b548b82..afdd3e9bb8ce9 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; export function jobAuditMessagesProvider( - mlClusterClient: ILegacyScopedClusterClient + client: IScopedClusterClient ): { getJobAuditMessages: (jobId?: string, from?: string) => any; getAuditMessagesSummary: (jobIds?: string[]) => any; diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js index 86d80c394137f..3fd5ebf3f68f4 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js @@ -34,14 +34,14 @@ const anomalyDetectorTypeFilter = { }, }; -export function jobAuditMessagesProvider({ callAsInternalUser }) { +export function jobAuditMessagesProvider({ asInternalUser }) { // search for audit messages, // jobId is optional. without it, all jobs will be listed. // from is optional and should be a string formatted in ES time units. e.g. 12h, 1d, 7d async function getJobAuditMessages(jobId, from) { let gte = null; if (jobId !== undefined && from === undefined) { - const jobs = await callAsInternalUser('ml.jobs', { jobId }); + const jobs = await asInternalUser.ml.getJobs({ job_id: jobId }); if (jobs.count > 0 && jobs.jobs !== undefined) { gte = moment(jobs.jobs[0].create_time).valueOf(); } @@ -99,26 +99,22 @@ export function jobAuditMessagesProvider({ callAsInternalUser }) { }); } - try { - const resp = await callAsInternalUser('search', { - index: ML_NOTIFICATION_INDEX_PATTERN, - ignore_unavailable: true, - rest_total_hits_as_int: true, - size: SIZE, - body: { - sort: [{ timestamp: { order: 'desc' } }, { job_id: { order: 'asc' } }], - query, - }, - }); + const { body } = await asInternalUser.search({ + index: ML_NOTIFICATION_INDEX_PATTERN, + ignore_unavailable: true, + rest_total_hits_as_int: true, + size: SIZE, + body: { + sort: [{ timestamp: { order: 'desc' } }, { job_id: { order: 'asc' } }], + query, + }, + }); - let messages = []; - if (resp.hits.total !== 0) { - messages = resp.hits.hits.map((hit) => hit._source); - } - return messages; - } catch (e) { - throw e; + let messages = []; + if (body.hits.total !== 0) { + messages = body.hits.hits.map((hit) => hit._source); } + return messages; } // search highest, most recent audit messages for all jobs for the last 24hrs. @@ -128,65 +124,63 @@ export function jobAuditMessagesProvider({ callAsInternalUser }) { const maxBuckets = 10000; let levelsPerJobAggSize = maxBuckets; - try { - const query = { - bool: { - filter: [ - { - range: { - timestamp: { - gte: 'now-1d', - }, + const query = { + bool: { + filter: [ + { + range: { + timestamp: { + gte: 'now-1d', }, }, - anomalyDetectorTypeFilter, - ], - }, - }; - - // If the jobIds arg is supplied, add a query filter - // to only include those jobIds in the aggregations. - if (Array.isArray(jobIds) && jobIds.length > 0) { - query.bool.filter.push({ - terms: { - job_id: jobIds, }, - }); - levelsPerJobAggSize = jobIds.length; - } + anomalyDetectorTypeFilter, + ], + }, + }; - const resp = await callAsInternalUser('search', { - index: ML_NOTIFICATION_INDEX_PATTERN, - ignore_unavailable: true, - rest_total_hits_as_int: true, - size: 0, - body: { - query, - aggs: { - levelsPerJob: { - terms: { - field: 'job_id', - size: levelsPerJobAggSize, - }, - aggs: { - levels: { - terms: { - field: 'level', - }, - aggs: { - latestMessage: { - terms: { - field: 'message.raw', - size: 1, - order: { - latestMessage: 'desc', - }, + // If the jobIds arg is supplied, add a query filter + // to only include those jobIds in the aggregations. + if (Array.isArray(jobIds) && jobIds.length > 0) { + query.bool.filter.push({ + terms: { + job_id: jobIds, + }, + }); + levelsPerJobAggSize = jobIds.length; + } + + const { body } = await asInternalUser.search({ + index: ML_NOTIFICATION_INDEX_PATTERN, + ignore_unavailable: true, + rest_total_hits_as_int: true, + size: 0, + body: { + query, + aggs: { + levelsPerJob: { + terms: { + field: 'job_id', + size: levelsPerJobAggSize, + }, + aggs: { + levels: { + terms: { + field: 'level', + }, + aggs: { + latestMessage: { + terms: { + field: 'message.raw', + size: 1, + order: { + latestMessage: 'desc', }, - aggs: { - latestMessage: { - max: { - field: 'timestamp', - }, + }, + aggs: { + latestMessage: { + max: { + field: 'timestamp', }, }, }, @@ -196,67 +190,65 @@ export function jobAuditMessagesProvider({ callAsInternalUser }) { }, }, }, - }); + }, + }); - let messagesPerJob = []; - const jobMessages = []; - if ( - resp.hits.total !== 0 && - resp.aggregations && - resp.aggregations.levelsPerJob && - resp.aggregations.levelsPerJob.buckets && - resp.aggregations.levelsPerJob.buckets.length - ) { - messagesPerJob = resp.aggregations.levelsPerJob.buckets; - } + let messagesPerJob = []; + const jobMessages = []; + if ( + body.hits.total !== 0 && + body.aggregations && + body.aggregations.levelsPerJob && + body.aggregations.levelsPerJob.buckets && + body.aggregations.levelsPerJob.buckets.length + ) { + messagesPerJob = body.aggregations.levelsPerJob.buckets; + } - messagesPerJob.forEach((job) => { - // ignore system messages (id==='') - if (job.key !== '' && job.levels && job.levels.buckets && job.levels.buckets.length) { - let highestLevel = 0; - let highestLevelText = ''; - let msgTime = 0; + messagesPerJob.forEach((job) => { + // ignore system messages (id==='') + if (job.key !== '' && job.levels && job.levels.buckets && job.levels.buckets.length) { + let highestLevel = 0; + let highestLevelText = ''; + let msgTime = 0; - job.levels.buckets.forEach((level) => { - const label = level.key; - // note the highest message level - if (LEVEL[label] > highestLevel) { - highestLevel = LEVEL[label]; - if ( - level.latestMessage && - level.latestMessage.buckets && - level.latestMessage.buckets.length - ) { - level.latestMessage.buckets.forEach((msg) => { - // there should only be one result here. - highestLevelText = msg.key; + job.levels.buckets.forEach((level) => { + const label = level.key; + // note the highest message level + if (LEVEL[label] > highestLevel) { + highestLevel = LEVEL[label]; + if ( + level.latestMessage && + level.latestMessage.buckets && + level.latestMessage.buckets.length + ) { + level.latestMessage.buckets.forEach((msg) => { + // there should only be one result here. + highestLevelText = msg.key; - // note the time in ms for the highest level - // so we can filter them out later if they're earlier than the - // job's create time. - if (msg.latestMessage && msg.latestMessage.value_as_string) { - const time = moment(msg.latestMessage.value_as_string); - msgTime = time.valueOf(); - } - }); - } + // note the time in ms for the highest level + // so we can filter them out later if they're earlier than the + // job's create time. + if (msg.latestMessage && msg.latestMessage.value_as_string) { + const time = moment(msg.latestMessage.value_as_string); + msgTime = time.valueOf(); + } + }); } - }); - - if (msgTime !== 0 && highestLevel !== 0) { - jobMessages.push({ - job_id: job.key, - highestLevelText, - highestLevel: levelToText(highestLevel), - msgTime, - }); } + }); + + if (msgTime !== 0 && highestLevel !== 0) { + jobMessages.push({ + job_id: job.key, + highestLevelText, + highestLevel: levelToText(highestLevel), + msgTime, + }); } - }); - return jobMessages; - } catch (e) { - throw e; - } + } + }); + return jobMessages; } function levelToText(level) { 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 98e1be48bb766..c0eb1b72825df 100644 --- a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts +++ b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; import { fillResultsWithTimeouts, isRequestTimeout } from './error_utils'; @@ -26,7 +26,7 @@ interface Results { }; } -export function datafeedsProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { +export function datafeedsProvider({ asInternalUser }: IScopedClusterClient) { async function forceStartDatafeeds(datafeedIds: string[], start?: number, end?: number) { const jobIds = await getJobIdsByDatafeedId(); const doStartsCalled = datafeedIds.reduce((acc, cur) => { @@ -42,8 +42,8 @@ export function datafeedsProvider({ callAsInternalUser }: ILegacyScopedClusterCl try { await startDatafeed(datafeedId, start, end); return { started: true }; - } catch (error) { - return { started: false, error }; + } catch ({ body }) { + return { started: false, error: body }; } } else { return { started: true }; @@ -66,7 +66,7 @@ export function datafeedsProvider({ callAsInternalUser }: ILegacyScopedClusterCl results[datafeedId] = await doStart(datafeedId); return fillResultsWithTimeouts(results, datafeedId, datafeedIds, JOB_STATE.OPENED); } - results[datafeedId] = { started: false, error }; + results[datafeedId] = { started: false, error: error.body }; } } else { results[datafeedId] = { @@ -84,8 +84,8 @@ export function datafeedsProvider({ callAsInternalUser }: ILegacyScopedClusterCl async function openJob(jobId: string) { let opened = false; try { - const resp = await callAsInternalUser('ml.openJob', { jobId }); - opened = resp.opened; + const { body } = await asInternalUser.ml.openJob({ job_id: jobId }); + opened = body.opened; } catch (error) { if (error.statusCode === 409) { opened = true; @@ -97,7 +97,11 @@ export function datafeedsProvider({ callAsInternalUser }: ILegacyScopedClusterCl } async function startDatafeed(datafeedId: string, start?: number, end?: number) { - return callAsInternalUser('ml.startDatafeed', { datafeedId, start, end }); + return asInternalUser.ml.startDatafeed({ + datafeed_id: datafeedId, + start: (start as unknown) as string, + end: (end as unknown) as string, + }); } async function stopDatafeeds(datafeedIds: string[]) { @@ -105,7 +109,12 @@ export function datafeedsProvider({ callAsInternalUser }: ILegacyScopedClusterCl for (const datafeedId of datafeedIds) { try { - results[datafeedId] = await callAsInternalUser('ml.stopDatafeed', { datafeedId }); + const { body } = await asInternalUser.ml.stopDatafeed<{ + started: boolean; + }>({ + datafeed_id: datafeedId, + }); + results[datafeedId] = body; } catch (error) { if (isRequestTimeout(error)) { return fillResultsWithTimeouts(results, datafeedId, datafeedIds, DATAFEED_STATE.STOPPED); @@ -117,11 +126,17 @@ export function datafeedsProvider({ callAsInternalUser }: ILegacyScopedClusterCl } async function forceDeleteDatafeed(datafeedId: string) { - return callAsInternalUser('ml.deleteDatafeed', { datafeedId, force: true }); + const { body } = await asInternalUser.ml.deleteDatafeed({ + datafeed_id: datafeedId, + force: true, + }); + return body; } async function getDatafeedIdsByJobId() { - const { datafeeds } = (await callAsInternalUser('ml.datafeeds')) as MlDatafeedsResponse; + const { + body: { datafeeds }, + } = await asInternalUser.ml.getDatafeeds(); return datafeeds.reduce((acc, cur) => { acc[cur.job_id] = cur.datafeed_id; return acc; @@ -129,7 +144,9 @@ export function datafeedsProvider({ callAsInternalUser }: ILegacyScopedClusterCl } async function getJobIdsByDatafeedId() { - const { datafeeds } = (await callAsInternalUser('ml.datafeeds')) as MlDatafeedsResponse; + const { + body: { datafeeds }, + } = await asInternalUser.ml.getDatafeeds(); return datafeeds.reduce((acc, cur) => { acc[cur.datafeed_id] = cur.job_id; return acc; diff --git a/x-pack/plugins/ml/server/models/job_service/error_utils.ts b/x-pack/plugins/ml/server/models/job_service/error_utils.ts index 8a47993546fb8..dc871a9dce805 100644 --- a/x-pack/plugins/ml/server/models/job_service/error_utils.ts +++ b/x-pack/plugins/ml/server/models/job_service/error_utils.ts @@ -7,11 +7,11 @@ import { i18n } from '@kbn/i18n'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; -const REQUEST_TIMEOUT = 'RequestTimeout'; +const REQUEST_TIMEOUT_NAME = 'RequestTimeout'; type ACTION_STATE = DATAFEED_STATE | JOB_STATE; -export function isRequestTimeout(error: { displayName: string }) { - return error.displayName === REQUEST_TIMEOUT; +export function isRequestTimeout(error: { name: string }) { + return error.name === REQUEST_TIMEOUT_NAME; } interface Results { diff --git a/x-pack/plugins/ml/server/models/job_service/groups.ts b/x-pack/plugins/ml/server/models/job_service/groups.ts index c4ea854c14f87..0f53d27f2eddf 100644 --- a/x-pack/plugins/ml/server/models/job_service/groups.ts +++ b/x-pack/plugins/ml/server/models/job_service/groups.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { CalendarManager } from '../calendar'; import { GLOBAL_CALENDAR } from '../../../common/constants/calendars'; import { Job } from '../../../common/types/anomaly_detection_jobs'; @@ -23,18 +23,19 @@ interface Results { }; } -export function groupsProvider(mlClusterClient: ILegacyScopedClusterClient) { - const calMngr = new CalendarManager(mlClusterClient); - const { callAsInternalUser } = mlClusterClient; +export function groupsProvider(client: IScopedClusterClient) { + const calMngr = new CalendarManager(client); + const { asInternalUser } = client; async function getAllGroups() { const groups: { [id: string]: Group } = {}; const jobIds: { [id: string]: undefined | null } = {}; - const [{ jobs }, calendars] = await Promise.all([ - callAsInternalUser('ml.jobs') as Promise, + const [{ body }, calendars] = await Promise.all([ + asInternalUser.ml.getJobs(), calMngr.getAllCalendars(), ]); + const { jobs } = body; if (jobs) { jobs.forEach((job) => { jobIds[job.job_id] = null; @@ -80,10 +81,10 @@ export function groupsProvider(mlClusterClient: ILegacyScopedClusterClient) { for (const job of jobs) { const { job_id: jobId, groups } = job; try { - await callAsInternalUser('ml.updateJob', { jobId, body: { groups } }); + await asInternalUser.ml.updateJob({ job_id: jobId, body: { groups } }); results[jobId] = { success: true }; - } catch (error) { - results[jobId] = { success: false, error }; + } catch ({ body }) { + results[jobId] = { success: false, error: body }; } } return results; 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 1ff33a7b00f0b..6fea5d3b5a491 100644 --- a/x-pack/plugins/ml/server/models/job_service/index.ts +++ b/x-pack/plugins/ml/server/models/job_service/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { datafeedsProvider } from './datafeeds'; import { jobsProvider } from './jobs'; import { groupsProvider } from './groups'; @@ -12,14 +12,14 @@ import { newJobCapsProvider } from './new_job_caps'; import { newJobChartsProvider, topCategoriesProvider } from './new_job'; import { modelSnapshotProvider } from './model_snapshots'; -export function jobServiceProvider(mlClusterClient: ILegacyScopedClusterClient) { +export function jobServiceProvider(client: IScopedClusterClient) { return { - ...datafeedsProvider(mlClusterClient), - ...jobsProvider(mlClusterClient), - ...groupsProvider(mlClusterClient), - ...newJobCapsProvider(mlClusterClient), - ...newJobChartsProvider(mlClusterClient), - ...topCategoriesProvider(mlClusterClient), - ...modelSnapshotProvider(mlClusterClient), + ...datafeedsProvider(client), + ...jobsProvider(client), + ...groupsProvider(client), + ...newJobCapsProvider(client), + ...newJobChartsProvider(client), + ...topCategoriesProvider(client), + ...modelSnapshotProvider(client), }; } 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 0aa1cfdae13c7..e047d31ba6eb7 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { uniq } from 'lodash'; import Boom from 'boom'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { parseTimeIntervalForJob } from '../../../common/util/job_utils'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; import { @@ -22,7 +22,7 @@ import { GLOBAL_CALENDAR } from '../../../common/constants/calendars'; import { datafeedsProvider, MlDatafeedsResponse, MlDatafeedsStatsResponse } from './datafeeds'; import { jobAuditMessagesProvider } from '../job_audit_messages'; import { resultsServiceProvider } from '../results_service'; -import { CalendarManager, Calendar } from '../calendar'; +import { CalendarManager } from '../calendar'; import { fillResultsWithTimeouts, isRequestTimeout } from './error_utils'; import { getEarliestDatafeedStartTime, @@ -47,16 +47,16 @@ interface Results { }; } -export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { - const { callAsInternalUser } = mlClusterClient; +export function jobsProvider(client: IScopedClusterClient) { + const { asInternalUser } = client; - const { forceDeleteDatafeed, getDatafeedIdsByJobId } = datafeedsProvider(mlClusterClient); - const { getAuditMessagesSummary } = jobAuditMessagesProvider(mlClusterClient); - const { getLatestBucketTimestampByJob } = resultsServiceProvider(mlClusterClient); - const calMngr = new CalendarManager(mlClusterClient); + const { forceDeleteDatafeed, getDatafeedIdsByJobId } = datafeedsProvider(client); + const { getAuditMessagesSummary } = jobAuditMessagesProvider(client); + const { getLatestBucketTimestampByJob } = resultsServiceProvider(client); + const calMngr = new CalendarManager(client); async function forceDeleteJob(jobId: string) { - return callAsInternalUser('ml.deleteJob', { jobId, force: true }); + return asInternalUser.ml.deleteJob({ job_id: jobId, force: true, wait_for_completion: false }); } async function deleteJobs(jobIds: string[]) { @@ -78,7 +78,7 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { if (isRequestTimeout(error)) { return fillResultsWithTimeouts(results, jobId, jobIds, DATAFEED_STATE.DELETED); } - results[jobId] = { deleted: false, error }; + results[jobId] = { deleted: false, error: error.body }; } } } catch (error) { @@ -90,7 +90,7 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { DATAFEED_STATE.DELETED ); } - results[jobId] = { deleted: false, error }; + results[jobId] = { deleted: false, error: error.body }; } } return results; @@ -100,7 +100,7 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { const results: Results = {}; for (const jobId of jobIds) { try { - await callAsInternalUser('ml.closeJob', { jobId }); + await asInternalUser.ml.closeJob({ job_id: jobId }); results[jobId] = { closed: true }; } catch (error) { if (isRequestTimeout(error)) { @@ -109,23 +109,23 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { if ( error.statusCode === 409 && - error.response && - error.response.includes('datafeed') === false + error.body.error?.reason && + error.body.error.reason.includes('datafeed') === false ) { // the close job request may fail (409) if the job has failed or if the datafeed hasn't been stopped. // if the job has failed we want to attempt a force close. // however, if we received a 409 due to the datafeed being started we should not attempt a force close. try { - await callAsInternalUser('ml.closeJob', { jobId, force: true }); + await asInternalUser.ml.closeJob({ job_id: jobId, force: true }); results[jobId] = { closed: true }; } catch (error2) { - if (isRequestTimeout(error)) { + if (isRequestTimeout(error2)) { return fillResultsWithTimeouts(results, jobId, jobIds, JOB_STATE.CLOSED); } - results[jobId] = { closed: false, error: error2 }; + results[jobId] = { closed: false, error: error2.body }; } } else { - results[jobId] = { closed: false, error }; + results[jobId] = { closed: false, error: error.body }; } } } @@ -139,12 +139,12 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { throw Boom.notFound(`Cannot find datafeed for job ${jobId}`); } - const dfResult = await callAsInternalUser('ml.stopDatafeed', { datafeedId, force: true }); - if (!dfResult || dfResult.stopped !== true) { + const { body } = await asInternalUser.ml.stopDatafeed({ datafeed_id: datafeedId, force: true }); + if (body.stopped !== true) { return { success: false }; } - await callAsInternalUser('ml.closeJob', { jobId, force: true }); + await asInternalUser.ml.closeJob({ job_id: jobId, force: true }); return { success: true }; } @@ -256,41 +256,26 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { const calendarsByJobId: { [jobId: string]: string[] } = {}; const globalCalendars: string[] = []; - const requests: [ - Promise, - Promise, - Promise, - Promise, - Promise, - Promise<{ [id: string]: number | undefined }> - ] = [ - jobIds.length > 0 - ? (callAsInternalUser('ml.jobs', { jobId: jobIds }) as Promise) // move length check in side call - : (callAsInternalUser('ml.jobs') as Promise), - jobIds.length > 0 - ? (callAsInternalUser('ml.jobStats', { jobId: jobIds }) as Promise) - : (callAsInternalUser('ml.jobStats') as Promise), - callAsInternalUser('ml.datafeeds') as Promise, - callAsInternalUser('ml.datafeedStats') as Promise, - calMngr.getAllCalendars(), - getLatestBucketTimestampByJob(), - ]; - + const jobIdsString = jobIds.join(); const [ - jobResults, - jobStatsResults, - datafeedResults, - datafeedStatsResults, + { body: jobResults }, + { body: jobStatsResults }, + { body: datafeedResults }, + { body: datafeedStatsResults }, calendarResults, latestBucketTimestampByJob, - ] = await Promise.all< - MlJobsResponse, - MlJobsStatsResponse, - MlDatafeedsResponse, - MlDatafeedsStatsResponse, - Calendar[], - { [id: string]: number | undefined } - >(requests); + ] = await Promise.all([ + asInternalUser.ml.getJobs( + jobIds.length > 0 ? { job_id: jobIdsString } : undefined + ), + asInternalUser.ml.getJobStats( + jobIds.length > 0 ? { job_id: jobIdsString } : undefined + ), + asInternalUser.ml.getDatafeeds(), + asInternalUser.ml.getDatafeedStats(), + calMngr.getAllCalendars(), + getLatestBucketTimestampByJob(), + ]); if (datafeedResults && datafeedResults.datafeeds) { datafeedResults.datafeeds.forEach((datafeed) => { @@ -400,9 +385,9 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { const detailed = true; const jobIds = []; try { - const tasksList = await callAsInternalUser('tasks.list', { actions, detailed }); - Object.keys(tasksList.nodes).forEach((nodeId) => { - const tasks = tasksList.nodes[nodeId].tasks; + const { body } = await asInternalUser.tasks.list({ actions, detailed }); + Object.keys(body.nodes).forEach((nodeId) => { + const tasks = body.nodes[nodeId].tasks; Object.keys(tasks).forEach((taskId) => { jobIds.push(tasks[taskId].description.replace(/^delete-job-/, '')); }); @@ -410,7 +395,9 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { } catch (e) { // if the user doesn't have permission to load the task list, // use the jobs list to get the ids of deleting jobs - const { jobs } = (await callAsInternalUser('ml.jobs')) as MlJobsResponse; + const { + body: { jobs }, + } = await asInternalUser.ml.getJobs(); jobIds.push(...jobs.filter((j) => j.deleting === true).map((j) => j.job_id)); } return { jobIds }; @@ -421,13 +408,13 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { // e.g. *_low_request_rate_ecs async function jobsExist(jobIds: string[] = []) { // Get the list of job IDs. - const jobsInfo = (await callAsInternalUser('ml.jobs', { - jobId: jobIds, - })) as MlJobsResponse; + const { body } = await asInternalUser.ml.getJobs({ + job_id: jobIds.join(), + }); const results: { [id: string]: boolean } = {}; - if (jobsInfo.count > 0) { - const allJobIds = jobsInfo.jobs.map((job) => job.job_id); + if (body.count > 0) { + const allJobIds = body.jobs.map((job) => job.job_id); // Check if each of the supplied IDs match existing jobs. jobIds.forEach((jobId) => { @@ -446,9 +433,9 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { } async function getAllJobAndGroupIds() { - const { getAllGroups } = groupsProvider(mlClusterClient); - const jobs = (await callAsInternalUser('ml.jobs')) as MlJobsResponse; - const jobIds = jobs.jobs.map((job) => job.job_id); + const { getAllGroups } = groupsProvider(client); + const { body } = await asInternalUser.ml.getJobs(); + const jobIds = body.jobs.map((job) => job.job_id); const groups = await getAllGroups(); const groupIds = groups.map((group) => group.id); @@ -460,13 +447,13 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { async function getLookBackProgress(jobId: string, start: number, end: number) { const datafeedId = `datafeed-${jobId}`; - const [jobStats, isRunning] = await Promise.all([ - callAsInternalUser('ml.jobStats', { jobId: [jobId] }) as Promise, + const [{ body }, isRunning] = await Promise.all([ + asInternalUser.ml.getJobStats({ job_id: jobId }), isDatafeedRunning(datafeedId), ]); - if (jobStats.jobs.length) { - const statsForJob = jobStats.jobs[0]; + if (body.jobs.length) { + const statsForJob = body.jobs[0]; const time = statsForJob.data_counts.latest_record_timestamp; const progress = (time - start) / (end - start); const isJobClosed = statsForJob.state === JOB_STATE.CLOSED; @@ -480,11 +467,11 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { } async function isDatafeedRunning(datafeedId: string) { - const stats = (await callAsInternalUser('ml.datafeedStats', { - datafeedId: [datafeedId], - })) as MlDatafeedsStatsResponse; - if (stats.datafeeds.length) { - const state = stats.datafeeds[0].state; + const { body } = await asInternalUser.ml.getDatafeedStats({ + datafeed_id: datafeedId, + }); + if (body.datafeeds.length) { + const state = body.datafeeds[0].state; return ( state === DATAFEED_STATE.STARTED || state === DATAFEED_STATE.STARTING || 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 576d6f8cbb160..34206a68ffeb9 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 @@ -6,7 +6,7 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { ModelSnapshot } from '../../../common/types/anomaly_detection_jobs'; import { datafeedsProvider } from './datafeeds'; import { FormCalendar, CalendarManager } from '../calendar'; @@ -19,9 +19,9 @@ export interface RevertModelSnapshotResponse { model: ModelSnapshot; } -export function modelSnapshotProvider(mlClusterClient: ILegacyScopedClusterClient) { - const { callAsInternalUser } = mlClusterClient; - const { forceStartDatafeeds, getDatafeedIdsByJobId } = datafeedsProvider(mlClusterClient); +export function modelSnapshotProvider(client: IScopedClusterClient) { + const { asInternalUser } = client; + const { forceStartDatafeeds, getDatafeedIdsByJobId } = datafeedsProvider(client); async function revertModelSnapshot( jobId: string, @@ -33,13 +33,13 @@ export function modelSnapshotProvider(mlClusterClient: ILegacyScopedClusterClien ) { let datafeedId = `datafeed-${jobId}`; // ensure job exists - await callAsInternalUser('ml.jobs', { jobId: [jobId] }); + await asInternalUser.ml.getJobs({ job_id: jobId }); try { // ensure the datafeed exists // the datafeed is probably called datafeed- - await callAsInternalUser('ml.datafeeds', { - datafeedId: [datafeedId], + await asInternalUser.ml.getDatafeeds({ + datafeed_id: datafeedId, }); } catch (e) { // if the datafeed isn't called datafeed- @@ -52,19 +52,21 @@ export function modelSnapshotProvider(mlClusterClient: ILegacyScopedClusterClien } // ensure the snapshot exists - const snapshot = (await callAsInternalUser('ml.modelSnapshots', { - jobId, - snapshotId, - })) as ModelSnapshotsResponse; + const { body: snapshot } = await asInternalUser.ml.getModelSnapshots({ + job_id: jobId, + snapshot_id: snapshotId, + }); // apply the snapshot revert - const { model } = (await callAsInternalUser('ml.revertModelSnapshot', { - jobId, - snapshotId, + const { + body: { model }, + } = await asInternalUser.ml.revertModelSnapshot({ + job_id: jobId, + snapshot_id: snapshotId, body: { delete_intervening_results: deleteInterveningResults, }, - })) as RevertModelSnapshotResponse; + }); // create calendar (if specified) and replay datafeed if (replay && model.snapshot_id === snapshotId && snapshot.model_snapshots.length) { @@ -85,7 +87,7 @@ export function modelSnapshotProvider(mlClusterClient: ILegacyScopedClusterClien end_time: s.end, })), }; - const cm = new CalendarManager(mlClusterClient); + const cm = new CalendarManager(client); await cm.newCalendar(calendar); } 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 ca3e0cef21049..6b9f30b2ae00b 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { chunk } from 'lodash'; import { SearchResponse } from 'elasticsearch'; import { CATEGORY_EXAMPLES_SAMPLE_SIZE } from '../../../../../common/constants/categorization_job'; @@ -18,9 +18,9 @@ import { ValidationResults } from './validation_results'; const CHUNK_SIZE = 100; export function categorizationExamplesProvider({ - callAsCurrentUser, - callAsInternalUser, -}: ILegacyScopedClusterClient) { + asCurrentUser, + asInternalUser, +}: IScopedClusterClient) { const validationResults = new ValidationResults(); async function categorizationExamples( @@ -57,7 +57,7 @@ export function categorizationExamplesProvider({ } } - const results: SearchResponse<{ [id: string]: string }> = await callAsCurrentUser('search', { + const { body } = await asCurrentUser.search>({ index: indexPatternTitle, size, body: { @@ -67,7 +67,7 @@ export function categorizationExamplesProvider({ }, }); - const tempExamples = results.hits.hits.map(({ _source }) => _source[categorizationFieldName]); + const tempExamples = body.hits.hits.map(({ _source }) => _source[categorizationFieldName]); validationResults.createNullValueResult(tempExamples); @@ -112,7 +112,9 @@ export function categorizationExamplesProvider({ } async function loadTokens(examples: string[], analyzer: CategorizationAnalyzer) { - const { tokens }: { tokens: Token[] } = await callAsInternalUser('indices.analyze', { + const { + body: { tokens }, + } = await asInternalUser.indices.analyze<{ tokens: Token[] }>({ body: { ...getAnalyzer(analyzer), text: examples, diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts index 5ade86806f383..347afec8ef73c 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts @@ -5,13 +5,13 @@ */ import { SearchResponse } from 'elasticsearch'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; import { CategoryId, Category } from '../../../../../common/types/categories'; -export function topCategoriesProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { - async function getTotalCategories(jobId: string): Promise<{ total: number }> { - const totalResp = await callAsInternalUser('search', { +export function topCategoriesProvider({ asInternalUser }: IScopedClusterClient) { + async function getTotalCategories(jobId: string): Promise { + const { body } = await asInternalUser.search>({ index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -33,11 +33,12 @@ export function topCategoriesProvider({ callAsInternalUser }: ILegacyScopedClust }, }, }); - return totalResp?.hits?.total?.value ?? 0; + // @ts-ignore total is an object here + return body?.hits?.total?.value ?? 0; } async function getTopCategoryCounts(jobId: string, numberOfCategories: number) { - const top: SearchResponse = await callAsInternalUser('search', { + const { body } = await asInternalUser.search>({ index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -76,7 +77,7 @@ export function topCategoriesProvider({ callAsInternalUser }: ILegacyScopedClust const catCounts: Array<{ id: CategoryId; count: number; - }> = top.aggregations?.cat_count?.buckets.map((c: any) => ({ + }> = body.aggregations?.cat_count?.buckets.map((c: any) => ({ id: c.key, count: c.doc_count, })); @@ -99,7 +100,7 @@ export function topCategoriesProvider({ callAsInternalUser }: ILegacyScopedClust field: 'category_id', }, }; - const result: SearchResponse = await callAsInternalUser('search', { + const { body } = await asInternalUser.search>({ index: ML_RESULTS_INDEX_PATTERN, size, body: { @@ -118,7 +119,7 @@ export function topCategoriesProvider({ callAsInternalUser }: ILegacyScopedClust }, }); - return result.hits.hits?.map((c: { _source: Category }) => c._source) || []; + return body.hits.hits?.map((c: { _source: Category }) => c._source) || []; } async function topCategories(jobId: string, numberOfCategories: number) { diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts index 4b90283a3a966..60595ccedff45 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts @@ -144,7 +144,7 @@ export class ValidationResults { this.createPrivilegesErrorResult(error); return; } - const message: string = error.message; + const message: string = error.body.error?.reason; if (message) { const rxp = /exceeded the allowed maximum of \[(\d+?)\]/; const match = rxp.exec(message); @@ -170,7 +170,7 @@ export class ValidationResults { } public createPrivilegesErrorResult(error: any) { - const message: string = error.message; + const message: string = error.body.error?.reason; if (message) { this._results.push({ id: VALIDATION_RESULT.INSUFFICIENT_PRIVILEGES, diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts b/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts index 63ae2c624ac38..da7d8d0577e4e 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { newJobLineChartProvider } from './line_chart'; import { newJobPopulationChartProvider } from './population_chart'; -export function newJobChartsProvider(mlClusterClient: ILegacyScopedClusterClient) { - const { newJobLineChart } = newJobLineChartProvider(mlClusterClient); - const { newJobPopulationChart } = newJobPopulationChartProvider(mlClusterClient); +export function newJobChartsProvider(client: IScopedClusterClient) { + const { newJobLineChart } = newJobLineChartProvider(client); + const { newJobPopulationChart } = newJobPopulationChartProvider(client); return { newJobLineChart, 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 3080b37867de5..9eea1ea2a28ae 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 @@ -5,7 +5,7 @@ */ import { get } from 'lodash'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; @@ -23,7 +23,7 @@ interface ProcessedResults { totalResults: number; } -export function newJobLineChartProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { +export function newJobLineChartProvider({ asCurrentUser }: IScopedClusterClient) { async function newJobLineChart( indexPatternTitle: string, timeField: string, @@ -47,9 +47,9 @@ export function newJobLineChartProvider({ callAsCurrentUser }: ILegacyScopedClus splitFieldValue ); - const results = await callAsCurrentUser('search', json); + const { body } = await asCurrentUser.search(json); return processSearchResults( - results, + body, aggFieldNamePairs.map((af) => af.field) ); } 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 ab75787a0069f..567afec809405 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 @@ -5,7 +5,7 @@ */ import { get } from 'lodash'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; @@ -29,7 +29,7 @@ interface ProcessedResults { totalResults: number; } -export function newJobPopulationChartProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { +export function newJobPopulationChartProvider({ asCurrentUser }: IScopedClusterClient) { async function newJobPopulationChart( indexPatternTitle: string, timeField: string, @@ -51,15 +51,11 @@ export function newJobPopulationChartProvider({ callAsCurrentUser }: ILegacyScop splitFieldName ); - try { - const results = await callAsCurrentUser('search', json); - return processSearchResults( - results, - aggFieldNamePairs.map((af) => af.field) - ); - } catch (error) { - return { error }; - } + const { body } = await asCurrentUser.search(json); + return processSearchResults( + body, + aggFieldNamePairs.map((af) => af.field) + ); } return { diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index fd20610450cc1..c3b1de64c3eb5 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { cloneDeep } from 'lodash'; import { SavedObjectsClientContract } from 'kibana/server'; import { @@ -40,35 +40,36 @@ const supportedTypes: string[] = [ export function fieldServiceProvider( indexPattern: string, isRollup: boolean, - mlClusterClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, savedObjectsClient: SavedObjectsClientContract ) { - return new FieldsService(indexPattern, isRollup, mlClusterClient, savedObjectsClient); + return new FieldsService(indexPattern, isRollup, client, savedObjectsClient); } class FieldsService { private _indexPattern: string; private _isRollup: boolean; - private _mlClusterClient: ILegacyScopedClusterClient; + private _mlClusterClient: IScopedClusterClient; private _savedObjectsClient: SavedObjectsClientContract; constructor( indexPattern: string, isRollup: boolean, - mlClusterClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, savedObjectsClient: SavedObjectsClientContract ) { this._indexPattern = indexPattern; this._isRollup = isRollup; - this._mlClusterClient = mlClusterClient; + this._mlClusterClient = client; this._savedObjectsClient = savedObjectsClient; } private async loadFieldCaps(): Promise { - return this._mlClusterClient.callAsCurrentUser('fieldCaps', { + const { body } = await this._mlClusterClient.asCurrentUser.fieldCaps({ index: this._indexPattern, fields: '*', }); + return body; } // create field object from the results from _field_caps diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts index 38d6481e02a74..891cb2e0d1e64 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts @@ -21,28 +21,23 @@ describe('job_service - job_caps', () => { let savedObjectsClientMock: any; beforeEach(() => { - const callAsNonRollupMock = jest.fn((action: string) => { - switch (action) { - case 'fieldCaps': - return farequoteFieldCaps; - } - }); + const asNonRollupMock = { + fieldCaps: jest.fn(() => ({ body: farequoteFieldCaps })), + }; + mlClusterClientNonRollupMock = { - callAsCurrentUser: callAsNonRollupMock, - callAsInternalUser: callAsNonRollupMock, + asCurrentUser: asNonRollupMock, + asInternalUser: asNonRollupMock, + }; + + const callAsRollupMock = { + fieldCaps: jest.fn(() => ({ body: cloudwatchFieldCaps })), + rollup: { getRollupIndexCaps: jest.fn(() => Promise.resolve({ body: rollupCaps })) }, }; - const callAsRollupMock = jest.fn((action: string) => { - switch (action) { - case 'fieldCaps': - return cloudwatchFieldCaps; - case 'ml.rollupIndexCapabilities': - return Promise.resolve(rollupCaps); - } - }); mlClusterClientRollupMock = { - callAsCurrentUser: callAsRollupMock, - callAsInternalUser: callAsRollupMock, + asCurrentUser: callAsRollupMock, + asInternalUser: callAsRollupMock, }; savedObjectsClientMock = { diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts index 5616dade53a78..7559111d012d0 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; +import { IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; import { Aggregation, Field, NewJobCaps } from '../../../../common/types/fields'; import { fieldServiceProvider } from './field_service'; @@ -12,18 +12,13 @@ interface NewJobCapsResponse { [indexPattern: string]: NewJobCaps; } -export function newJobCapsProvider(mlClusterClient: ILegacyScopedClusterClient) { +export function newJobCapsProvider(client: IScopedClusterClient) { async function newJobCaps( indexPattern: string, isRollup: boolean = false, savedObjectsClient: SavedObjectsClientContract ): Promise { - const fieldService = fieldServiceProvider( - indexPattern, - isRollup, - mlClusterClient, - savedObjectsClient - ); + const fieldService = fieldServiceProvider(indexPattern, isRollup, client, savedObjectsClient); const { aggs, fields } = await fieldService.getData(); convertForStringify(aggs, fields); diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts index f3a9bd49c27d6..b7f4c8af62283 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { SavedObject } from 'kibana/server'; import { IndexPatternAttributes } from 'src/plugins/data/server'; import { SavedObjectsClientContract } from 'kibana/server'; @@ -22,7 +22,7 @@ export interface RollupJob { export async function rollupServiceProvider( indexPattern: string, - { callAsCurrentUser }: ILegacyScopedClusterClient, + { asCurrentUser }: IScopedClusterClient, savedObjectsClient: SavedObjectsClientContract ) { const rollupIndexPatternObject = await loadRollupIndexPattern(indexPattern, savedObjectsClient); @@ -32,8 +32,8 @@ export async function rollupServiceProvider( if (rollupIndexPatternObject !== null) { const parsedTypeMetaData = JSON.parse(rollupIndexPatternObject.attributes.typeMeta); const rollUpIndex: string = parsedTypeMetaData.params.rollup_index; - const rollupCaps = await callAsCurrentUser('ml.rollupIndexCapabilities', { - indexPattern: rollUpIndex, + const { body: rollupCaps } = await asCurrentUser.rollup.getRollupIndexCaps({ + index: rollUpIndex, }); const indexRollupCaps = rollupCaps[rollUpIndex]; 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 1c74953e4dda9..810d0ae9dcd87 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 @@ -4,48 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { validateJob, ValidateJobPayload } from './job_validation'; import { JobValidationMessage } from '../../../common/constants/messages'; -const mlClusterClient = ({ - // mock callAsCurrentUser - callAsCurrentUser: (method: string) => { - return new Promise((resolve) => { - if (method === 'fieldCaps') { - resolve({ fields: [] }); - return; - } else if (method === 'ml.info') { - resolve({ +const callAs = { + fieldCaps: () => Promise.resolve({ body: { fields: [] } }), + ml: { + info: () => + Promise.resolve({ + body: { limits: { effective_max_model_memory_limit: '100MB', max_model_memory_limit: '1GB', }, - }); - } - resolve({}); - }) as Promise; + }, + }), }, + search: () => Promise.resolve({ body: {} }), +}; - // mock callAsInternalUser - callAsInternalUser: (method: string) => { - return new Promise((resolve) => { - if (method === 'fieldCaps') { - resolve({ fields: [] }); - return; - } else if (method === 'ml.info') { - resolve({ - limits: { - effective_max_model_memory_limit: '100MB', - max_model_memory_limit: '1GB', - }, - }); - } - resolve({}); - }) as Promise; - }, -} as unknown) as ILegacyScopedClusterClient; +const mlClusterClient = ({ + asCurrentUser: callAs, + asInternalUser: callAs, +} as unknown) as IScopedClusterClient; // Note: The tests cast `payload` as any // so we can simulate possible runtime payloads 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 6692ecb22bd9e..9e272f1f770fc 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 @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import Boom from 'boom'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { fieldsServiceProvider } from '../fields_service'; import { renderTemplate } from '../../../common/util/string_utils'; @@ -34,7 +34,7 @@ export type ValidateJobPayload = TypeOf; * @kbn/config-schema has checked the payload {@link validateJobSchema}. */ export async function validateJob( - mlClusterClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, payload: ValidateJobPayload, kbnVersion = 'current', isSecurityDisabled?: boolean @@ -63,8 +63,8 @@ export async function validateJob( // if no duration was part of the request, fall back to finding out // the time range of the time field of the index, but also check first // if the time field is a valid field of type 'date' using isValidTimeField() - if (typeof duration === 'undefined' && (await isValidTimeField(mlClusterClient, job))) { - const fs = fieldsServiceProvider(mlClusterClient); + if (typeof duration === 'undefined' && (await isValidTimeField(client, job))) { + const fs = fieldsServiceProvider(client); const index = job.datafeed_config.indices.join(','); const timeField = job.data_description.time_field; const timeRange = await fs.getTimeFieldRange(index, timeField, job.datafeed_config.query); @@ -79,24 +79,22 @@ export async function validateJob( // next run only the cardinality tests to find out if they trigger an error // so we can decide later whether certain additional tests should be run - const cardinalityMessages = await validateCardinality(mlClusterClient, job); + const cardinalityMessages = await validateCardinality(client, job); validationMessages.push(...cardinalityMessages); const cardinalityError = cardinalityMessages.some((m) => { return messages[m.id as MessageId].status === VALIDATION_STATUS.ERROR; }); validationMessages.push( - ...(await validateBucketSpan(mlClusterClient, job, duration, isSecurityDisabled)) + ...(await validateBucketSpan(client, job, duration, isSecurityDisabled)) ); - validationMessages.push(...(await validateTimeRange(mlClusterClient, job, duration))); + validationMessages.push(...(await validateTimeRange(client, job, duration))); // only run the influencer and model memory limit checks // if cardinality checks didn't return a message with an error level if (cardinalityError === false) { validationMessages.push(...(await validateInfluencers(job))); - validationMessages.push( - ...(await validateModelMemoryLimit(mlClusterClient, job, duration)) - ); + validationMessages.push(...(await validateModelMemoryLimit(client, job, duration))); } } else { validationMessages = basicValidation.messages; diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js index 11f8d8967c4e0..315ad09176571 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js @@ -45,7 +45,7 @@ const pickBucketSpan = (bucketSpans) => { return bucketSpans[i]; }; -export async function validateBucketSpan(mlClusterClient, job, duration) { +export async function validateBucketSpan(client, job, duration) { validateJobObject(job); // if there is no duration, do not run the estimate test @@ -117,7 +117,7 @@ export async function validateBucketSpan(mlClusterClient, job, duration) { try { const estimations = estimatorConfigs.map((data) => { return new Promise((resolve) => { - estimateBucketSpanFactory(mlClusterClient)(data) + estimateBucketSpanFactory(client)(data) .then(resolve) // this catch gets triggered when the estimation code runs without error // but isn't able to come up with a bucket span estimation. diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts index f9145ab576d71..80418d590af76 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts @@ -24,12 +24,12 @@ import mockItSearchResponse from './__mocks__/mock_it_search_response.json'; const mlClusterClientFactory = (mockSearchResponse: any) => { const callAs = () => { return new Promise((resolve) => { - resolve(mockSearchResponse); + resolve({ body: mockSearchResponse }); }); }; return { - callAsCurrentUser: callAs, - callAsInternalUser: callAs, + asCurrentUser: callAs, + asInternalUser: callAs, }; }; diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts index 16ee70ad9efde..1be0751e15f22 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts @@ -6,7 +6,7 @@ import cloneDeep from 'lodash/cloneDeep'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; @@ -24,21 +24,21 @@ const mockResponses = { const mlClusterClientFactory = ( responses: Record, fail = false -): ILegacyScopedClusterClient => { - const callAs = (requestName: string) => { - return new Promise((resolve, reject) => { - const response = responses[requestName]; - if (fail) { - reject(response); - } else { - resolve(response); - } - }) as Promise; +): IScopedClusterClient => { + const callAs = { + search: () => Promise.resolve({ body: responses.search }), + fieldCaps: () => Promise.resolve({ body: responses.fieldCaps }), }; - return { - callAsCurrentUser: callAs, - callAsInternalUser: callAs, + + const callAsFail = { + search: () => Promise.reject({ body: {} }), + fieldCaps: () => Promise.reject({ body: {} }), }; + + return ({ + asCurrentUser: fail === false ? callAs : callAsFail, + asInternalUser: fail === false ? callAs : callAsFail, + } as unknown) as IScopedClusterClient; }; describe('ML - validateCardinality', () => { diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts index 1545c4c0062ec..c5822b863c83d 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { DataVisualizer } from '../data_visualizer'; import { validateJobObject } from './validate_job_object'; @@ -43,12 +43,9 @@ type Validator = (obj: { messages: Messages; }>; -const validateFactory = ( - mlClusterClient: ILegacyScopedClusterClient, - job: CombinedJob -): Validator => { - const { callAsCurrentUser } = mlClusterClient; - const dv = new DataVisualizer(mlClusterClient); +const validateFactory = (client: IScopedClusterClient, job: CombinedJob): Validator => { + const { asCurrentUser } = client; + const dv = new DataVisualizer(client); const modelPlotConfigTerms = job?.model_plot_config?.terms ?? ''; const modelPlotConfigFieldCount = @@ -77,7 +74,7 @@ const validateFactory = ( ] as string[]; // use fieldCaps endpoint to get data about whether fields are aggregatable - const fieldCaps = await callAsCurrentUser('fieldCaps', { + const { body: fieldCaps } = await asCurrentUser.fieldCaps({ index: job.datafeed_config.indices.join(','), fields: uniqueFieldNames, }); @@ -154,7 +151,7 @@ const validateFactory = ( }; export async function validateCardinality( - mlClusterClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, job?: CombinedJob ): Promise | never { const messages: Messages = []; @@ -174,7 +171,7 @@ export async function validateCardinality( } // validate({ type, isInvalid }) asynchronously returns an array of validation messages - const validate = validateFactory(mlClusterClient, job); + const validate = validateFactory(client, job); const modelPlotEnabled = job.model_plot_config?.enabled ?? false; diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts index 6ffb0e320982b..35792c66e66ec 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { CombinedJob, Detector } from '../../../common/types/anomaly_detection_jobs'; -import { ModelMemoryEstimate } from '../calculate_model_memory_limit/calculate_model_memory_limit'; +import { ModelMemoryEstimateResponse } from '../calculate_model_memory_limit/calculate_model_memory_limit'; import { validateModelMemoryLimit } from './validate_model_memory_limit'; describe('ML - validateModelMemoryLimit', () => { @@ -65,44 +65,36 @@ describe('ML - validateModelMemoryLimit', () => { }; // mock estimate model memory - const modelMemoryEstimateResponse: ModelMemoryEstimate = { + const modelMemoryEstimateResponse: ModelMemoryEstimateResponse = { model_memory_estimate: '40mb', }; interface MockAPICallResponse { - 'ml.estimateModelMemory'?: ModelMemoryEstimate; + 'ml.estimateModelMemory'?: ModelMemoryEstimateResponse; } - // mock callAsCurrentUser + // mock asCurrentUser // used in three places: // - to retrieve the info endpoint // - to search for cardinality of split field // - to retrieve field capabilities used in search for split field cardinality const getMockMlClusterClient = ({ 'ml.estimateModelMemory': estimateModelMemory, - }: MockAPICallResponse = {}): ILegacyScopedClusterClient => { - const callAs = (call: string) => { - if (typeof call === undefined) { - return Promise.reject(); - } - - let response = {}; - if (call === 'ml.info') { - response = mlInfoResponse; - } else if (call === 'search') { - response = cardinalitySearchResponse; - } else if (call === 'fieldCaps') { - response = fieldCapsResponse; - } else if (call === 'ml.estimateModelMemory') { - response = estimateModelMemory || modelMemoryEstimateResponse; - } - return Promise.resolve(response); + }: MockAPICallResponse = {}): IScopedClusterClient => { + const callAs = { + ml: { + info: () => Promise.resolve({ body: mlInfoResponse }), + estimateModelMemory: () => + Promise.resolve({ body: estimateModelMemory || modelMemoryEstimateResponse }), + }, + search: () => Promise.resolve({ body: cardinalitySearchResponse }), + fieldCaps: () => Promise.resolve({ body: fieldCapsResponse }), }; - return { - callAsCurrentUser: callAs, - callAsInternalUser: callAs, - }; + return ({ + asCurrentUser: callAs, + asInternalUser: callAs, + } as unknown) as IScopedClusterClient; }; function getJobConfig(influencers: string[] = [], detectors: Detector[] = []) { diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts index 728342294c424..9733e17e0f379 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts @@ -5,7 +5,7 @@ */ import numeral from '@elastic/numeral'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { validateJobObject } from './validate_job_object'; import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_limit'; @@ -16,11 +16,11 @@ import { MlInfoResponse } from '../../../common/types/ml_server_info'; const MODEL_MEMORY_LIMIT_MINIMUM_BYTES = 1048576; export async function validateModelMemoryLimit( - mlClusterClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, job: CombinedJob, duration?: { start?: number; end?: number } ) { - const { callAsInternalUser } = mlClusterClient; + const { asInternalUser } = client; validateJobObject(job); // retrieve the model memory limit specified by the user in the job config. @@ -52,12 +52,12 @@ export async function validateModelMemoryLimit( // retrieve the max_model_memory_limit value from the server // this will be unset unless the user has set this on their cluster - const info = (await callAsInternalUser('ml.info')) as MlInfoResponse; - const maxModelMemoryLimit = info.limits.max_model_memory_limit?.toUpperCase(); - const effectiveMaxModelMemoryLimit = info.limits.effective_max_model_memory_limit?.toUpperCase(); + const { body } = await asInternalUser.ml.info(); + const maxModelMemoryLimit = body.limits.max_model_memory_limit?.toUpperCase(); + const effectiveMaxModelMemoryLimit = body.limits.effective_max_model_memory_limit?.toUpperCase(); if (runCalcModelMemoryTest) { - const { modelMemoryLimit } = await calculateModelMemoryLimitProvider(mlClusterClient)( + const { modelMemoryLimit } = await calculateModelMemoryLimitProvider(client)( job.analysis_config, job.datafeed_config.indices.join(','), job.datafeed_config.query, diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts index a45be189ba3d8..12458af0521a9 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts @@ -6,7 +6,7 @@ import cloneDeep from 'lodash/cloneDeep'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; @@ -21,16 +21,15 @@ const mockSearchResponse = { search: mockTimeRange, }; -const mlClusterClientFactory = (resp: any): ILegacyScopedClusterClient => { - const callAs = (path: string) => { - return new Promise((resolve) => { - resolve(resp[path]); - }) as Promise; - }; - return { - callAsCurrentUser: callAs, - callAsInternalUser: callAs, +const mlClusterClientFactory = (response: any): IScopedClusterClient => { + const callAs = { + fieldCaps: () => Promise.resolve({ body: response.fieldCaps }), + search: () => Promise.resolve({ body: response.search }), }; + return ({ + asCurrentUser: callAs, + asInternalUser: callAs, + } as unknown) as IScopedClusterClient; }; function getMinimalValidJob() { diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts index a94ceffa90273..83d9621898f96 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/server'; import { parseInterval } from '../../../common/util/parse_interval'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; @@ -26,15 +26,12 @@ const BUCKET_SPAN_COMPARE_FACTOR = 25; const MIN_TIME_SPAN_MS = 7200000; const MIN_TIME_SPAN_READABLE = '2 hours'; -export async function isValidTimeField( - { callAsCurrentUser }: ILegacyScopedClusterClient, - job: CombinedJob -) { +export async function isValidTimeField({ asCurrentUser }: IScopedClusterClient, job: CombinedJob) { const index = job.datafeed_config.indices.join(','); const timeField = job.data_description.time_field; // check if time_field is of type 'date' or 'date_nanos' - const fieldCaps = await callAsCurrentUser('fieldCaps', { + const { body: fieldCaps } = await asCurrentUser.fieldCaps({ index, fields: [timeField], }); @@ -47,7 +44,7 @@ export async function isValidTimeField( } export async function validateTimeRange( - mlClientCluster: ILegacyScopedClusterClient, + mlClientCluster: IScopedClusterClient, job: CombinedJob, timeRange?: Partial ) { diff --git a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts index 9c0efe259844c..76dc68d2b59e3 100644 --- a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts +++ b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { PARTITION_FIELDS } from '../../../common/constants/anomalies'; import { PartitionFieldsType } from '../../../common/types/anomalies'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; @@ -74,9 +74,7 @@ function getFieldObject(fieldType: PartitionFieldsType, aggs: any) { : {}; } -export const getPartitionFieldsValuesFactory = ({ - callAsInternalUser, -}: ILegacyScopedClusterClient) => +export const getPartitionFieldsValuesFactory = ({ asInternalUser }: IScopedClusterClient) => /** * Gets the record of partition fields with possible values that fit the provided queries. * @param jobId - Job ID @@ -92,7 +90,7 @@ export const getPartitionFieldsValuesFactory = ({ earliestMs: number, latestMs: number ) { - const jobsResponse = await callAsInternalUser('ml.jobs', { jobId: [jobId] }); + const { body: jobsResponse } = await asInternalUser.ml.getJobs({ job_id: jobId }); if (jobsResponse.count === 0 || jobsResponse.jobs === undefined) { throw Boom.notFound(`Job with the id "${jobId}" not found`); } @@ -101,7 +99,7 @@ export const getPartitionFieldsValuesFactory = ({ const isModelPlotEnabled = job?.model_plot_config?.enabled; - const resp = await callAsInternalUser('search', { + const { body } = await asInternalUser.search({ index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -151,7 +149,7 @@ export const getPartitionFieldsValuesFactory = ({ return PARTITION_FIELDS.reduce((acc, key) => { return { ...acc, - ...getFieldObject(key, resp.aggregations), + ...getFieldObject(key, body.aggregations), }; }, {}); }; diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.ts b/x-pack/plugins/ml/server/models/results_service/results_service.ts index 7be8bac61e69d..190b5d99309d7 100644 --- a/x-pack/plugins/ml/server/models/results_service/results_service.ts +++ b/x-pack/plugins/ml/server/models/results_service/results_service.ts @@ -9,7 +9,7 @@ import slice from 'lodash/slice'; import get from 'lodash/get'; import moment from 'moment'; import { SearchResponse } from 'elasticsearch'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import Boom from 'boom'; import { buildAnomalyTableItems } from './build_anomaly_table_items'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; @@ -40,8 +40,8 @@ interface Influencer { fieldValue: any; } -export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClient) { - const { callAsInternalUser } = mlClusterClient; +export function resultsServiceProvider(client: IScopedClusterClient) { + const { asInternalUser } = client; // Obtains data for the anomalies table, aggregating anomalies by day or hour as requested. // Return an Object with properties 'anomalies' and 'interval' (interval used to aggregate anomalies, // one of day, hour or second. Note 'auto' can be provided as the aggregationInterval in the request, @@ -144,7 +144,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }); } - const resp: SearchResponse = await callAsInternalUser('search', { + const { body } = await asInternalUser.search>({ index: ML_RESULTS_INDEX_PATTERN, rest_total_hits_as_int: true, size: maxRecords, @@ -178,9 +178,9 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie anomalies: [], interval: 'second', }; - if (resp.hits.total !== 0) { + if (body.hits.total !== 0) { let records: AnomalyRecordDoc[] = []; - resp.hits.hits.forEach((hit) => { + body.hits.hits.forEach((hit) => { records.push(hit._source); }); @@ -298,8 +298,8 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }, }; - const resp = await callAsInternalUser('search', query); - const maxScore = get(resp, ['aggregations', 'max_score', 'value'], null); + const { body } = await asInternalUser.search(query); + const maxScore = get(body, ['aggregations', 'max_score', 'value'], null); return { maxScore }; } @@ -336,7 +336,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie // Size of job terms agg, consistent with maximum number of jobs supported by Java endpoints. const maxJobs = 10000; - const resp = await callAsInternalUser('search', { + const { body } = await asInternalUser.search({ index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -364,7 +364,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }); const bucketsByJobId: Array<{ key: string; maxTimestamp: { value?: number } }> = get( - resp, + body, ['aggregations', 'byJobId', 'buckets'], [] ); @@ -380,7 +380,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie // from the given index and job ID. // Returned response consists of a list of examples against category ID. async function getCategoryExamples(jobId: string, categoryIds: any, maxExamples: number) { - const resp = await callAsInternalUser('search', { + const { body } = await asInternalUser.search({ index: ML_RESULTS_INDEX_PATTERN, rest_total_hits_as_int: true, size: ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, // Matches size of records in anomaly summary table. @@ -394,8 +394,8 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }); const examplesByCategoryId: { [key: string]: any } = {}; - if (resp.hits.total !== 0) { - resp.hits.hits.forEach((hit: any) => { + if (body.hits.total !== 0) { + body.hits.hits.forEach((hit: any) => { if (maxExamples) { examplesByCategoryId[hit._source.category_id] = slice( hit._source.examples, @@ -415,7 +415,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie // Returned response contains four properties - categoryId, regex, examples // and terms (space delimited String of the common tokens matched in values of the category). async function getCategoryDefinition(jobId: string, categoryId: string) { - const resp = await callAsInternalUser('search', { + const { body } = await asInternalUser.search({ index: ML_RESULTS_INDEX_PATTERN, rest_total_hits_as_int: true, size: 1, @@ -429,8 +429,8 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }); const definition = { categoryId, terms: null, regex: null, examples: [] }; - if (resp.hits.total !== 0) { - const source = resp.hits.hits[0]._source; + if (body.hits.total !== 0) { + const source = body.hits.hits[0]._source; definition.categoryId = source.category_id; definition.regex = source.regex; definition.terms = source.terms; @@ -456,7 +456,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }, }); } - const results: SearchResponse = await callAsInternalUser('search', { + const { body } = await asInternalUser.search>({ index: ML_RESULTS_INDEX_PATTERN, body: { query: { @@ -473,7 +473,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }, }, }); - return results ? results.hits.hits.map((r) => r._source) : []; + return body ? body.hits.hits.map((r) => r._source) : []; } async function getCategoryStoppedPartitions( @@ -485,15 +485,15 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }; // first determine from job config if stop_on_warn is true // if false return [] - const jobConfigResponse: MlJobsResponse = await callAsInternalUser('ml.jobs', { - jobId: jobIds, + const { body } = await asInternalUser.ml.getJobs({ + job_id: jobIds.join(), }); - if (!jobConfigResponse || jobConfigResponse.jobs.length < 1) { + if (!body || body.jobs.length < 1) { throw Boom.notFound(`Unable to find anomaly detector jobs ${jobIds.join(', ')}`); } - const jobIdsWithStopOnWarnSet = jobConfigResponse.jobs + const jobIdsWithStopOnWarnSet = body.jobs .filter( (jobConfig) => jobConfig.analysis_config?.per_partition_categorization?.stop_on_warn === true @@ -543,7 +543,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }, }, ]; - const results: SearchResponse = await callAsInternalUser('search', { + const { body: results } = await asInternalUser.search>({ index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -594,7 +594,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie getCategoryExamples, getLatestBucketTimestampByJob, getMaxAnomalyScore, - getPartitionFieldsValues: getPartitionFieldsValuesFactory(mlClusterClient), + getPartitionFieldsValues: getPartitionFieldsValuesFactory(client), getCategorizerStats, getCategoryStoppedPartitions, }; diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 76128341e6ddc..39672f5b188bc 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -9,18 +9,16 @@ import { CoreSetup, CoreStart, Plugin, - ILegacyScopedClusterClient, KibanaRequest, Logger, PluginInitializerContext, - ILegacyCustomClusterClient, CapabilitiesStart, + IClusterClient, } from 'kibana/server'; import { PluginsSetup, RouteInitialization } from './types'; import { PLUGIN_ID, PLUGIN_ICON } from '../common/constants/app'; import { MlCapabilities } from '../common/types/capabilities'; -import { elasticsearchJsPlugin } from './client/elasticsearch_ml'; import { initMlTelemetry } from './lib/telemetry'; import { initMlServerLog } from './client/log'; import { initSampleDataSets } from './lib/sample_data_sets'; @@ -50,17 +48,7 @@ import { setupCapabilitiesSwitcher } from './lib/capabilities'; import { registerKibanaSettings } from './lib/register_settings'; import { inferenceRoutes } from './routes/inference'; -declare module 'kibana/server' { - interface RequestHandlerContext { - [PLUGIN_ID]?: { - mlClient: ILegacyScopedClusterClient; - }; - } -} - -export interface MlPluginSetup extends SharedServices { - mlClient: ILegacyCustomClusterClient; -} +export type MlPluginSetup = SharedServices; export type MlPluginStart = void; export class MlServerPlugin implements Plugin { @@ -68,6 +56,7 @@ export class MlServerPlugin implements Plugin { - return { - mlClient: mlClient.asScoped(request), - }; - }); - const routeInit: RouteInitialization = { router: coreSetup.http.createRouter(), mlLicense: this.mlLicense, @@ -176,13 +154,19 @@ export class MlServerPlugin implements Plugin this.clusterClient + ), }; } public start(coreStart: CoreStart): MlPluginStart { this.capabilities = coreStart.capabilities; + this.clusterClient = coreStart.elasticsearch.client; } public stop() { diff --git a/x-pack/plugins/ml/server/routes/annotations.ts b/x-pack/plugins/ml/server/routes/annotations.ts index a6de80bb7e5e2..5c4b36164fbb0 100644 --- a/x-pack/plugins/ml/server/routes/annotations.ts +++ b/x-pack/plugins/ml/server/routes/annotations.ts @@ -58,9 +58,9 @@ export function annotationRoutes( tags: ['access:ml:canGetAnnotations'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { getAnnotations } = annotationServiceProvider(legacyClient); + const { getAnnotations } = annotationServiceProvider(client); const resp = await getAnnotations(request.body); return response.ok({ @@ -91,14 +91,14 @@ export function annotationRoutes( tags: ['access:ml:canCreateAnnotation'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const annotationsFeatureAvailable = await isAnnotationsFeatureAvailable(legacyClient); + const annotationsFeatureAvailable = await isAnnotationsFeatureAvailable(client); if (annotationsFeatureAvailable === false) { throw getAnnotationsFeatureUnavailableErrorMessage(); } - const { indexAnnotation } = annotationServiceProvider(legacyClient); + const { indexAnnotation } = annotationServiceProvider(client); const currentUser = securityPlugin !== undefined ? securityPlugin.authc.getCurrentUser(request) : {}; @@ -134,15 +134,15 @@ export function annotationRoutes( tags: ['access:ml:canDeleteAnnotation'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const annotationsFeatureAvailable = await isAnnotationsFeatureAvailable(legacyClient); + const annotationsFeatureAvailable = await isAnnotationsFeatureAvailable(client); if (annotationsFeatureAvailable === false) { throw getAnnotationsFeatureUnavailableErrorMessage(); } const annotationId = request.params.annotationId; - const { deleteAnnotation } = annotationServiceProvider(legacyClient); + const { deleteAnnotation } = annotationServiceProvider(client); const resp = await deleteAnnotation(annotationId); return response.ok({ diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index 0027bec910134..251e465eafccc 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -5,6 +5,7 @@ */ import { schema } from '@kbn/config-schema'; +import { RequestParams } from '@elastic/elasticsearch'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -20,6 +21,7 @@ import { getModelSnapshotsSchema, updateModelSnapshotSchema, } from './schemas/anomaly_detectors_schema'; + /** * Routes for the anomaly detectors */ @@ -42,11 +44,11 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ response, client }) => { try { - const results = await legacyClient.callAsInternalUser('ml.jobs'); + const { body } = await client.asInternalUser.ml.getJobs(); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -73,12 +75,12 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { jobId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.jobs', { jobId }); + const { body } = await client.asInternalUser.ml.getJobs({ job_id: jobId }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -104,11 +106,11 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.jobStats'); + const { body } = await client.asInternalUser.ml.getJobStats(); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -135,12 +137,12 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { jobId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.jobStats', { jobId }); + const { body } = await client.asInternalUser.ml.getJobStats({ job_id: jobId }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -171,15 +173,15 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { jobId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.addJob', { - jobId, + const { body } = await client.asInternalUser.ml.putJob({ + job_id: jobId, body: request.body, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -208,15 +210,15 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canUpdateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { jobId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.updateJob', { - jobId, + const { body } = await client.asInternalUser.ml.updateJob({ + job_id: jobId, body: request.body, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -243,14 +245,12 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canOpenJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { jobId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.openJob', { - jobId, - }); + const { body } = await client.asInternalUser.ml.openJob({ job_id: jobId }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -277,18 +277,18 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCloseJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const options: { jobId: string; force?: boolean } = { - jobId: request.params.jobId, + const options: RequestParams.MlCloseJob = { + job_id: request.params.jobId, }; const force = request.query.force; if (force !== undefined) { options.force = force; } - const results = await legacyClient.callAsInternalUser('ml.closeJob', options); + const { body } = await client.asInternalUser.ml.closeJob(options); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -315,18 +315,19 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canDeleteJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const options: { jobId: string; force?: boolean } = { - jobId: request.params.jobId, + const options: RequestParams.MlDeleteJob = { + job_id: request.params.jobId, + wait_for_completion: false, }; const force = request.query.force; if (force !== undefined) { options.force = force; } - const results = await legacyClient.callAsInternalUser('ml.deleteJob', options); + const { body } = await client.asInternalUser.ml.deleteJob(options); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -351,13 +352,11 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.validateDetector', { - body: request.body, - }); + const { body } = await client.asInternalUser.ml.validateDetector({ body: request.body }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -386,16 +385,16 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canForecastJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const jobId = request.params.jobId; const duration = request.body.duration; - const results = await legacyClient.callAsInternalUser('ml.forecast', { - jobId, + const { body } = await client.asInternalUser.ml.forecast({ + job_id: jobId, duration, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -427,14 +426,14 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.records', { - jobId: request.params.jobId, + const { body } = await client.asInternalUser.ml.getRecords({ + job_id: request.params.jobId, body: request.body, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -466,15 +465,15 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.buckets', { - jobId: request.params.jobId, + const { body } = await client.asInternalUser.ml.getBuckets({ + job_id: request.params.jobId, timestamp: request.params.timestamp, body: request.body, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -506,17 +505,17 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.overallBuckets', { - jobId: request.params.jobId, + const { body } = await client.asInternalUser.ml.getOverallBuckets({ + job_id: request.params.jobId, top_n: request.body.topN, bucket_span: request.body.bucketSpan, start: request.body.start, end: request.body.end, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -543,14 +542,14 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.categories', { - jobId: request.params.jobId, - categoryId: request.params.categoryId, + const { body } = await client.asInternalUser.ml.getCategories({ + job_id: request.params.jobId, + category_id: request.params.categoryId, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -577,13 +576,13 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.modelSnapshots', { - jobId: request.params.jobId, + const { body } = await client.asInternalUser.ml.getModelSnapshots({ + job_id: request.params.jobId, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -610,14 +609,14 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.modelSnapshots', { - jobId: request.params.jobId, - snapshotId: request.params.snapshotId, + const { body } = await client.asInternalUser.ml.getModelSnapshots({ + job_id: request.params.jobId, + snapshot_id: request.params.snapshotId, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -646,15 +645,15 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.updateModelSnapshot', { - jobId: request.params.jobId, - snapshotId: request.params.snapshotId, + const { body } = await client.asInternalUser.ml.updateModelSnapshot({ + job_id: request.params.jobId, + snapshot_id: request.params.snapshotId, body: request.body, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -681,14 +680,14 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.deleteModelSnapshot', { - jobId: request.params.jobId, - snapshotId: request.params.snapshotId, + const { body } = await client.asInternalUser.ml.deleteModelSnapshot({ + job_id: request.params.jobId, + snapshot_id: request.params.snapshotId, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); diff --git a/x-pack/plugins/ml/server/routes/calendars.ts b/x-pack/plugins/ml/server/routes/calendars.ts index 3beb6e437b2ee..2c95ce6fb59ec 100644 --- a/x-pack/plugins/ml/server/routes/calendars.ts +++ b/x-pack/plugins/ml/server/routes/calendars.ts @@ -4,43 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { calendarSchema, calendarIdSchema, calendarIdsSchema } from './schemas/calendars_schema'; import { CalendarManager, Calendar, FormCalendar } from '../models/calendar'; -function getAllCalendars(legacyClient: ILegacyScopedClusterClient) { - const cal = new CalendarManager(legacyClient); +function getAllCalendars(client: IScopedClusterClient) { + const cal = new CalendarManager(client); return cal.getAllCalendars(); } -function getCalendar(legacyClient: ILegacyScopedClusterClient, calendarId: string) { - const cal = new CalendarManager(legacyClient); +function getCalendar(client: IScopedClusterClient, calendarId: string) { + const cal = new CalendarManager(client); return cal.getCalendar(calendarId); } -function newCalendar(legacyClient: ILegacyScopedClusterClient, calendar: FormCalendar) { - const cal = new CalendarManager(legacyClient); +function newCalendar(client: IScopedClusterClient, calendar: FormCalendar) { + const cal = new CalendarManager(client); return cal.newCalendar(calendar); } -function updateCalendar( - legacyClient: ILegacyScopedClusterClient, - calendarId: string, - calendar: Calendar -) { - const cal = new CalendarManager(legacyClient); +function updateCalendar(client: IScopedClusterClient, calendarId: string, calendar: Calendar) { + const cal = new CalendarManager(client); return cal.updateCalendar(calendarId, calendar); } -function deleteCalendar(legacyClient: ILegacyScopedClusterClient, calendarId: string) { - const cal = new CalendarManager(legacyClient); +function deleteCalendar(client: IScopedClusterClient, calendarId: string) { + const cal = new CalendarManager(client); return cal.deleteCalendar(calendarId); } -function getCalendarsByIds(legacyClient: ILegacyScopedClusterClient, calendarIds: string) { - const cal = new CalendarManager(legacyClient); +function getCalendarsByIds(client: IScopedClusterClient, calendarIds: string) { + const cal = new CalendarManager(client); return cal.getCalendarsByIds(calendarIds); } @@ -60,9 +56,9 @@ export function calendars({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetCalendars'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const resp = await getAllCalendars(legacyClient); + const resp = await getAllCalendars(client); return response.ok({ body: resp, @@ -92,15 +88,15 @@ export function calendars({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetCalendars'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { let returnValue; try { const calendarIds = request.params.calendarIds.split(','); if (calendarIds.length === 1) { - returnValue = await getCalendar(legacyClient, calendarIds[0]); + returnValue = await getCalendar(client, calendarIds[0]); } else { - returnValue = await getCalendarsByIds(legacyClient, calendarIds); + returnValue = await getCalendarsByIds(client, calendarIds); } return response.ok({ @@ -131,10 +127,10 @@ export function calendars({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateCalendar'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const body = request.body; - const resp = await newCalendar(legacyClient, body); + const resp = await newCalendar(client, body); return response.ok({ body: resp, @@ -166,11 +162,11 @@ export function calendars({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateCalendar'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { calendarId } = request.params; const body = request.body; - const resp = await updateCalendar(legacyClient, calendarId, body); + const resp = await updateCalendar(client, calendarId, body); return response.ok({ body: resp, @@ -200,10 +196,10 @@ export function calendars({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canDeleteCalendar'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { calendarId } = request.params; - const resp = await deleteCalendar(legacyClient, calendarId); + const resp = await deleteCalendar(client, calendarId); return response.ok({ body: resp, diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index 75d48056cf458..dea4803e8275e 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RequestHandlerContext, ILegacyScopedClusterClient } from 'kibana/server'; +import { RequestHandlerContext, IScopedClusterClient } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { analyticsAuditMessagesProvider } from '../models/data_frame_analytics/analytics_audit_messages'; import { RouteInitialization } from '../types'; @@ -36,13 +36,14 @@ function deleteDestIndexPatternById(context: RequestHandlerContext, indexPattern */ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitialization) { async function userCanDeleteIndex( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, destinationIndex: string ): Promise { if (!mlLicense.isSecurityEnabled()) { return true; } - const privilege = await legacyClient.callAsCurrentUser('ml.privilegeCheck', { + + const { body } = await client.asCurrentUser.security.hasPrivileges({ body: { index: [ { @@ -52,10 +53,8 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat ], }, }); - if (!privilege) { - return false; - } - return privilege.has_all_requested === true; + + return body?.has_all_requested === true; } /** @@ -76,11 +75,11 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canGetDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.getDataFrameAnalytics'); + const { body } = await client.asInternalUser.ml.getDataFrameAnalytics({ size: 1000 }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -107,14 +106,14 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canGetDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { analyticsId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.getDataFrameAnalytics', { - analyticsId, + const { body } = await client.asInternalUser.ml.getDataFrameAnalytics({ + id: analyticsId, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -137,11 +136,11 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canGetDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.getDataFrameAnalyticsStats'); + const { body } = await client.asInternalUser.ml.getDataFrameAnalyticsStats({ size: 1000 }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -168,14 +167,14 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canGetDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { analyticsId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.getDataFrameAnalyticsStats', { - analyticsId, + const { body } = await client.asInternalUser.ml.getDataFrameAnalyticsStats({ + id: analyticsId, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -205,16 +204,18 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canCreateDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { analyticsId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.createDataFrameAnalytics', { - body: request.body, - analyticsId, - ...getAuthorizationHeader(request), - }); + const { body } = await client.asInternalUser.ml.putDataFrameAnalytics( + { + id: analyticsId, + body: request.body, + }, + getAuthorizationHeader(request) + ); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -241,14 +242,16 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canGetDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.evaluateDataFrameAnalytics', { - body: request.body, - ...getAuthorizationHeader(request), - }); + const { body } = await client.asInternalUser.ml.evaluateDataFrame( + { + body: request.body, + }, + getAuthorizationHeader(request) + ); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -276,13 +279,13 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canCreateDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const results = await legacyClient.callAsInternalUser('ml.explainDataFrameAnalytics', { + const { body } = await client.asInternalUser.ml.explainDataFrameAnalytics({ body: request.body, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -310,7 +313,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canDeleteDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response, context }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response, context }) => { try { const { analyticsId } = request.params; const { deleteDestIndex, deleteDestIndexPattern } = request.query; @@ -324,11 +327,11 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat // Check if analyticsId is valid and get destination index if (deleteDestIndex || deleteDestIndexPattern) { try { - const dfa = await legacyClient.callAsInternalUser('ml.getDataFrameAnalytics', { - analyticsId, + const { body } = await client.asInternalUser.ml.getDataFrameAnalytics({ + id: analyticsId, }); - if (Array.isArray(dfa.data_frame_analytics) && dfa.data_frame_analytics.length > 0) { - destinationIndex = dfa.data_frame_analytics[0].dest.index; + if (Array.isArray(body.data_frame_analytics) && body.data_frame_analytics.length > 0) { + destinationIndex = body.data_frame_analytics[0].dest.index; } } catch (e) { return response.customError(wrapError(e)); @@ -337,11 +340,11 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat // If user checks box to delete the destinationIndex associated with the job if (destinationIndex && deleteDestIndex) { // Verify if user has privilege to delete the destination index - const userCanDeleteDestIndex = await userCanDeleteIndex(legacyClient, destinationIndex); + const userCanDeleteDestIndex = await userCanDeleteIndex(client, destinationIndex); // If user does have privilege to delete the index, then delete the index if (userCanDeleteDestIndex) { try { - await legacyClient.callAsCurrentUser('indices.delete', { + await client.asCurrentUser.indices.delete({ index: destinationIndex, }); destIndexDeleted.success = true; @@ -370,8 +373,8 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat // Delete the data frame analytics try { - await legacyClient.callAsInternalUser('ml.deleteDataFrameAnalytics', { - analyticsId, + await client.asInternalUser.ml.deleteDataFrameAnalytics({ + id: analyticsId, }); analyticsJobDeleted.success = true; } catch (deleteDFAError) { @@ -413,14 +416,14 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canStartStopDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { analyticsId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.startDataFrameAnalytics', { - analyticsId, + const { body } = await client.asInternalUser.ml.startDataFrameAnalytics({ + id: analyticsId, }); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -449,10 +452,10 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canStartStopDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const options: { analyticsId: string; force?: boolean | undefined } = { - analyticsId: request.params.analyticsId, + const options: { id: string; force?: boolean | undefined } = { + id: request.params.analyticsId, }; // @ts-expect-error TODO: update types if (request.url?.query?.force !== undefined) { @@ -460,9 +463,9 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat options.force = request.url.query.force; } - const results = await legacyClient.callAsInternalUser('ml.stopDataFrameAnalytics', options); + const { body } = await client.asInternalUser.ml.stopDataFrameAnalytics(options); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -490,16 +493,18 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canCreateDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { analyticsId } = request.params; - const results = await legacyClient.callAsInternalUser('ml.updateDataFrameAnalytics', { - body: request.body, - analyticsId, - ...getAuthorizationHeader(request), - }); + const { body } = await client.asInternalUser.ml.updateDataFrameAnalytics( + { + id: analyticsId, + body: request.body, + }, + getAuthorizationHeader(request) + ); return response.ok({ - body: results, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -526,10 +531,10 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canGetDataFrameAnalytics'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { analyticsId } = request.params; - const { getAnalyticsAuditMessages } = analyticsAuditMessagesProvider(legacyClient); + const { getAnalyticsAuditMessages } = analyticsAuditMessagesProvider(client); const results = await getAnalyticsAuditMessages(analyticsId); return response.ok({ diff --git a/x-pack/plugins/ml/server/routes/data_visualizer.ts b/x-pack/plugins/ml/server/routes/data_visualizer.ts index 6355285127f06..a697fe017f192 100644 --- a/x-pack/plugins/ml/server/routes/data_visualizer.ts +++ b/x-pack/plugins/ml/server/routes/data_visualizer.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { DataVisualizer } from '../models/data_visualizer'; import { Field, HistogramField } from '../models/data_visualizer/data_visualizer'; @@ -17,7 +17,7 @@ import { import { RouteInitialization } from '../types'; function getOverallStats( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, indexPatternTitle: string, query: object, aggregatableFields: string[], @@ -27,7 +27,7 @@ function getOverallStats( earliestMs: number, latestMs: number ) { - const dv = new DataVisualizer(legacyClient); + const dv = new DataVisualizer(client); return dv.getOverallStats( indexPatternTitle, query, @@ -41,7 +41,7 @@ function getOverallStats( } function getStatsForFields( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, indexPatternTitle: string, query: any, fields: Field[], @@ -52,7 +52,7 @@ function getStatsForFields( interval: number, maxExamples: number ) { - const dv = new DataVisualizer(legacyClient); + const dv = new DataVisualizer(client); return dv.getStatsForFields( indexPatternTitle, query, @@ -67,13 +67,13 @@ function getStatsForFields( } function getHistogramsForFields( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, indexPatternTitle: string, query: any, fields: HistogramField[], samplerShardSize: number ) { - const dv = new DataVisualizer(legacyClient); + const dv = new DataVisualizer(client); return dv.getHistogramsForFields(indexPatternTitle, query, fields, samplerShardSize); } @@ -104,7 +104,7 @@ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canAccessML'], }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { const { params: { indexPatternTitle }, @@ -112,7 +112,7 @@ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) } = request; const results = await getHistogramsForFields( - legacyClient, + client, indexPatternTitle, query, fields, @@ -151,7 +151,7 @@ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canAccessML'], }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { const { params: { indexPatternTitle }, @@ -168,7 +168,7 @@ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) } = request; const results = await getStatsForFields( - legacyClient, + client, indexPatternTitle, query, fields, @@ -216,7 +216,7 @@ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canAccessML'], }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { const { params: { indexPatternTitle }, @@ -232,7 +232,7 @@ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) } = request; const results = await getOverallStats( - legacyClient, + client, indexPatternTitle, query, aggregatableFields, diff --git a/x-pack/plugins/ml/server/routes/datafeeds.ts b/x-pack/plugins/ml/server/routes/datafeeds.ts index 47a9afc2244d9..df2aa9e79d71b 100644 --- a/x-pack/plugins/ml/server/routes/datafeeds.ts +++ b/x-pack/plugins/ml/server/routes/datafeeds.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { RequestParams } from '@elastic/elasticsearch'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -33,12 +34,12 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetDatafeeds'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const resp = await legacyClient.callAsInternalUser('ml.datafeeds'); + const { body } = await client.asInternalUser.ml.getDatafeeds(); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -65,13 +66,13 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetDatafeeds'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const datafeedId = request.params.datafeedId; - const resp = await legacyClient.callAsInternalUser('ml.datafeeds', { datafeedId }); + const { body } = await client.asInternalUser.ml.getDatafeeds({ datafeed_id: datafeedId }); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -94,12 +95,12 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetDatafeeds'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await legacyClient.callAsInternalUser('ml.datafeedStats'); + const { body } = await client.asInternalUser.ml.getDatafeedStats(); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -126,15 +127,15 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetDatafeeds'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const datafeedId = request.params.datafeedId; - const resp = await legacyClient.callAsInternalUser('ml.datafeedStats', { - datafeedId, + const { body } = await client.asInternalUser.ml.getDatafeedStats({ + datafeed_id: datafeedId, }); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -163,17 +164,19 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const datafeedId = request.params.datafeedId; - const resp = await legacyClient.callAsInternalUser('ml.addDatafeed', { - datafeedId, - body: request.body, - ...getAuthorizationHeader(request), - }); + const { body } = await client.asInternalUser.ml.putDatafeed( + { + datafeed_id: datafeedId, + body: request.body, + }, + getAuthorizationHeader(request) + ); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -202,17 +205,19 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canUpdateDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const datafeedId = request.params.datafeedId; - const resp = await legacyClient.callAsInternalUser('ml.updateDatafeed', { - datafeedId, - body: request.body, - ...getAuthorizationHeader(request), - }); + const { body } = await client.asInternalUser.ml.updateDatafeed( + { + datafeed_id: datafeedId, + body: request.body, + }, + getAuthorizationHeader(request) + ); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -241,20 +246,20 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canDeleteDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const options: { datafeedId: string; force?: boolean } = { - datafeedId: request.params.jobId, + const options: RequestParams.MlDeleteDatafeed = { + datafeed_id: request.params.jobId, }; const force = request.query.force; if (force !== undefined) { options.force = force; } - const resp = await legacyClient.callAsInternalUser('ml.deleteDatafeed', options); + const { body } = await client.asInternalUser.ml.deleteDatafeed(options); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -283,19 +288,19 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canStartStopDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const datafeedId = request.params.datafeedId; const { start, end } = request.body; - const resp = await legacyClient.callAsInternalUser('ml.startDatafeed', { - datafeedId, + const { body } = await client.asInternalUser.ml.startDatafeed({ + datafeed_id: datafeedId, start, end, }); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -322,16 +327,16 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canStartStopDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const datafeedId = request.params.datafeedId; - const resp = await legacyClient.callAsInternalUser('ml.stopDatafeed', { - datafeedId, + const { body } = await client.asInternalUser.ml.stopDatafeed({ + datafeed_id: datafeedId, }); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); @@ -358,16 +363,18 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canPreviewDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const datafeedId = request.params.datafeedId; - const resp = await legacyClient.callAsInternalUser('ml.datafeedPreview', { - datafeedId, - ...getAuthorizationHeader(request), - }); + const { body } = await client.asInternalUser.ml.previewDatafeed( + { + datafeed_id: datafeedId, + }, + getAuthorizationHeader(request) + ); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); diff --git a/x-pack/plugins/ml/server/routes/fields_service.ts b/x-pack/plugins/ml/server/routes/fields_service.ts index 0595b31d5bbbc..e1bd8a5736d82 100644 --- a/x-pack/plugins/ml/server/routes/fields_service.ts +++ b/x-pack/plugins/ml/server/routes/fields_service.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -13,14 +13,14 @@ import { } from './schemas/fields_service_schema'; import { fieldsServiceProvider } from '../models/fields_service'; -function getCardinalityOfFields(legacyClient: ILegacyScopedClusterClient, payload: any) { - const fs = fieldsServiceProvider(legacyClient); +function getCardinalityOfFields(client: IScopedClusterClient, payload: any) { + const fs = fieldsServiceProvider(client); const { index, fieldNames, query, timeFieldName, earliestMs, latestMs } = payload; return fs.getCardinalityOfFields(index, fieldNames, query, timeFieldName, earliestMs, latestMs); } -function getTimeFieldRange(legacyClient: ILegacyScopedClusterClient, payload: any) { - const fs = fieldsServiceProvider(legacyClient); +function getTimeFieldRange(client: IScopedClusterClient, payload: any) { + const fs = fieldsServiceProvider(client); const { index, timeFieldName, query } = payload; return fs.getTimeFieldRange(index, timeFieldName, query); } @@ -50,9 +50,9 @@ export function fieldsService({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canAccessML'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getCardinalityOfFields(legacyClient, request.body); + const resp = await getCardinalityOfFields(client, request.body); return response.ok({ body: resp, @@ -85,9 +85,9 @@ export function fieldsService({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canAccessML'], }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getTimeFieldRange(legacyClient, request.body); + const resp = await getTimeFieldRange(client, request.body); return response.ok({ body: resp, diff --git a/x-pack/plugins/ml/server/routes/file_data_visualizer.ts b/x-pack/plugins/ml/server/routes/file_data_visualizer.ts index 88949fecbc7df..4c1ee87e96fc5 100644 --- a/x-pack/plugins/ml/server/routes/file_data_visualizer.ts +++ b/x-pack/plugins/ml/server/routes/file_data_visualizer.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { MAX_FILE_SIZE_BYTES } from '../../common/constants/file_datavisualizer'; import { InputOverrides, @@ -28,17 +28,13 @@ import { importFileQuerySchema, } from './schemas/file_data_visualizer_schema'; -function analyzeFiles( - legacyClient: ILegacyScopedClusterClient, - data: InputData, - overrides: InputOverrides -) { - const { analyzeFile } = fileDataVisualizerProvider(legacyClient); +function analyzeFiles(client: IScopedClusterClient, data: InputData, overrides: InputOverrides) { + const { analyzeFile } = fileDataVisualizerProvider(client); return analyzeFile(data, overrides); } function importData( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, id: string, index: string, settings: Settings, @@ -46,7 +42,7 @@ function importData( ingestPipeline: IngestPipelineWrapper, data: InputData ) { - const { importData: importDataFunc } = importDataProvider(legacyClient); + const { importData: importDataFunc } = importDataProvider(client); return importDataFunc(id, index, settings, mappings, ingestPipeline, data); } @@ -78,9 +74,9 @@ export function fileDataVisualizerRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canFindFileStructure'], }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { - const result = await analyzeFiles(legacyClient, request.body, request.query); + const result = await analyzeFiles(client, request.body, request.query); return response.ok({ body: result }); } catch (e) { return response.customError(wrapError(e)); @@ -113,7 +109,7 @@ export function fileDataVisualizerRoutes({ router, mlLicense }: RouteInitializat tags: ['access:ml:canFindFileStructure'], }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { const { id } = request.query; const { index, data, settings, mappings, ingestPipeline } = request.body; @@ -126,7 +122,7 @@ export function fileDataVisualizerRoutes({ router, mlLicense }: RouteInitializat } const result = await importData( - legacyClient, + client, id, index, settings, diff --git a/x-pack/plugins/ml/server/routes/filters.ts b/x-pack/plugins/ml/server/routes/filters.ts index bb4f8a2bebaa9..efb4acfa432f9 100644 --- a/x-pack/plugins/ml/server/routes/filters.ts +++ b/x-pack/plugins/ml/server/routes/filters.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { createFilterSchema, filterIdSchema, updateFilterSchema } from './schemas/filters_schema'; @@ -12,37 +12,33 @@ import { FilterManager, FormFilter } from '../models/filter'; // TODO - add function for returning a list of just the filter IDs. // TODO - add function for returning a list of filter IDs plus item count. -function getAllFilters(legacyClient: ILegacyScopedClusterClient) { - const mgr = new FilterManager(legacyClient); +function getAllFilters(client: IScopedClusterClient) { + const mgr = new FilterManager(client); return mgr.getAllFilters(); } -function getAllFilterStats(legacyClient: ILegacyScopedClusterClient) { - const mgr = new FilterManager(legacyClient); +function getAllFilterStats(client: IScopedClusterClient) { + const mgr = new FilterManager(client); return mgr.getAllFilterStats(); } -function getFilter(legacyClient: ILegacyScopedClusterClient, filterId: string) { - const mgr = new FilterManager(legacyClient); +function getFilter(client: IScopedClusterClient, filterId: string) { + const mgr = new FilterManager(client); return mgr.getFilter(filterId); } -function newFilter(legacyClient: ILegacyScopedClusterClient, filter: FormFilter) { - const mgr = new FilterManager(legacyClient); +function newFilter(client: IScopedClusterClient, filter: FormFilter) { + const mgr = new FilterManager(client); return mgr.newFilter(filter); } -function updateFilter( - legacyClient: ILegacyScopedClusterClient, - filterId: string, - filter: FormFilter -) { - const mgr = new FilterManager(legacyClient); +function updateFilter(client: IScopedClusterClient, filterId: string, filter: FormFilter) { + const mgr = new FilterManager(client); return mgr.updateFilter(filterId, filter); } -function deleteFilter(legacyClient: ILegacyScopedClusterClient, filterId: string) { - const mgr = new FilterManager(legacyClient); +function deleteFilter(client: IScopedClusterClient, filterId: string) { + const mgr = new FilterManager(client); return mgr.deleteFilter(filterId); } @@ -65,9 +61,9 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetFilters'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const resp = await getAllFilters(legacyClient); + const resp = await getAllFilters(client); return response.ok({ body: resp, @@ -100,9 +96,9 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetFilters'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getFilter(legacyClient, request.params.filterId); + const resp = await getFilter(client, request.params.filterId); return response.ok({ body: resp, }); @@ -134,10 +130,10 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateFilter'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const body = request.body; - const resp = await newFilter(legacyClient, body); + const resp = await newFilter(client, body); return response.ok({ body: resp, @@ -172,11 +168,11 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateFilter'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { filterId } = request.params; const body = request.body; - const resp = await updateFilter(legacyClient, filterId, body); + const resp = await updateFilter(client, filterId, body); return response.ok({ body: resp, @@ -206,10 +202,10 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canDeleteFilter'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { filterId } = request.params; - const resp = await deleteFilter(legacyClient, filterId); + const resp = await deleteFilter(client, filterId); return response.ok({ body: resp, @@ -239,9 +235,9 @@ export function filtersRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetFilters'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const resp = await getAllFilterStats(legacyClient); + const resp = await getAllFilterStats(client); return response.ok({ body: resp, diff --git a/x-pack/plugins/ml/server/routes/indices.ts b/x-pack/plugins/ml/server/routes/indices.ts index 6a759cb97f308..ee817c492dbd4 100644 --- a/x-pack/plugins/ml/server/routes/indices.ts +++ b/x-pack/plugins/ml/server/routes/indices.ts @@ -31,7 +31,7 @@ export function indicesRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canAccessML'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { body: { index, fields: requestFields }, @@ -40,8 +40,8 @@ export function indicesRoutes({ router, mlLicense }: RouteInitialization) { requestFields !== undefined && Array.isArray(requestFields) ? requestFields.join(',') : '*'; - const result = await legacyClient.callAsCurrentUser('fieldCaps', { index, fields }); - return response.ok({ body: result }); + const { body } = await client.asInternalUser.fieldCaps({ index, fields }); + return response.ok({ body }); } catch (e) { return response.customError(wrapError(e)); } diff --git a/x-pack/plugins/ml/server/routes/job_audit_messages.ts b/x-pack/plugins/ml/server/routes/job_audit_messages.ts index 2313decfabd5b..0c90081f8e755 100644 --- a/x-pack/plugins/ml/server/routes/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/routes/job_audit_messages.ts @@ -37,9 +37,9 @@ export function jobAuditMessagesRoutes({ router, mlLicense }: RouteInitializatio tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { getJobAuditMessages } = jobAuditMessagesProvider(legacyClient); + const { getJobAuditMessages } = jobAuditMessagesProvider(client); const { jobId } = request.params; const { from } = request.query; const resp = await getJobAuditMessages(jobId, from); @@ -72,9 +72,9 @@ export function jobAuditMessagesRoutes({ router, mlLicense }: RouteInitializatio tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { getJobAuditMessages } = jobAuditMessagesProvider(legacyClient); + const { getJobAuditMessages } = jobAuditMessagesProvider(client); const { from } = request.query; const resp = await getJobAuditMessages(undefined, from); diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index 3d560fc857e95..3c7f35b871b10 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -48,9 +48,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canStartStopDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { forceStartDatafeeds } = jobServiceProvider(legacyClient); + const { forceStartDatafeeds } = jobServiceProvider(client); const { datafeedIds, start, end } = request.body; const resp = await forceStartDatafeeds(datafeedIds, start, end); @@ -82,9 +82,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canStartStopDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { stopDatafeeds } = jobServiceProvider(legacyClient); + const { stopDatafeeds } = jobServiceProvider(client); const { datafeedIds } = request.body; const resp = await stopDatafeeds(datafeedIds); @@ -116,9 +116,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canDeleteJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { deleteJobs } = jobServiceProvider(legacyClient); + const { deleteJobs } = jobServiceProvider(client); const { jobIds } = request.body; const resp = await deleteJobs(jobIds); @@ -150,9 +150,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCloseJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { closeJobs } = jobServiceProvider(legacyClient); + const { closeJobs } = jobServiceProvider(client); const { jobIds } = request.body; const resp = await closeJobs(jobIds); @@ -184,9 +184,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCloseJob', 'access:ml:canStartStopDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { forceStopAndCloseJob } = jobServiceProvider(legacyClient); + const { forceStopAndCloseJob } = jobServiceProvider(client); const { jobId } = request.body; const resp = await forceStopAndCloseJob(jobId); @@ -223,9 +223,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { jobsSummary } = jobServiceProvider(legacyClient); + const { jobsSummary } = jobServiceProvider(client); const { jobIds } = request.body; const resp = await jobsSummary(jobIds); @@ -257,9 +257,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const { jobsWithTimerange } = jobServiceProvider(legacyClient); + const { jobsWithTimerange } = jobServiceProvider(client); const resp = await jobsWithTimerange(); return response.ok({ @@ -290,9 +290,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { createFullJobsList } = jobServiceProvider(legacyClient); + const { createFullJobsList } = jobServiceProvider(client); const { jobIds } = request.body; const resp = await createFullJobsList(jobIds); @@ -320,9 +320,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const { getAllGroups } = jobServiceProvider(legacyClient); + const { getAllGroups } = jobServiceProvider(client); const resp = await getAllGroups(); return response.ok({ @@ -353,9 +353,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canUpdateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { updateGroups } = jobServiceProvider(legacyClient); + const { updateGroups } = jobServiceProvider(client); const { jobs } = request.body; const resp = await updateGroups(jobs); @@ -383,9 +383,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const { deletingJobTasks } = jobServiceProvider(legacyClient); + const { deletingJobTasks } = jobServiceProvider(client); const resp = await deletingJobTasks(); return response.ok({ @@ -416,9 +416,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { jobsExist } = jobServiceProvider(legacyClient); + const { jobsExist } = jobServiceProvider(client); const { jobIds } = request.body; const resp = await jobsExist(jobIds); @@ -449,12 +449,12 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response, context }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response, context }) => { try { const { indexPattern } = request.params; const isRollup = request.query.rollup === 'true'; const savedObjectsClient = context.core.savedObjects.client; - const { newJobCaps } = jobServiceProvider(legacyClient); + const { newJobCaps } = jobServiceProvider(client); const resp = await newJobCaps(indexPattern, isRollup, savedObjectsClient); return response.ok({ @@ -485,7 +485,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { indexPatternTitle, @@ -499,7 +499,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { splitFieldValue, } = request.body; - const { newJobLineChart } = jobServiceProvider(legacyClient); + const { newJobLineChart } = jobServiceProvider(client); const resp = await newJobLineChart( indexPatternTitle, timeField, @@ -540,7 +540,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { const { indexPatternTitle, @@ -553,7 +553,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { splitFieldName, } = request.body; - const { newJobPopulationChart } = jobServiceProvider(legacyClient); + const { newJobPopulationChart } = jobServiceProvider(client); const resp = await newJobPopulationChart( indexPatternTitle, timeField, @@ -589,9 +589,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const { getAllJobAndGroupIds } = jobServiceProvider(legacyClient); + const { getAllJobAndGroupIds } = jobServiceProvider(client); const resp = await getAllJobAndGroupIds(); return response.ok({ @@ -622,9 +622,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { getLookBackProgress } = jobServiceProvider(legacyClient); + const { getLookBackProgress } = jobServiceProvider(client); const { jobId, start, end } = request.body; const resp = await getLookBackProgress(jobId, start, end); @@ -656,9 +656,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { validateCategoryExamples } = categorizationExamplesProvider(legacyClient); + const { validateCategoryExamples } = categorizationExamplesProvider(client); const { indexPatternTitle, timeField, @@ -709,9 +709,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { topCategories } = jobServiceProvider(legacyClient); + const { topCategories } = jobServiceProvider(client); const { jobId, count } = request.body; const resp = await topCategories(jobId, count); @@ -743,9 +743,9 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateJob', 'access:ml:canStartStopDatafeed'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const { revertModelSnapshot } = jobServiceProvider(legacyClient); + const { revertModelSnapshot } = jobServiceProvider(client); const { jobId, snapshotId, diff --git a/x-pack/plugins/ml/server/routes/job_validation.ts b/x-pack/plugins/ml/server/routes/job_validation.ts index 6da052663a002..b52043595327b 100644 --- a/x-pack/plugins/ml/server/routes/job_validation.ts +++ b/x-pack/plugins/ml/server/routes/job_validation.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { AnalysisConfig } from '../../common/types/anomaly_detection_jobs'; import { wrapError } from '../client/error_wrapper'; @@ -27,12 +27,12 @@ type CalculateModelMemoryLimitPayload = TypeOf; */ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, version: string) { function calculateModelMemoryLimit( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, payload: CalculateModelMemoryLimitPayload ) { const { analysisConfig, indexPattern, query, timeFieldName, earliestMs, latestMs } = payload; - return calculateModelMemoryLimitProvider(legacyClient)( + return calculateModelMemoryLimitProvider(client)( analysisConfig as AnalysisConfig, indexPattern, query, @@ -61,10 +61,10 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { let errorResp; - const resp = await estimateBucketSpanFactory(legacyClient)(request.body) + const resp = await estimateBucketSpanFactory(client)(request.body) // this catch gets triggered when the estimation code runs without error // but isn't able to come up with a bucket span estimation. // this doesn't return a HTTP error but an object with an error message @@ -109,9 +109,9 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await calculateModelMemoryLimit(legacyClient, request.body); + const resp = await calculateModelMemoryLimit(client, request.body); return response.ok({ body: resp, @@ -141,9 +141,9 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await validateCardinality(legacyClient, request.body); + const resp = await validateCardinality(client, request.body); return response.ok({ body: resp, @@ -173,11 +173,11 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { // version corresponds to the version used in documentation links. const resp = await validateJob( - legacyClient, + client, request.body, version, mlLicense.isSecurityEnabled() === false diff --git a/x-pack/plugins/ml/server/routes/modules.ts b/x-pack/plugins/ml/server/routes/modules.ts index 23e37d2213029..72a4c5e428c2b 100644 --- a/x-pack/plugins/ml/server/routes/modules.ts +++ b/x-pack/plugins/ml/server/routes/modules.ts @@ -6,11 +6,7 @@ import { TypeOf } from '@kbn/config-schema'; -import { - ILegacyScopedClusterClient, - KibanaRequest, - SavedObjectsClientContract, -} from 'kibana/server'; +import { IScopedClusterClient, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { DatafeedOverride, JobOverride } from '../../common/types/modules'; import { wrapError } from '../client/error_wrapper'; import { DataRecognizer } from '../models/data_recognizer'; @@ -23,22 +19,22 @@ import { import { RouteInitialization } from '../types'; function recognize( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, savedObjectsClient: SavedObjectsClientContract, request: KibanaRequest, indexPatternTitle: string ) { - const dr = new DataRecognizer(legacyClient, savedObjectsClient, request); + const dr = new DataRecognizer(client, savedObjectsClient, request); return dr.findMatches(indexPatternTitle); } function getModule( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, savedObjectsClient: SavedObjectsClientContract, request: KibanaRequest, moduleId: string ) { - const dr = new DataRecognizer(legacyClient, savedObjectsClient, request); + const dr = new DataRecognizer(client, savedObjectsClient, request); if (moduleId === undefined) { return dr.listModules(); } else { @@ -47,7 +43,7 @@ function getModule( } function setup( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, savedObjectsClient: SavedObjectsClientContract, request: KibanaRequest, moduleId: string, @@ -63,7 +59,7 @@ function setup( datafeedOverrides?: DatafeedOverride | DatafeedOverride[], estimateModelMemory?: boolean ) { - const dr = new DataRecognizer(legacyClient, savedObjectsClient, request); + const dr = new DataRecognizer(client, savedObjectsClient, request); return dr.setup( moduleId, prefix, @@ -81,12 +77,12 @@ function setup( } function dataRecognizerJobsExist( - legacyClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, savedObjectsClient: SavedObjectsClientContract, request: KibanaRequest, moduleId: string ) { - const dr = new DataRecognizer(legacyClient, savedObjectsClient, request); + const dr = new DataRecognizer(client, savedObjectsClient, request); return dr.dataRecognizerJobsExist(moduleId); } @@ -131,11 +127,11 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response, context }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response, context }) => { try { const { indexPatternTitle } = request.params; const results = await recognize( - legacyClient, + client, context.core.savedObjects.client, request, indexPatternTitle @@ -266,7 +262,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response, context }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response, context }) => { try { let { moduleId } = request.params; if (moduleId === '') { @@ -275,7 +271,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { moduleId = undefined; } const results = await getModule( - legacyClient, + client, context.core.savedObjects.client, request, moduleId @@ -439,7 +435,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canCreateJob'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response, context }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response, context }) => { try { const { moduleId } = request.params; @@ -458,7 +454,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { } = request.body as TypeOf; const result = await setup( - legacyClient, + client, context.core.savedObjects.client, request, moduleId, @@ -544,11 +540,11 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response, context }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response, context }) => { try { const { moduleId } = request.params; const result = await dataRecognizerJobsExist( - legacyClient, + client, context.core.savedObjects.client, request, moduleId diff --git a/x-pack/plugins/ml/server/routes/notification_settings.ts b/x-pack/plugins/ml/server/routes/notification_settings.ts index 09c145d6257a8..5bb51bf9e596c 100644 --- a/x-pack/plugins/ml/server/routes/notification_settings.ts +++ b/x-pack/plugins/ml/server/routes/notification_settings.ts @@ -26,16 +26,15 @@ export function notificationRoutes({ router, mlLicense }: RouteInitialization) { tags: ['access:ml:canAccessML'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, response }) => { try { - const params = { - includeDefaults: true, - filterPath: '**.xpack.notification', - }; - const resp = await legacyClient.callAsCurrentUser('cluster.getSettings', params); + const { body } = await client.asCurrentUser.cluster.getSettings({ + include_defaults: true, + filter_path: '**.xpack.notification', + }); return response.ok({ - body: resp, + body, }); } catch (e) { return response.customError(wrapError(e)); diff --git a/x-pack/plugins/ml/server/routes/results_service.ts b/x-pack/plugins/ml/server/routes/results_service.ts index 2af37c17f714a..4e34320d51333 100644 --- a/x-pack/plugins/ml/server/routes/results_service.ts +++ b/x-pack/plugins/ml/server/routes/results_service.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; @@ -23,8 +23,8 @@ import { getCategorizerStoppedPartitionsSchema, } from './schemas/results_service_schema'; -function getAnomaliesTableData(legacyClient: ILegacyScopedClusterClient, payload: any) { - const rs = resultsServiceProvider(legacyClient); +function getAnomaliesTableData(client: IScopedClusterClient, payload: any) { + const rs = resultsServiceProvider(client); const { jobIds, criteriaFields, @@ -53,39 +53,39 @@ function getAnomaliesTableData(legacyClient: ILegacyScopedClusterClient, payload ); } -function getCategoryDefinition(legacyClient: ILegacyScopedClusterClient, payload: any) { - const rs = resultsServiceProvider(legacyClient); +function getCategoryDefinition(client: IScopedClusterClient, payload: any) { + const rs = resultsServiceProvider(client); return rs.getCategoryDefinition(payload.jobId, payload.categoryId); } -function getCategoryExamples(legacyClient: ILegacyScopedClusterClient, payload: any) { - const rs = resultsServiceProvider(legacyClient); +function getCategoryExamples(client: IScopedClusterClient, payload: any) { + const rs = resultsServiceProvider(client); const { jobId, categoryIds, maxExamples } = payload; return rs.getCategoryExamples(jobId, categoryIds, maxExamples); } -function getMaxAnomalyScore(legacyClient: ILegacyScopedClusterClient, payload: any) { - const rs = resultsServiceProvider(legacyClient); +function getMaxAnomalyScore(client: IScopedClusterClient, payload: any) { + const rs = resultsServiceProvider(client); const { jobIds, earliestMs, latestMs } = payload; return rs.getMaxAnomalyScore(jobIds, earliestMs, latestMs); } -function getPartitionFieldsValues(legacyClient: ILegacyScopedClusterClient, payload: any) { - const rs = resultsServiceProvider(legacyClient); +function getPartitionFieldsValues(client: IScopedClusterClient, payload: any) { + const rs = resultsServiceProvider(client); const { jobId, searchTerm, criteriaFields, earliestMs, latestMs } = payload; return rs.getPartitionFieldsValues(jobId, searchTerm, criteriaFields, earliestMs, latestMs); } -function getCategorizerStats(legacyClient: ILegacyScopedClusterClient, params: any, query: any) { +function getCategorizerStats(client: IScopedClusterClient, params: any, query: any) { const { jobId } = params; const { partitionByValue } = query; - const rs = resultsServiceProvider(legacyClient); + const rs = resultsServiceProvider(client); return rs.getCategorizerStats(jobId, partitionByValue); } -function getCategoryStoppedPartitions(legacyClient: ILegacyScopedClusterClient, payload: any) { +function getCategoryStoppedPartitions(client: IScopedClusterClient, payload: any) { const { jobIds, fieldToBucket } = payload; - const rs = resultsServiceProvider(legacyClient); + const rs = resultsServiceProvider(client); return rs.getCategoryStoppedPartitions(jobIds, fieldToBucket); } @@ -112,9 +112,9 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getAnomaliesTableData(legacyClient, request.body); + const resp = await getAnomaliesTableData(client, request.body); return response.ok({ body: resp, @@ -144,9 +144,9 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getCategoryDefinition(legacyClient, request.body); + const resp = await getCategoryDefinition(client, request.body); return response.ok({ body: resp, @@ -176,9 +176,9 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getMaxAnomalyScore(legacyClient, request.body); + const resp = await getMaxAnomalyScore(client, request.body); return response.ok({ body: resp, @@ -208,9 +208,9 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getCategoryExamples(legacyClient, request.body); + const resp = await getCategoryExamples(client, request.body); return response.ok({ body: resp, @@ -240,9 +240,9 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getPartitionFieldsValues(legacyClient, request.body); + const resp = await getPartitionFieldsValues(client, request.body); return response.ok({ body: resp, @@ -269,14 +269,14 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { - const body = { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { + const { body } = await client.asInternalUser.search({ ...request.body, index: ML_RESULTS_INDEX_PATTERN, - }; + }); try { return response.ok({ - body: await legacyClient.callAsInternalUser('search', body), + body, }); } catch (error) { return response.customError(wrapError(error)); @@ -304,9 +304,9 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getCategorizerStats(legacyClient, request.params, request.query); + const resp = await getCategorizerStats(client, request.params, request.query); return response.ok({ body: resp, }); @@ -334,9 +334,9 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { - const resp = await getCategoryStoppedPartitions(legacyClient, request.body); + const resp = await getCategoryStoppedPartitions(client, request.body); return response.ok({ body: resp, }); diff --git a/x-pack/plugins/ml/server/routes/system.ts b/x-pack/plugins/ml/server/routes/system.ts index 273b86163245f..3a66f60943bb3 100644 --- a/x-pack/plugins/ml/server/routes/system.ts +++ b/x-pack/plugins/ml/server/routes/system.ts @@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema'; import { Request } from 'hapi'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { IScopedClusterClient } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { mlLog } from '../client/log'; import { capabilitiesProvider } from '../lib/capabilities'; @@ -21,17 +21,16 @@ export function systemRoutes( { router, mlLicense }: RouteInitialization, { spaces, cloud, resolveMlCapabilities }: SystemRouteDeps ) { - async function getNodeCount(legacyClient: ILegacyScopedClusterClient) { - const filterPath = 'nodes.*.attributes'; - const resp = await legacyClient.callAsInternalUser('nodes.info', { - filterPath, + async function getNodeCount(client: IScopedClusterClient) { + const { body } = await client.asInternalUser.nodes.info({ + filter_path: 'nodes.*.attributes', }); let count = 0; - if (typeof resp.nodes === 'object') { - Object.keys(resp.nodes).forEach((k) => { - if (resp.nodes[k].attributes !== undefined) { - const maxOpenJobs = resp.nodes[k].attributes['ml.max_open_jobs']; + if (typeof body.nodes === 'object') { + Object.keys(body.nodes).forEach((k) => { + if (body.nodes[k].attributes !== undefined) { + const maxOpenJobs = body.nodes[k].attributes['ml.max_open_jobs']; if (maxOpenJobs !== null && maxOpenJobs > 0) { count++; } @@ -58,15 +57,15 @@ export function systemRoutes( tags: ['access:ml:canAccessML'], }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { - const { callAsCurrentUser, callAsInternalUser } = legacyClient; + const { asCurrentUser, asInternalUser } = client; let upgradeInProgress = false; try { - const info = await callAsInternalUser('ml.info'); + const { body } = await asInternalUser.ml.info(); // if ml indices are currently being migrated, upgrade_mode will be set to true // pass this back with the privileges to allow for the disabling of UI controls. - upgradeInProgress = info.upgrade_mode === true; + upgradeInProgress = body.upgrade_mode === true; } catch (error) { // if the ml.info check fails, it could be due to the user having insufficient privileges // most likely they do not have the ml_user role and therefore will be blocked from using @@ -90,11 +89,12 @@ export function systemRoutes( }, }); } else { - const body = request.body; - const resp = await callAsCurrentUser('ml.privilegeCheck', { body }); - resp.upgradeInProgress = upgradeInProgress; + const { body } = await asCurrentUser.security.hasPrivileges({ body: request.body }); return response.ok({ - body: resp, + body: { + ...body, + upgradeInProgress, + }, }); } } catch (error) { @@ -115,7 +115,7 @@ export function systemRoutes( path: '/api/ml/ml_capabilities', validate: false, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { // if spaces is disabled force isMlEnabledInSpace to be true const { isMlEnabledInSpace } = @@ -129,7 +129,7 @@ export function systemRoutes( } const { getCapabilities } = capabilitiesProvider( - legacyClient, + client, mlCapabilities, mlLicense, isMlEnabledInSpace @@ -159,10 +159,10 @@ export function systemRoutes( }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { return response.ok({ - body: await getNodeCount(legacyClient), + body: await getNodeCount(client), }); } catch (e) { return response.customError(wrapError(e)); @@ -185,12 +185,12 @@ export function systemRoutes( tags: ['access:ml:canAccessML'], }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { - const info = await legacyClient.callAsInternalUser('ml.info'); + const { body } = await client.asInternalUser.ml.info(); const cloudId = cloud && cloud.cloudId; return response.ok({ - body: { ...info, cloudId }, + body: { ...body, cloudId }, }); } catch (error) { return response.customError(wrapError(error)); @@ -216,10 +216,11 @@ export function systemRoutes( tags: ['access:ml:canGetJobs'], }, }, - mlLicense.fullLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { try { + const { body } = await client.asCurrentUser.search(request.body); return response.ok({ - body: await legacyClient.callAsCurrentUser('search', request.body), + body, }); } catch (error) { return response.customError(wrapError(error)); @@ -243,22 +244,21 @@ export function systemRoutes( tags: ['access:ml:canAccessML'], }, }, - mlLicense.basicLicenseAPIGuard(async ({ legacyClient, request, response }) => { + mlLicense.basicLicenseAPIGuard(async ({ client, request, response }) => { try { const { index } = request.body; const options = { index: [index], fields: ['*'], - ignoreUnavailable: true, - allowNoIndices: true, - ignore: 404, + ignore_unavailable: true, + allow_no_indices: true, }; - const fieldsResult = await legacyClient.callAsCurrentUser('fieldCaps', options); + const { body } = await client.asCurrentUser.fieldCaps(options); const result = { exists: false }; - if (Array.isArray(fieldsResult.indices) && fieldsResult.indices.length !== 0) { + if (Array.isArray(body.indices) && body.indices.length !== 0) { result.exists = true; } diff --git a/x-pack/plugins/ml/server/shared_services/errors.ts b/x-pack/plugins/ml/server/shared_services/errors.ts new file mode 100644 index 0000000000000..f15a85a490a46 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/errors.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export class MLClusterClientUninitialized extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/x-pack/plugins/ml/server/shared_services/license_checks/errors.ts b/x-pack/plugins/ml/server/shared_services/license_checks/errors.ts new file mode 100644 index 0000000000000..18e7dab43fda7 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/license_checks/errors.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable max-classes-per-file */ + +export class InsufficientFullLicenseError extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class InsufficientBasicLicenseError extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/x-pack/plugins/ml/server/shared_services/license_checks/index.ts b/x-pack/plugins/ml/server/shared_services/license_checks/index.ts new file mode 100644 index 0000000000000..6b837dadf5c0d --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/license_checks/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LicenseCheck, licenseChecks } from './license_checks'; +export { InsufficientBasicLicenseError, InsufficientFullLicenseError } from './errors'; diff --git a/x-pack/plugins/ml/server/shared_services/license_checks.ts b/x-pack/plugins/ml/server/shared_services/license_checks/license_checks.ts similarity index 66% rename from x-pack/plugins/ml/server/shared_services/license_checks.ts rename to x-pack/plugins/ml/server/shared_services/license_checks/license_checks.ts index 191124ffa5f3a..3d9de1ef70f2d 100644 --- a/x-pack/plugins/ml/server/shared_services/license_checks.ts +++ b/x-pack/plugins/ml/server/shared_services/license_checks/license_checks.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MlServerLicense } from '../lib/license'; +import { MlServerLicense } from '../../lib/license'; +import { InsufficientFullLicenseError, InsufficientBasicLicenseError } from './errors'; export type LicenseCheck = () => void; @@ -14,12 +15,12 @@ export function licenseChecks( return { isFullLicense() { if (mlLicense.isFullLicense() === false) { - throw Error('Platinum, Enterprise or trial license needed'); + throw new InsufficientFullLicenseError('Platinum, Enterprise or trial license needed'); } }, isMinimumLicense() { if (mlLicense.isMinimumLicense() === false) { - throw Error('Basic license needed'); + throw new InsufficientBasicLicenseError('Basic license needed'); } }, }; diff --git a/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts b/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts index 603b4fba17adb..53898cb64d07f 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts @@ -4,40 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; +import { KibanaRequest } from 'kibana/server'; import { Job } from '../../../common/types/anomaly_detection_jobs'; -import { SharedServicesChecks } from '../shared_services'; +import { GetGuards } from '../shared_services'; export interface AnomalyDetectorsProvider { anomalyDetectorsProvider( - mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest ): { jobs(jobId?: string): Promise<{ count: number; jobs: Job[] }>; }; } -export function getAnomalyDetectorsProvider({ - isFullLicense, - getHasMlCapabilities, -}: SharedServicesChecks): AnomalyDetectorsProvider { +export function getAnomalyDetectorsProvider(getGuards: GetGuards): AnomalyDetectorsProvider { return { - anomalyDetectorsProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { - // APM is using this service in anomaly alert, kibana alerting doesn't provide request object - // So we are adding a dummy request for now - // TODO: Remove this once kibana alerting provides request object - const hasMlCapabilities = - request.params !== 'DummyKibanaRequest' - ? getHasMlCapabilities(request) - : (_caps: string[]) => Promise.resolve(); + anomalyDetectorsProvider(request: KibanaRequest) { return { async jobs(jobId?: string) { - isFullLicense(); - await hasMlCapabilities(['canGetJobs']); - return mlClusterClient.callAsInternalUser( - 'ml.jobs', - jobId !== undefined ? { jobId } : {} - ); + return await getGuards(request) + .isFullLicense() + .hasMlCapabilities(['canGetJobs']) + .ok(async ({ scopedClient }) => { + const { body } = await scopedClient.asInternalUser.ml.getJobs<{ + count: number; + jobs: Job[]; + }>(jobId !== undefined ? { job_id: jobId } : undefined); + return body; + }); }, }; }, diff --git a/x-pack/plugins/ml/server/shared_services/providers/job_service.ts b/x-pack/plugins/ml/server/shared_services/providers/job_service.ts index c734dcc1583a1..2897bf08717f8 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/job_service.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/job_service.ts @@ -4,38 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; +import { KibanaRequest } from 'kibana/server'; import { jobServiceProvider } from '../../models/job_service'; -import { SharedServicesChecks } from '../shared_services'; +import { GetGuards } from '../shared_services'; type OrigJobServiceProvider = ReturnType; export interface JobServiceProvider { jobServiceProvider( - mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest ): { jobsSummary: OrigJobServiceProvider['jobsSummary']; }; } -export function getJobServiceProvider({ - isFullLicense, - getHasMlCapabilities, -}: SharedServicesChecks): JobServiceProvider { +export function getJobServiceProvider(getGuards: GetGuards): JobServiceProvider { return { - jobServiceProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { - // const hasMlCapabilities = getHasMlCapabilities(request); - const { jobsSummary } = jobServiceProvider(mlClusterClient); + jobServiceProvider(request: KibanaRequest) { return { - async jobsSummary(...args) { - isFullLicense(); - // Removed while https://github.com/elastic/kibana/issues/64588 exists. - // SIEM are calling this endpoint with a dummy request object from their alerting - // integration and currently alerting does not supply a request object. - // await hasMlCapabilities(['canGetJobs']); - - return jobsSummary(...args); + jobsSummary: async (...args) => { + return await getGuards(request) + .isFullLicense() + .hasMlCapabilities(['canGetJobs']) + .ok(async ({ scopedClient }) => { + const { jobsSummary } = jobServiceProvider(scopedClient); + return jobsSummary(...args); + }); }, }; }, diff --git a/x-pack/plugins/ml/server/shared_services/providers/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/modules.ts index fb7d59f9c8218..a727d96433f1d 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/modules.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/modules.ts @@ -4,23 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - ILegacyScopedClusterClient, - KibanaRequest, - SavedObjectsClientContract, -} from 'kibana/server'; +import { IScopedClusterClient, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { DataRecognizer } from '../../models/data_recognizer'; -import { SharedServicesChecks } from '../shared_services'; +import { GetGuards } from '../shared_services'; import { moduleIdParamSchema, setupModuleBodySchema } from '../../routes/schemas/modules'; -import { HasMlCapabilities } from '../../lib/capabilities'; export type ModuleSetupPayload = TypeOf & TypeOf; export interface ModulesProvider { modulesProvider( - mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest, savedObjectsClient: SavedObjectsClientContract ): { @@ -31,61 +25,58 @@ export interface ModulesProvider { }; } -export function getModulesProvider({ - isFullLicense, - getHasMlCapabilities, -}: SharedServicesChecks): ModulesProvider { +export function getModulesProvider(getGuards: GetGuards): ModulesProvider { return { - modulesProvider( - mlClusterClient: ILegacyScopedClusterClient, - request: KibanaRequest, - savedObjectsClient: SavedObjectsClientContract - ) { - let hasMlCapabilities: HasMlCapabilities; - if (request.params === 'DummyKibanaRequest') { - hasMlCapabilities = () => Promise.resolve(); - } else { - hasMlCapabilities = getHasMlCapabilities(request); - } - const dr = dataRecognizerFactory(mlClusterClient, savedObjectsClient, request); - + modulesProvider(request: KibanaRequest, savedObjectsClient: SavedObjectsClientContract) { return { async recognize(...args) { - isFullLicense(); - await hasMlCapabilities(['canCreateJob']); - - return dr.findMatches(...args); + return await getGuards(request) + .isFullLicense() + .hasMlCapabilities(['canGetJobs']) + .ok(async ({ scopedClient }) => { + const dr = dataRecognizerFactory(scopedClient, savedObjectsClient, request); + return dr.findMatches(...args); + }); }, async getModule(moduleId: string) { - isFullLicense(); - await hasMlCapabilities(['canGetJobs']); - - return dr.getModule(moduleId); + return await getGuards(request) + .isFullLicense() + .hasMlCapabilities(['canGetJobs']) + .ok(async ({ scopedClient }) => { + const dr = dataRecognizerFactory(scopedClient, savedObjectsClient, request); + return dr.getModule(moduleId); + }); }, async listModules() { - isFullLicense(); - await hasMlCapabilities(['canGetJobs']); - - return dr.listModules(); + return await getGuards(request) + .isFullLicense() + .hasMlCapabilities(['canGetJobs']) + .ok(async ({ scopedClient }) => { + const dr = dataRecognizerFactory(scopedClient, savedObjectsClient, request); + return dr.listModules(); + }); }, async setup(payload: ModuleSetupPayload) { - isFullLicense(); - await hasMlCapabilities(['canCreateJob']); - - return dr.setup( - payload.moduleId, - payload.prefix, - payload.groups, - payload.indexPatternName, - payload.query, - payload.useDedicatedIndex, - payload.startDatafeed, - payload.start, - payload.end, - payload.jobOverrides, - payload.datafeedOverrides, - payload.estimateModelMemory - ); + return await getGuards(request) + .isFullLicense() + .hasMlCapabilities(['canCreateJob']) + .ok(async ({ scopedClient }) => { + const dr = dataRecognizerFactory(scopedClient, savedObjectsClient, request); + return dr.setup( + payload.moduleId, + payload.prefix, + payload.groups, + payload.indexPatternName, + payload.query, + payload.useDedicatedIndex, + payload.startDatafeed, + payload.start, + payload.end, + payload.jobOverrides, + payload.datafeedOverrides, + payload.estimateModelMemory + ); + }); }, }; }, @@ -93,9 +84,9 @@ export function getModulesProvider({ } function dataRecognizerFactory( - mlClusterClient: ILegacyScopedClusterClient, + client: IScopedClusterClient, savedObjectsClient: SavedObjectsClientContract, request: KibanaRequest ) { - return new DataRecognizer(mlClusterClient, savedObjectsClient, request); + return new DataRecognizer(client, savedObjectsClient, request); } diff --git a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts index 6af4eb008567a..5536765cc376a 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts @@ -4,41 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; +import { KibanaRequest } from 'kibana/server'; import { resultsServiceProvider } from '../../models/results_service'; -import { SharedServicesChecks } from '../shared_services'; +import { GetGuards } from '../shared_services'; type OrigResultsServiceProvider = ReturnType; export interface ResultsServiceProvider { resultsServiceProvider( - mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest ): { getAnomaliesTableData: OrigResultsServiceProvider['getAnomaliesTableData']; }; } -export function getResultsServiceProvider({ - isFullLicense, - getHasMlCapabilities, -}: SharedServicesChecks): ResultsServiceProvider { +export function getResultsServiceProvider(getGuards: GetGuards): ResultsServiceProvider { return { - resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { - // Uptime is using this service in anomaly alert, kibana alerting doesn't provide request object - // So we are adding a dummy request for now - // TODO: Remove this once kibana alerting provides request object - const hasMlCapabilities = - request.params !== 'DummyKibanaRequest' - ? getHasMlCapabilities(request) - : (_caps: string[]) => Promise.resolve(); - - const { getAnomaliesTableData } = resultsServiceProvider(mlClusterClient); + resultsServiceProvider(request: KibanaRequest) { return { async getAnomaliesTableData(...args) { - isFullLicense(); - await hasMlCapabilities(['canGetJobs']); - return getAnomaliesTableData(...args); + return await getGuards(request) + .isFullLicense() + .hasMlCapabilities(['canGetJobs']) + .ok(async ({ scopedClient }) => { + const { getAnomaliesTableData } = resultsServiceProvider(scopedClient); + return getAnomaliesTableData(...args); + }); }, }; }, diff --git a/x-pack/plugins/ml/server/shared_services/providers/system.ts b/x-pack/plugins/ml/server/shared_services/providers/system.ts index d292abc438a2f..3217ff13787b0 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/system.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/system.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; -import { SearchResponse, SearchParams } from 'elasticsearch'; +import { KibanaRequest } from 'kibana/server'; +import { SearchResponse } from 'elasticsearch'; +import { RequestParams } from '@elastic/elasticsearch'; import { MlServerLicense } from '../../lib/license'; import { CloudSetup } from '../../../../cloud/server'; import { spacesUtilsProvider } from '../../lib/spaces_utils'; @@ -14,73 +15,79 @@ import { capabilitiesProvider } from '../../lib/capabilities'; import { MlInfoResponse } from '../../../common/types/ml_server_info'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { MlCapabilitiesResponse, ResolveMlCapabilities } from '../../../common/types/capabilities'; -import { SharedServicesChecks } from '../shared_services'; +import { GetGuards } from '../shared_services'; export interface MlSystemProvider { mlSystemProvider( - mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest ): { mlCapabilities(): Promise; mlInfo(): Promise; - mlAnomalySearch(searchParams: SearchParams): Promise>; + mlAnomalySearch(searchParams: RequestParams.Search): Promise>; }; } export function getMlSystemProvider( - { isMinimumLicense, isFullLicense, getHasMlCapabilities }: SharedServicesChecks, + getGuards: GetGuards, mlLicense: MlServerLicense, spaces: SpacesPluginSetup | undefined, cloud: CloudSetup | undefined, resolveMlCapabilities: ResolveMlCapabilities ): MlSystemProvider { return { - mlSystemProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { - // const hasMlCapabilities = getHasMlCapabilities(request); - const { callAsInternalUser } = mlClusterClient; + mlSystemProvider(request: KibanaRequest) { return { async mlCapabilities() { - isMinimumLicense(); + return await getGuards(request) + .isMinimumLicense() + .ok(async ({ scopedClient }) => { + const { isMlEnabledInSpace } = + spaces !== undefined + ? spacesUtilsProvider(spaces, request) + : { isMlEnabledInSpace: async () => true }; - const { isMlEnabledInSpace } = - spaces !== undefined - ? spacesUtilsProvider(spaces, request) - : { isMlEnabledInSpace: async () => true }; + const mlCapabilities = await resolveMlCapabilities(request); + if (mlCapabilities === null) { + throw new Error('mlCapabilities is not defined'); + } - const mlCapabilities = await resolveMlCapabilities(request); - if (mlCapabilities === null) { - throw new Error('mlCapabilities is not defined'); - } - - const { getCapabilities } = capabilitiesProvider( - mlClusterClient, - mlCapabilities, - mlLicense, - isMlEnabledInSpace - ); - return getCapabilities(); + const { getCapabilities } = capabilitiesProvider( + scopedClient, + mlCapabilities, + mlLicense, + isMlEnabledInSpace + ); + return getCapabilities(); + }); }, async mlInfo(): Promise { - isMinimumLicense(); + return await getGuards(request) + .isMinimumLicense() + .ok(async ({ scopedClient }) => { + const { asInternalUser } = scopedClient; - const info = await callAsInternalUser('ml.info'); - const cloudId = cloud && cloud.cloudId; - return { - ...info, - cloudId, - }; + const { body: info } = await asInternalUser.ml.info(); + const cloudId = cloud && cloud.cloudId; + return { + ...info, + cloudId, + }; + }); }, - async mlAnomalySearch(searchParams: SearchParams): Promise> { - isFullLicense(); - // Removed while https://github.com/elastic/kibana/issues/64588 exists. - // SIEM are calling this endpoint with a dummy request object from their alerting - // integration and currently alerting does not supply a request object. - // await hasMlCapabilities(['canAccessML']); - - return callAsInternalUser('search', { - ...searchParams, - index: ML_RESULTS_INDEX_PATTERN, - }); + async mlAnomalySearch( + searchParams: RequestParams.Search + ): Promise> { + return await getGuards(request) + .isFullLicense() + .hasMlCapabilities(['canAccessML']) + .ok(async ({ scopedClient }) => { + const { asInternalUser } = scopedClient; + const { body } = await asInternalUser.search>({ + ...searchParams, + index: ML_RESULTS_INDEX_PATTERN, + }); + return body; + }); }, }; }, diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index 3345111fad4ae..4c568e4515a27 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'kibana/server'; +import { IClusterClient, IScopedClusterClient } from 'kibana/server'; +// including KibanaRequest from 'kibana/server' causes an error +// when being used with instanceof +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaRequest } from '../../.././../../src/core/server/http'; import { MlServerLicense } from '../lib/license'; import { SpacesPluginSetup } from '../../../spaces/server'; @@ -18,8 +22,9 @@ import { AnomalyDetectorsProvider, getAnomalyDetectorsProvider, } from './providers/anomaly_detectors'; -import { ResolveMlCapabilities } from '../../common/types/capabilities'; +import { ResolveMlCapabilities, MlCapabilitiesKey } from '../../common/types/capabilities'; import { hasMlCapabilitiesProvider, HasMlCapabilities } from '../lib/capabilities'; +import { MLClusterClientUninitialized } from './errors'; export type SharedServices = JobServiceProvider & AnomalyDetectorsProvider & @@ -27,31 +32,97 @@ export type SharedServices = JobServiceProvider & ModulesProvider & ResultsServiceProvider; +interface Guards { + isMinimumLicense(): Guards; + isFullLicense(): Guards; + hasMlCapabilities: (caps: MlCapabilitiesKey[]) => Guards; + ok(callback: OkCallback): any; +} + +export type GetGuards = (request: KibanaRequest) => Guards; + export interface SharedServicesChecks { - isFullLicense(): void; - isMinimumLicense(): void; - getHasMlCapabilities(request: KibanaRequest): HasMlCapabilities; + getGuards(request: KibanaRequest): Guards; } +interface OkParams { + scopedClient: IScopedClusterClient; +} + +type OkCallback = (okParams: OkParams) => any; + export function createSharedServices( mlLicense: MlServerLicense, spaces: SpacesPluginSetup | undefined, cloud: CloudSetup, - resolveMlCapabilities: ResolveMlCapabilities + resolveMlCapabilities: ResolveMlCapabilities, + getClusterClient: () => IClusterClient | null ): SharedServices { + const getRequestItems = getRequestItemsProvider(resolveMlCapabilities, getClusterClient); const { isFullLicense, isMinimumLicense } = licenseChecks(mlLicense); - const getHasMlCapabilities = hasMlCapabilitiesProvider(resolveMlCapabilities); - const checks: SharedServicesChecks = { - isFullLicense, - isMinimumLicense, - getHasMlCapabilities, - }; + + function getGuards(request: KibanaRequest): Guards { + const { hasMlCapabilities, scopedClient } = getRequestItems(request); + const asyncGuards: Array> = []; + + const guards: Guards = { + isMinimumLicense: () => { + isMinimumLicense(); + return guards; + }, + isFullLicense: () => { + isFullLicense(); + return guards; + }, + hasMlCapabilities: (caps: MlCapabilitiesKey[]) => { + asyncGuards.push(hasMlCapabilities(caps)); + return guards; + }, + async ok(callback: OkCallback) { + await Promise.all(asyncGuards); + return callback({ scopedClient }); + }, + }; + return guards; + } return { - ...getJobServiceProvider(checks), - ...getAnomalyDetectorsProvider(checks), - ...getModulesProvider(checks), - ...getResultsServiceProvider(checks), - ...getMlSystemProvider(checks, mlLicense, spaces, cloud, resolveMlCapabilities), + ...getJobServiceProvider(getGuards), + ...getAnomalyDetectorsProvider(getGuards), + ...getModulesProvider(getGuards), + ...getResultsServiceProvider(getGuards), + ...getMlSystemProvider(getGuards, mlLicense, spaces, cloud, resolveMlCapabilities), + }; +} + +function getRequestItemsProvider( + resolveMlCapabilities: ResolveMlCapabilities, + getClusterClient: () => IClusterClient | null +) { + return (request: KibanaRequest) => { + const getHasMlCapabilities = hasMlCapabilitiesProvider(resolveMlCapabilities); + let hasMlCapabilities: HasMlCapabilities; + let scopedClient: IScopedClusterClient; + // While https://github.com/elastic/kibana/issues/64588 exists we + // will not receive a real request object when being called from an alert. + // instead a dummy request object will be supplied + const clusterClient = getClusterClient(); + + if (clusterClient === null) { + throw new MLClusterClientUninitialized(`ML's cluster client has not been initialized`); + } + + if (request instanceof KibanaRequest) { + hasMlCapabilities = getHasMlCapabilities(request); + scopedClient = clusterClient.asScoped(request); + } else { + hasMlCapabilities = () => Promise.resolve(); + const { asInternalUser } = clusterClient; + scopedClient = { + asInternalUser, + asCurrentUser: asInternalUser, + }; + } + return { hasMlCapabilities, scopedClient }; }; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_ml_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_ml_signals.ts index 18a64a12431b8..bd9bf50688b58 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_ml_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_ml_signals.ts @@ -6,13 +6,12 @@ import dateMath from '@elastic/datemath'; -import { ILegacyScopedClusterClient, KibanaRequest } from '../../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../../src/core/server'; import { MlPluginSetup } from '../../../../../ml/server'; import { getAnomalies } from '../../machine_learning'; export const findMlSignals = async ({ ml, - clusterClient, request, jobId, anomalyThreshold, @@ -20,14 +19,13 @@ export const findMlSignals = async ({ to, }: { ml: MlPluginSetup; - clusterClient: ILegacyScopedClusterClient; request: KibanaRequest; jobId: string; anomalyThreshold: number; from: string; to: string; }) => { - const { mlAnomalySearch } = ml.mlSystemProvider(clusterClient, request); + const { mlAnomalySearch } = ml.mlSystemProvider(request); const params = { jobIds: [jobId], threshold: anomalyThreshold, 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 c5124edcaf187..9d688736a9846 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 @@ -185,12 +185,12 @@ export const signalRulesAlertType = ({ ); } - const scopedClusterClient = services.getLegacyScopedClusterClient(ml.mlClient); // Using fake KibanaRequest as it is needed to satisfy the ML Services API, but can be empty as it is // currently unused by the jobsSummary function. - const summaryJobs = await ( - await ml.jobServiceProvider(scopedClusterClient, ({} as unknown) as KibanaRequest) - ).jobsSummary([machineLearningJobId]); + const fakeRequest = {} as KibanaRequest; + const summaryJobs = await ml + .jobServiceProvider(fakeRequest) + .jobsSummary([machineLearningJobId]); const jobSummary = summaryJobs.find((job) => job.id === machineLearningJobId); if (jobSummary == null || !isJobStarted(jobSummary.jobState, jobSummary.datafeedState)) { @@ -207,7 +207,6 @@ export const signalRulesAlertType = ({ const anomalyResults = await findMlSignals({ ml, - clusterClient: scopedClusterClient, // Using fake KibanaRequest as it is needed to satisfy the ML Services API, but can be empty as it is // currently unused by the mlAnomalySearch function. request: ({} as unknown) as KibanaRequest, diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/authz.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/authz.ts index 98de9536b1baa..386eda5281f0c 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/authz.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/authz.ts @@ -114,7 +114,6 @@ export const isMlAdmin = async ({ request: KibanaRequest; ml: MlPluginSetup; }): Promise => { - const scopedMlClient = ml.mlClient.asScoped(request); - const mlCapabilities = await ml.mlSystemProvider(scopedMlClient, request).mlCapabilities(); + const mlCapabilities = await ml.mlSystemProvider(request).mlCapabilities(); return hasMlAdminPermissions(mlCapabilities); }; diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts index ad2f1e5a8285c..c17d9ab1bb46e 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SearchResponse, SearchParams } from 'elasticsearch'; +import { SearchResponse } from 'elasticsearch'; +import { RequestParams } from '@elastic/elasticsearch'; import { AnomalyRecordDoc as Anomaly } from '../../../../ml/server'; export { Anomaly }; export type AnomalyResults = SearchResponse; -type MlAnomalySearch = (searchParams: SearchParams) => Promise>; +type MlAnomalySearch = (searchParams: RequestParams.Search) => Promise>; export interface AnomaliesSearchParams { jobIds: string[]; diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts index a6d4dc7a38e14..5cf17af2fa9c0 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts @@ -172,18 +172,12 @@ export const getMlJobsUsage = async (ml: MlPluginSetup | undefined): Promise module.jobs); - const jobs = await ml.jobServiceProvider(internalMlClient, fakeRequest).jobsSummary(); + const jobs = await ml.jobServiceProvider(fakeRequest).jobsSummary(); jobsUsage = jobs.filter(isSecurityJob).reduce((usage, job) => { const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms.ts b/x-pack/plugins/transform/server/routes/api/field_histograms.ts index f2fd81368ec17..2642040c4cd0d 100644 --- a/x-pack/plugins/transform/server/routes/api/field_histograms.ts +++ b/x-pack/plugins/transform/server/routes/api/field_histograms.ts @@ -34,7 +34,7 @@ export function registerFieldHistogramsRoutes({ router, license }: RouteDependen try { const resp = await getHistogramsForFields( - ctx.transform!.dataClient, + ctx.core.elasticsearch.client, indexPatternTitle, query, fields, diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts index 9ed453d286285..568b0873b8dbb 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { KibanaRequest } from 'kibana/server'; import moment from 'moment'; import { schema } from '@kbn/config-schema'; -import { ILegacyScopedClusterClient } from 'kibana/server'; import { updateState } from './common'; import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants/alerts'; import { commonStateTranslations, durationAnomalyTranslations } from './translations'; @@ -36,13 +36,11 @@ export const getAnomalySummary = (anomaly: AnomaliesTableRecord, monitorInfo: Pi const getAnomalies = async ( plugins: UptimeCorePlugins, - mlClusterClient: ILegacyScopedClusterClient, params: Record, lastCheckedAt: string ) => { - const { getAnomaliesTableData } = plugins.ml.resultsServiceProvider(mlClusterClient, { - params: 'DummyKibanaRequest', - } as any); + const fakeRequest = {} as KibanaRequest; + const { getAnomaliesTableData } = plugins.ml.resultsServiceProvider(fakeRequest); return await getAnomaliesTableData( [getMLJobId(params.monitorId)], @@ -82,23 +80,12 @@ export const durationAnomalyAlertFactory: UptimeAlertTypeFactory = (_server, _li producer: 'uptime', async executor(options) { const { - services: { - alertInstanceFactory, - callCluster, - savedObjectsClient, - getLegacyScopedClusterClient, - }, + services: { alertInstanceFactory, callCluster, savedObjectsClient }, state, params, } = options; - const { anomalies } = - (await getAnomalies( - plugins, - getLegacyScopedClusterClient(plugins.ml.mlClient), - params, - state.lastCheckedAt - )) ?? {}; + const { anomalies } = (await getAnomalies(plugins, params, state.lastCheckedAt)) ?? {}; const foundAnomalies = anomalies?.length > 0; diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts index f4964308cd8c9..7aa5c180c5a02 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts @@ -258,7 +258,7 @@ export default ({ getService }: FtrProviderContext) => { description: 'Not found', }; const id = `${jobId}_invalid`; - const message = `[resource_not_found_exception] No known data frame analytics with id [${id}]`; + const message = 'resource_not_found_exception'; const { body } = await supertest .post(`/api/ml/data_frame/analytics/${id}/_update`) diff --git a/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts b/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts index 299f5f93fd281..a5969bdffbf8a 100644 --- a/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts +++ b/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts @@ -60,8 +60,7 @@ export default ({ getService }: FtrProviderContext) => { responseBody: { statusCode: 404, error: 'Not Found', - message: - '[index_not_found_exception] no such index [ft_farequote_not_exists], with { resource.type="index_or_alias" & resource.id="ft_farequote_not_exists" & index_uuid="_na_" & index="ft_farequote_not_exists" }', + message: 'index_not_found_exception', }, }, }; diff --git a/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_stats.ts b/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_stats.ts index 5795eac9637b1..4ec8ae0429a6b 100644 --- a/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_stats.ts +++ b/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_stats.ts @@ -152,8 +152,7 @@ export default ({ getService }: FtrProviderContext) => { responseBody: { statusCode: 404, error: 'Not Found', - message: - '[index_not_found_exception] no such index [ft_farequote_not_exists], with { resource.type="index_or_alias" & resource.id="ft_farequote_not_exists" & index_uuid="_na_" & index="ft_farequote_not_exists" }', + message: 'index_not_found_exception', }, }, }; diff --git a/x-pack/test/api_integration/apis/ml/data_visualizer/get_overall_stats.ts b/x-pack/test/api_integration/apis/ml/data_visualizer/get_overall_stats.ts index fa83807be161a..af2c53c577253 100644 --- a/x-pack/test/api_integration/apis/ml/data_visualizer/get_overall_stats.ts +++ b/x-pack/test/api_integration/apis/ml/data_visualizer/get_overall_stats.ts @@ -116,8 +116,7 @@ export default ({ getService }: FtrProviderContext) => { responseBody: { statusCode: 404, error: 'Not Found', - message: - '[index_not_found_exception] no such index [ft_farequote_not_exist], with { resource.type="index_or_alias" & resource.id="ft_farequote_not_exist" & index_uuid="_na_" & index="ft_farequote_not_exist" }', + message: 'index_not_found_exception', }, }, }, diff --git a/x-pack/test/api_integration/apis/ml/fields_service/field_cardinality.ts b/x-pack/test/api_integration/apis/ml/fields_service/field_cardinality.ts index 627d9454beeb6..7dbb4a9d03e4f 100644 --- a/x-pack/test/api_integration/apis/ml/fields_service/field_cardinality.ts +++ b/x-pack/test/api_integration/apis/ml/fields_service/field_cardinality.ts @@ -78,8 +78,7 @@ export default ({ getService }: FtrProviderContext) => { responseBody: { statusCode: 404, error: 'Not Found', - message: - '[index_not_found_exception] no such index [ft_ecommerce_not_exist], with { resource.type="index_or_alias" & resource.id="ft_ecommerce_not_exist" & index_uuid="_na_" & index="ft_ecommerce_not_exist" }', + message: 'index_not_found_exception', }, }, }, diff --git a/x-pack/test/api_integration/apis/ml/fields_service/time_field_range.ts b/x-pack/test/api_integration/apis/ml/fields_service/time_field_range.ts index b1c086ddbb456..6588d4df570b7 100644 --- a/x-pack/test/api_integration/apis/ml/fields_service/time_field_range.ts +++ b/x-pack/test/api_integration/apis/ml/fields_service/time_field_range.ts @@ -81,8 +81,7 @@ export default ({ getService }: FtrProviderContext) => { responseBody: { statusCode: 404, error: 'Not Found', - message: - '[index_not_found_exception] no such index [ft_ecommerce_not_exist], with { resource.type="index_or_alias" & resource.id="ft_ecommerce_not_exist" & index_uuid="_na_" & index="ft_ecommerce_not_exist" }', + message: 'index_not_found_exception', }, }, }, diff --git a/x-pack/test/api_integration/apis/ml/filters/create_filters.ts b/x-pack/test/api_integration/apis/ml/filters/create_filters.ts index dfec7798ffc0c..598fab2cb7fa6 100644 --- a/x-pack/test/api_integration/apis/ml/filters/create_filters.ts +++ b/x-pack/test/api_integration/apis/ml/filters/create_filters.ts @@ -73,7 +73,7 @@ export default ({ getService }: FtrProviderContext) => { responseCode: 400, responseBody: { error: 'Bad Request', - message: 'Invalid filter_id', + message: 'status_exception', }, }, }, diff --git a/x-pack/test/api_integration/apis/ml/filters/get_filters.ts b/x-pack/test/api_integration/apis/ml/filters/get_filters.ts index 5d7900ea5e9d9..4a3589634ded9 100644 --- a/x-pack/test/api_integration/apis/ml/filters/get_filters.ts +++ b/x-pack/test/api_integration/apis/ml/filters/get_filters.ts @@ -91,7 +91,7 @@ export default ({ getService }: FtrProviderContext) => { .set(COMMON_REQUEST_HEADERS) .expect(400); expect(body.error).to.eql('Bad Request'); - expect(body.message).to.contain('Unable to find filter'); + expect(body.message).to.contain('resource_not_found_exception'); }); }); }; diff --git a/x-pack/test/api_integration/apis/ml/filters/update_filters.ts b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts index fbbb94d54c035..6f421ad120b51 100644 --- a/x-pack/test/api_integration/apis/ml/filters/update_filters.ts +++ b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts @@ -111,7 +111,7 @@ export default ({ getService }: FtrProviderContext) => { .send(updateFilterRequestBody) .expect(400); - expect(body.message).to.contain('No filter with id'); + expect(body.message).to.contain('resource_not_found_exception'); }); }); }; diff --git a/x-pack/test/api_integration/apis/ml/jobs/close_jobs.ts b/x-pack/test/api_integration/apis/ml/jobs/close_jobs.ts index 5b9c5393e81d9..0b7f9cf927d26 100644 --- a/x-pack/test/api_integration/apis/ml/jobs/close_jobs.ts +++ b/x-pack/test/api_integration/apis/ml/jobs/close_jobs.ts @@ -47,8 +47,8 @@ export default ({ getService }: FtrProviderContext) => { responseCode: 200, responseBody: { - [SINGLE_METRIC_JOB_CONFIG.job_id]: { closed: false, error: { statusCode: 409 } }, - [MULTI_METRIC_JOB_CONFIG.job_id]: { closed: false, error: { statusCode: 409 } }, + [SINGLE_METRIC_JOB_CONFIG.job_id]: { closed: false, error: { status: 409 } }, + [MULTI_METRIC_JOB_CONFIG.job_id]: { closed: false, error: { status: 409 } }, }, }, }, @@ -162,9 +162,7 @@ export default ({ getService }: FtrProviderContext) => { expectedRspJobIds.forEach((id) => { expect(body[id].closed).to.eql(testData.expected.responseBody[id].closed); - expect(body[id].error.statusCode).to.eql( - testData.expected.responseBody[id].error.statusCode - ); + expect(body[id].error.status).to.eql(testData.expected.responseBody[id].error.status); }); // ensure jobs are still open From 70cea4871846e5487cd977a13ef6b488e57ecdfa Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 2 Sep 2020 09:11:05 -0400 Subject: [PATCH 26/53] [ML] DF Analytics jobs list: persist pagination through refresh interval (#75996) * wip: switch analyticsList inMemoryTable to basic and implement search bar * move basicTable settings to custom hook and update types * update types * add types for empty prompt * ensure sorting works * add refresh to analytics management list * ensure table still updates editing job --- .../analytics_list/analytics_list.tsx | 245 ++++++++---------- .../components/analytics_list/common.ts | 1 + .../analytics_list/empty_prompt.tsx | 51 ++++ .../components/analytics_list/use_columns.tsx | 2 - .../analytics_list/use_table_settings.ts | 119 +++++++++ .../analytics_search_bar.tsx | 157 +++++++++++ .../components/analytics_search_bar/index.ts | 7 + .../components/jobs_list/jobs_list.js | 2 +- 8 files changed, 446 insertions(+), 138 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/empty_prompt.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_search_bar/analytics_search_bar.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_search_bar/index.ts diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index c4c7a8a4ca11a..88287b963a028 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -5,17 +5,13 @@ */ import React, { FC, useCallback, useState, useEffect } from 'react'; - import { i18n } from '@kbn/i18n'; - import { - Direction, - EuiButton, EuiCallOut, - EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, - EuiInMemoryTable, + EuiBasicTable, + EuiSearchBar, EuiSearchBarProps, EuiSpacer, } from '@elastic/eui'; @@ -43,6 +39,39 @@ import { getGroupQueryText, } from '../../../../../jobs/jobs_list/components/utils'; import { SourceSelection } from '../source_selection'; +import { filterAnalytics, AnalyticsSearchBar } from '../analytics_search_bar'; +import { AnalyticsEmptyPrompt } from './empty_prompt'; +import { useTableSettings } from './use_table_settings'; +import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button'; + +const filters: EuiSearchBarProps['filters'] = [ + { + type: 'field_value_selection', + field: 'job_type', + name: i18n.translate('xpack.ml.dataframe.analyticsList.typeFilter', { + defaultMessage: 'Type', + }), + multiSelect: 'or', + options: Object.values(ANALYSIS_CONFIG_TYPE).map((val) => ({ + value: val, + name: val, + view: getJobTypeBadge(val), + })), + }, + { + type: 'field_value_selection', + field: 'state', + name: i18n.translate('xpack.ml.dataframe.analyticsList.statusFilter', { + defaultMessage: 'Status', + }), + multiSelect: 'or', + options: Object.values(DATA_FRAME_TASK_STATE).map((val) => ({ + value: val, + name: val, + view: getTaskStateBadge(val), + })), + }, +]; function getItemIdToExpandedRowMap( itemIds: DataFrameAnalyticsId[], @@ -70,23 +99,23 @@ export const DataFrameAnalyticsList: FC = ({ const [isInitialized, setIsInitialized] = useState(false); const [isSourceIndexModalVisible, setIsSourceIndexModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(false); - + const [filteredAnalytics, setFilteredAnalytics] = useState<{ + active: boolean; + items: DataFrameAnalyticsListRow[]; + }>({ + active: false, + items: [], + }); const [searchQueryText, setSearchQueryText] = useState(''); - const [analytics, setAnalytics] = useState([]); const [analyticsStats, setAnalyticsStats] = useState( undefined ); const [expandedRowItemIds, setExpandedRowItemIds] = useState([]); - const [errorMessage, setErrorMessage] = useState(undefined); - const [searchError, setSearchError] = useState(undefined); - - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - - const [sortField, setSortField] = useState(DataFrameAnalyticsListColumn.id); - const [sortDirection, setSortDirection] = useState('asc'); + // Query text/job_id based on url but only after getAnalytics is done first + // selectedJobIdFromUrlInitialized makes sure the query is only run once since analytics is being refreshed constantly + const [selectedIdFromUrlInitialized, setSelectedIdFromUrlInitialized] = useState(false); const disabled = !checkPermission('canCreateDataFrameAnalytics') || @@ -100,9 +129,29 @@ export const DataFrameAnalyticsList: FC = ({ blockRefresh ); - // Query text/job_id based on url but only after getAnalytics is done first - // selectedJobIdFromUrlInitialized makes sure the query is only run once since analytics is being refreshed constantly - const [selectedIdFromUrlInitialized, setSelectedIdFromUrlInitialized] = useState(false); + const setQueryClauses = (queryClauses: any) => { + if (queryClauses.length) { + const filtered = filterAnalytics(analytics, queryClauses); + setFilteredAnalytics({ active: true, items: filtered }); + } else { + setFilteredAnalytics({ active: false, items: [] }); + } + }; + + const filterList = () => { + if (searchQueryText !== '' && selectedIdFromUrlInitialized === true) { + // trigger table filtering with query for job id to trigger table filter + const query = EuiSearchBar.Query.parse(searchQueryText); + let clauses: any = []; + if (query && query.ast !== undefined && query.ast.clauses !== undefined) { + clauses = query.ast.clauses; + } + setQueryClauses(clauses); + } else { + setQueryClauses([]); + } + }; + useEffect(() => { if (selectedIdFromUrlInitialized === false && analytics.length > 0) { const { jobId, groupIds } = getSelectedIdFromUrl(window.location.href); @@ -116,9 +165,15 @@ export const DataFrameAnalyticsList: FC = ({ setSelectedIdFromUrlInitialized(true); setSearchQueryText(queryText); + } else { + filterList(); } }, [selectedIdFromUrlInitialized, analytics]); + useEffect(() => { + filterList(); + }, [selectedIdFromUrlInitialized, searchQueryText]); + const getAnalyticsCallback = useCallback(() => getAnalytics(true), []); // Subscribe to the refresh observable to trigger reloading the analytics list. @@ -137,6 +192,10 @@ export const DataFrameAnalyticsList: FC = ({ isMlEnabledInSpace ); + const { onTableChange, pageOfItems, pagination, sorting } = useTableSettings( + filteredAnalytics.active ? filteredAnalytics.items : analytics + ); + // Before the analytics have been loaded for the first time, display the loading indicator only. // Otherwise a user would see 'No data frame analytics found' during the initial loading. if (!isInitialized) { @@ -160,34 +219,10 @@ export const DataFrameAnalyticsList: FC = ({ if (analytics.length === 0) { return ( <> - - {i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptTitle', { - defaultMessage: 'Create your first data frame analytics job', - })} - - } - actions={ - !isManagementTable - ? [ - setIsSourceIndexModalVisible(true)} - isDisabled={disabled} - color="primary" - iconType="plusInCircle" - fill - data-test-subj="mlAnalyticsCreateFirstButton" - > - {i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', { - defaultMessage: 'Create job', - })} - , - ] - : [] - } - data-test-subj="mlNoDataFrameAnalyticsFound" + setIsSourceIndexModalVisible(true)} /> {isSourceIndexModalVisible === true && ( setIsSourceIndexModalVisible(false)} /> @@ -196,95 +231,32 @@ export const DataFrameAnalyticsList: FC = ({ ); } - const sorting = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(expandedRowItemIds, analytics); - const pagination = { - initialPageIndex: pageIndex, - initialPageSize: pageSize, - totalItemCount: analytics.length, - pageSizeOptions: [10, 20, 50], - hidePerPageOptions: false, - }; - - const handleSearchOnChange: EuiSearchBarProps['onChange'] = (search) => { - if (search.error !== null) { - setSearchError(search.error.message); - return false; - } - - setSearchError(undefined); - setSearchQueryText(search.queryText); - return true; - }; - - const search: EuiSearchBarProps = { - query: searchQueryText, - onChange: handleSearchOnChange, - box: { - incremental: true, - }, - filters: [ - { - type: 'field_value_selection', - field: 'job_type', - name: i18n.translate('xpack.ml.dataframe.analyticsList.typeFilter', { - defaultMessage: 'Type', - }), - multiSelect: 'or', - options: Object.values(ANALYSIS_CONFIG_TYPE).map((val) => ({ - value: val, - name: val, - view: getJobTypeBadge(val), - })), - }, - { - type: 'field_value_selection', - field: 'state', - name: i18n.translate('xpack.ml.dataframe.analyticsList.statusFilter', { - defaultMessage: 'Status', - }), - multiSelect: 'or', - options: Object.values(DATA_FRAME_TASK_STATE).map((val) => ({ - value: val, - name: val, - view: getTaskStateBadge(val), - })), - }, - ], - }; - - const onTableChange: EuiInMemoryTable['onTableChange'] = ({ - page = { index: 0, size: 10 }, - sort = { field: DataFrameAnalyticsListColumn.id, direction: 'asc' }, - }) => { - const { index, size } = page; - setPageIndex(index); - setPageSize(size); + const stats = analyticsStats && ( + + + + ); - const { field, direction } = sort; - setSortField(field); - setSortDirection(direction); - }; + const managementStats = ( + + + {stats} + + + + + + ); return ( <> {modals} - + {!isManagementTable && } - - {analyticsStats && ( - - - - )} - + {!isManagementTable && stats} + {isManagementTable && managementStats} {!isManagementTable && ( @@ -300,22 +272,25 @@ export const DataFrameAnalyticsList: FC = ({
- + + className="mlAnalyticsTable" columns={columns} - error={searchError} hasActions={false} isExpandable={true} isSelectable={false} - items={analytics} + items={pageOfItems} itemId={DataFrameAnalyticsListColumn.id} itemIdToExpandedRowMap={itemIdToExpandedRowMap} loading={isLoading} - onTableChange={onTableChange} - pagination={pagination} + onChange={onTableChange} + pagination={pagination!} sorting={sorting} - search={search} data-test-subj={isLoading ? 'mlAnalyticsTable loading' : 'mlAnalyticsTable loaded'} rowProps={(item) => ({ 'data-test-subj': `mlAnalyticsTableRow row-${item.id}`, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts index 774864ae964a8..994357412510d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts @@ -26,6 +26,7 @@ export type Clause = Parameters[0]; type ExtractClauseType = T extends (x: any) => x is infer Type ? Type : never; export type TermClause = ExtractClauseType; export type FieldClause = ExtractClauseType; +export type Value = Parameters[0]; interface ProgressSection { phase: string; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/empty_prompt.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/empty_prompt.tsx new file mode 100644 index 0000000000000..fb173697b4572 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/empty_prompt.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface Props { + disabled: boolean; + isManagementTable: boolean; + onCreateFirstJobClick: () => void; +} + +export const AnalyticsEmptyPrompt: FC = ({ + disabled, + isManagementTable, + onCreateFirstJobClick, +}) => ( + + {i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptTitle', { + defaultMessage: 'Create your first data frame analytics job', + })} + + } + actions={ + !isManagementTable + ? [ + + {i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', { + defaultMessage: 'Create job', + })} + , + ] + : [] + } + data-test-subj="mlNoDataFrameAnalyticsFound" + /> +); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 7001681b6917a..ef1d373a55a12 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -23,7 +23,6 @@ import { getJobIdUrl, TAB_IDS } from '../../../../../util/get_selected_ids_url'; import { getAnalysisType, DataFrameAnalyticsId } from '../../../../common'; import { - getDataFrameAnalyticsProgress, getDataFrameAnalyticsProgressPhase, isDataFrameAnalyticsFailed, isDataFrameAnalyticsRunning, @@ -76,7 +75,6 @@ export const progressColumn = { name: i18n.translate('xpack.ml.dataframe.analyticsList.progress', { defaultMessage: 'Progress', }), - sortable: (item: DataFrameAnalyticsListRow) => getDataFrameAnalyticsProgress(item.stats), truncateText: true, render(item: DataFrameAnalyticsListRow) { const { currentPhase, progress, totalPhases } = getDataFrameAnalyticsProgressPhase(item.stats); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts new file mode 100644 index 0000000000000..57eb9f6857053 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; +import { Direction, EuiBasicTableProps, EuiTableSortingType } from '@elastic/eui'; +import sortBy from 'lodash/sortBy'; +import get from 'lodash/get'; +import { DataFrameAnalyticsListColumn, DataFrameAnalyticsListRow } from './common'; + +const PAGE_SIZE = 10; +const PAGE_SIZE_OPTIONS = [10, 25, 50]; + +const jobPropertyMap = { + ID: 'id', + Status: 'state', + Type: 'job_type', +}; + +interface AnalyticsBasicTableSettings { + pageIndex: number; + pageSize: number; + totalItemCount: number; + hidePerPageOptions: boolean; + sortField: string; + sortDirection: Direction; +} + +interface UseTableSettingsReturnValue { + onTableChange: EuiBasicTableProps['onChange']; + pageOfItems: DataFrameAnalyticsListRow[]; + pagination: EuiBasicTableProps['pagination']; + sorting: EuiTableSortingType; +} + +export function useTableSettings(items: DataFrameAnalyticsListRow[]): UseTableSettingsReturnValue { + const [tableSettings, setTableSettings] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + totalItemCount: 0, + hidePerPageOptions: false, + sortField: DataFrameAnalyticsListColumn.id, + sortDirection: 'asc', + }); + + const getPageOfItems = ( + list: any[], + index: number, + size: number, + sortField: string, + sortDirection: Direction + ) => { + list = sortBy(list, (item) => + get(item, jobPropertyMap[sortField as keyof typeof jobPropertyMap] || sortField) + ); + list = sortDirection === 'asc' ? list : list.reverse(); + const listLength = list.length; + + let pageStart = index * size; + if (pageStart >= listLength && listLength !== 0) { + // if the page start is larger than the number of items due to + // filters being applied or items being deleted, calculate a new page start + pageStart = Math.floor((listLength - 1) / size) * size; + + setTableSettings({ ...tableSettings, pageIndex: pageStart / size }); + } + return { + pageOfItems: list.slice(pageStart, pageStart + size), + totalItemCount: listLength, + }; + }; + + const onTableChange = ({ + page = { index: 0, size: PAGE_SIZE }, + sort = { field: DataFrameAnalyticsListColumn.id, direction: 'asc' }, + }: { + page?: { index: number; size: number }; + sort?: { field: string; direction: Direction }; + }) => { + const { index, size } = page; + const { field, direction } = sort; + + setTableSettings({ + ...tableSettings, + pageIndex: index, + pageSize: size, + sortField: field, + sortDirection: direction, + }); + }; + + const { pageIndex, pageSize, sortField, sortDirection } = tableSettings; + + const { pageOfItems, totalItemCount } = getPageOfItems( + items, + pageIndex, + pageSize, + sortField, + sortDirection + ); + + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: PAGE_SIZE_OPTIONS, + }; + + const sorting = { + sort: { + field: sortField, + direction: sortDirection, + }, + }; + + return { onTableChange, pageOfItems, pagination, sorting }; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_search_bar/analytics_search_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_search_bar/analytics_search_bar.tsx new file mode 100644 index 0000000000000..44a6572a3766c --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_search_bar/analytics_search_bar.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Dispatch, SetStateAction, FC, Fragment, useState } from 'react'; +import { + EuiSearchBar, + EuiSearchBarProps, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { stringMatch } from '../../../../../util/string_utils'; +import { + TermClause, + FieldClause, + Value, + DataFrameAnalyticsListRow, +} from '../analytics_list/common'; + +export function filterAnalytics( + items: DataFrameAnalyticsListRow[], + clauses: Array +) { + if (clauses.length === 0) { + return items; + } + + // keep count of the number of matches we make as we're looping over the clauses + // we only want to return items which match all clauses, i.e. each search term is ANDed + const matches: Record = items.reduce((p: Record, c) => { + p[c.id] = { + job: c, + count: 0, + }; + return p; + }, {}); + + clauses.forEach((c) => { + // the search term could be negated with a minus, e.g. -bananas + const bool = c.match === 'must'; + let js = []; + + if (c.type === 'term') { + // filter term based clauses, e.g. bananas + // match on id, description and memory_status + // if the term has been negated, AND the matches + if (bool === true) { + js = items.filter( + (item) => + stringMatch(item.id, c.value) === bool || + stringMatch(item.config.description, c.value) === bool || + stringMatch(item.stats?.memory_usage?.status, c.value) === bool + ); + } else { + js = items.filter( + (item) => + stringMatch(item.id, c.value) === bool && + stringMatch(item.config.description, c.value) === bool && + stringMatch(item.stats?.memory_usage?.status, c.value) === bool + ); + } + } else { + // filter other clauses, i.e. the filters for type and status + if (Array.isArray(c.value)) { + // job type value and status value are an array of string(s) e.g. c.value => ['failed', 'stopped'] + js = items.filter((item) => + (c.value as Value[]).includes( + item[c.field as keyof Pick] + ) + ); + } else { + js = items.filter( + (item) => item[c.field as keyof Pick] === c.value + ); + } + } + + js.forEach((j) => matches[j.id].count++); + }); + + // loop through the matches and return only those items which have match all the clauses + const filtered = Object.values(matches) + .filter((m) => (m && m.count) >= clauses.length) + .map((m) => m.job); + + return filtered; +} + +function getError(errorMessage: string | null) { + if (errorMessage) { + return i18n.translate('xpack.ml.analyticList.searchBar.invalidSearchErrorMessage', { + defaultMessage: 'Invalid search: {errorMessage}', + values: { errorMessage }, + }); + } + + return ''; +} + +interface Props { + filters: EuiSearchBarProps['filters']; + searchQueryText: string; + setSearchQueryText: Dispatch>; +} + +export const AnalyticsSearchBar: FC = ({ filters, searchQueryText, setSearchQueryText }) => { + const [errorMessage, setErrorMessage] = useState(null); + + const onChange: EuiSearchBarProps['onChange'] = ({ query, error }) => { + if (error) { + setErrorMessage(error.message); + } else if (query !== null && query.text !== undefined) { + setSearchQueryText(query.text); + setErrorMessage(null); + } + }; + + return ( + + + {searchQueryText === undefined && ( + + )} + {searchQueryText !== undefined && ( + + )} + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_search_bar/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_search_bar/index.ts new file mode 100644 index 0000000000000..3b901f5063eb1 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_search_bar/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AnalyticsSearchBar, filterAnalytics } from './analytics_search_bar'; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index f90bbf3cf3fe6..fa4ea09b89ff9 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -7,7 +7,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { sortBy } from 'lodash'; +import sortBy from 'lodash/sortBy'; import moment from 'moment'; import { toLocaleString } from '../../../../util/string_utils'; From 093f58872054c8d0b8601ad94865ba3bf50a4886 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 2 Sep 2020 15:35:33 +0200 Subject: [PATCH 27/53] [RUM Dashboard] Use median values instead of averages to display page load times (#76407) --- .../elasticsearch_fieldnames.test.ts.snap | 12 ++++++++ .../apm/common/elasticsearch_fieldnames.ts | 5 ++++ .../RumDashboard/Charts/PageLoadDistChart.tsx | 2 +- .../PercentileAnnotations.tsx | 4 +-- .../__snapshots__/queries.test.ts.snap | 18 +++++++++--- .../lib/rum_client/get_client_metrics.ts | 28 +++++++++++++------ .../server/lib/transaction_groups/fetcher.ts | 4 +-- .../apm/typings/elasticsearch/aggregations.ts | 2 +- 8 files changed, 57 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 7c42fb6f12a54..aecf4af667603 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -120,6 +120,8 @@ exports[`Error TRACE_ID 1`] = `"trace id"`; exports[`Error TRANSACTION_BREAKDOWN_COUNT 1`] = `undefined`; +exports[`Error TRANSACTION_DOM_INTERACTIVE 1`] = `undefined`; + exports[`Error TRANSACTION_DURATION 1`] = `undefined`; exports[`Error TRANSACTION_ID 1`] = `"transaction id"`; @@ -132,6 +134,8 @@ exports[`Error TRANSACTION_RESULT 1`] = `undefined`; exports[`Error TRANSACTION_SAMPLED 1`] = `undefined`; +exports[`Error TRANSACTION_TIME_TO_FIRST_BYTE 1`] = `undefined`; + exports[`Error TRANSACTION_TYPE 1`] = `"request"`; exports[`Error TRANSACTION_URL 1`] = `undefined`; @@ -268,6 +272,8 @@ exports[`Span TRACE_ID 1`] = `"trace id"`; exports[`Span TRANSACTION_BREAKDOWN_COUNT 1`] = `undefined`; +exports[`Span TRANSACTION_DOM_INTERACTIVE 1`] = `undefined`; + exports[`Span TRANSACTION_DURATION 1`] = `undefined`; exports[`Span TRANSACTION_ID 1`] = `"transaction id"`; @@ -280,6 +286,8 @@ exports[`Span TRANSACTION_RESULT 1`] = `undefined`; exports[`Span TRANSACTION_SAMPLED 1`] = `undefined`; +exports[`Span TRANSACTION_TIME_TO_FIRST_BYTE 1`] = `undefined`; + exports[`Span TRANSACTION_TYPE 1`] = `undefined`; exports[`Span TRANSACTION_URL 1`] = `undefined`; @@ -416,6 +424,8 @@ exports[`Transaction TRACE_ID 1`] = `"trace id"`; exports[`Transaction TRANSACTION_BREAKDOWN_COUNT 1`] = `undefined`; +exports[`Transaction TRANSACTION_DOM_INTERACTIVE 1`] = `undefined`; + exports[`Transaction TRANSACTION_DURATION 1`] = `1337`; exports[`Transaction TRANSACTION_ID 1`] = `"transaction id"`; @@ -428,6 +438,8 @@ exports[`Transaction TRANSACTION_RESULT 1`] = `"transaction result"`; exports[`Transaction TRANSACTION_SAMPLED 1`] = `true`; +exports[`Transaction TRANSACTION_TIME_TO_FIRST_BYTE 1`] = `undefined`; + exports[`Transaction TRANSACTION_TYPE 1`] = `"transaction type"`; exports[`Transaction TRANSACTION_URL 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 610a32e8e9b99..4aa68de9b8b32 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -99,3 +99,8 @@ export const TRANSACTION_URL = 'transaction.page.url'; export const CLIENT_GEO = 'client.geo'; export const USER_AGENT_DEVICE = 'user_agent.device.name'; export const USER_AGENT_OS = 'user_agent.os.name'; + +export const TRANSACTION_TIME_TO_FIRST_BYTE = + 'transaction.marks.agent.timeToFirstByte'; +export const TRANSACTION_DOM_INTERACTIVE = + 'transaction.marks.agent.domInteractive'; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx index b2b5e66d06ac6..33573052dbcbb 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -35,7 +35,7 @@ import { BreakdownSeries } from '../PageLoadDistribution/BreakdownSeries'; interface PageLoadData { pageLoadDistribution: Array<{ x: number; y: number }>; - percentiles: Record | undefined; + percentiles: Record | undefined; minDuration: number; maxDuration: number; } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx index 407ec42f03ff5..7e81dc011bdb5 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx @@ -16,11 +16,11 @@ import styled from 'styled-components'; import { EuiToolTip } from '@elastic/eui'; interface Props { - percentiles?: Record; + percentiles?: Record; } function generateAnnotationData( - values?: Record + values?: Record ): LineAnnotationDatum[] { return Object.entries(values ?? {}).map((value) => ({ dataValue: value[1], diff --git a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap index c5264373ea495..22b8c226e9026 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap @@ -10,15 +10,25 @@ Object { "body": Object { "aggs": Object { "backEnd": Object { - "avg": Object { + "percentiles": Object { "field": "transaction.marks.agent.timeToFirstByte", - "missing": 0, + "hdr": Object { + "number_of_significant_value_digits": 3, + }, + "percents": Array [ + 50, + ], }, }, "domInteractive": Object { - "avg": Object { + "percentiles": Object { "field": "transaction.marks.agent.domInteractive", - "missing": 0, + "hdr": Object { + "number_of_significant_value_digits": 3, + }, + "percents": Array [ + 50, + ], }, }, "pageViews": Object { diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_client_metrics.ts b/x-pack/plugins/apm/server/lib/rum_client/get_client_metrics.ts index 194c136e2b3d0..e0dec183f06d6 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_client_metrics.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_client_metrics.ts @@ -11,6 +11,10 @@ import { SetupTimeRange, SetupUIFilters, } from '../helpers/setup_request'; +import { + TRANSACTION_DOM_INTERACTIVE, + TRANSACTION_TIME_TO_FIRST_BYTE, +} from '../../../common/elasticsearch_fieldnames'; export async function getClientMetrics({ setup, @@ -30,15 +34,21 @@ export async function getClientMetrics({ aggs: { pageViews: { value_count: { field: 'transaction.type' } }, backEnd: { - avg: { - field: 'transaction.marks.agent.timeToFirstByte', - missing: 0, + percentiles: { + field: TRANSACTION_TIME_TO_FIRST_BYTE, + percents: [50], + hdr: { + number_of_significant_value_digits: 3, + }, }, }, domInteractive: { - avg: { - field: 'transaction.marks.agent.domInteractive', - missing: 0, + percentiles: { + field: TRANSACTION_DOM_INTERACTIVE, + percents: [50], + hdr: { + number_of_significant_value_digits: 3, + }, }, }, }, @@ -53,9 +63,11 @@ export async function getClientMetrics({ // Divide by 1000 to convert ms into seconds return { pageViews, - backEnd: { value: (backEnd.value || 0) / 1000 }, + backEnd: { value: (backEnd.values['50.0'] || 0) / 1000 }, frontEnd: { - value: ((domInteractive.value || 0) - (backEnd.value || 0)) / 1000, + value: + ((domInteractive.values['50.0'] || 0) - (backEnd.values['50.0'] || 0)) / + 1000, }, }; } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index 2a1a581c79574..d0ba31f42c536 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -65,7 +65,7 @@ function getItemsWithRelativeImpact( key: string | Record; avg?: number | null; count?: number | null; - p95?: number; + p95?: number | null; sample?: Transaction; }> ) { @@ -188,7 +188,7 @@ export interface TransactionGroup { key: Record | string; averageResponseTime: number | null | undefined; transactionsPerMinute: number; - p95: number | undefined; + p95: number | null | undefined; impact: number; sample: Transaction; } diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index d25ec8709e3be..6a2a0e81e81cd 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -223,7 +223,7 @@ interface AggregationResponsePart< value: number; }; percentiles: { - values: Record; + values: Record; }; extended_stats: { count: number; From 5345af9281406368d8c4520742fbdd3ae08a61fd Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Wed, 2 Sep 2020 09:48:33 -0400 Subject: [PATCH 28/53] [Security Solution][Resolver] Add events link to Process Detail Panel (#76195) * [Security_Solution][Resolver]Add events link to Process Detail Panel --- .../resolver/store/data/reducer.test.ts | 19 ++++++++++++++++++ .../public/resolver/store/data/selectors.ts | 20 +++++++++++++++++++ .../public/resolver/store/selectors.ts | 12 +++++++++++ .../public/resolver/view/panels/index.tsx | 12 +++++++---- .../resolver/view/panels/process_details.tsx | 18 +++++++++++++++++ 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts index e087db9f74685..21c4f92f8e502 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts @@ -59,6 +59,7 @@ describe('Resolver Data Middleware', () => { let firstChildNodeInTree: TreeNode; let eventStatsForFirstChildNode: { total: number; byCategory: Record }; let categoryToOverCount: string; + let aggregateCategoryTotalForFirstChildNode: number; let tree: ResolverTree; /** @@ -73,6 +74,7 @@ describe('Resolver Data Middleware', () => { firstChildNodeInTree, eventStatsForFirstChildNode, categoryToOverCount, + aggregateCategoryTotalForFirstChildNode, } = mockedTree()); if (tree) { dispatchTree(tree); @@ -138,6 +140,13 @@ describe('Resolver Data Middleware', () => { expect(notDisplayed(typeCounted)).toBe(0); } }); + it('should return an overall correct count for the number of related events', () => { + const aggregateTotalByEntityId = selectors.relatedEventAggregateTotalByEntityId( + store.getState() + ); + const countForId = aggregateTotalByEntityId(firstChildNodeInTree.id); + expect(countForId).toBe(aggregateCategoryTotalForFirstChildNode); + }); }); describe('when data was received and stats show more related events than the API can provide', () => { beforeEach(() => { @@ -262,6 +271,7 @@ function mockedTree() { tree: tree!, firstChildNodeInTree, eventStatsForFirstChildNode: statsResults.eventStats, + aggregateCategoryTotalForFirstChildNode: statsResults.aggregateCategoryTotal, categoryToOverCount: statsResults.firstCategory, }; } @@ -288,6 +298,7 @@ function compileStatsForChild( }; /** The category of the first event. */ firstCategory: string; + aggregateCategoryTotal: number; } { const totalRelatedEvents = node.relatedEvents.length; // For the purposes of testing, we pick one category to fake an extra event for @@ -295,6 +306,12 @@ function compileStatsForChild( let firstCategory: string | undefined; + // This is the "aggregate total" which is displayed to users as the total count + // of related events for the node. It is tallied by incrementing for every discrete + // event.category in an event.category array (or just 1 for a plain string). E.g. two events + // categories 'file' and ['dns','network'] would have an `aggregate total` of 3. + let aggregateCategoryTotal: number = 0; + const compiledStats = node.relatedEvents.reduce( (counts: Record, relatedEvent) => { // `relatedEvent.event.category` is `string | string[]`. @@ -310,6 +327,7 @@ function compileStatsForChild( // Increment the count of events with this category counts[category] = counts[category] ? counts[category] + 1 : 1; + aggregateCategoryTotal++; } return counts; }, @@ -327,5 +345,6 @@ function compileStatsForChild( byCategory: compiledStats, }, firstCategory, + aggregateCategoryTotal, }; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 965547f1e309a..eaa80b46471fa 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -170,6 +170,26 @@ export const relatedEventsStats: ( } ); +/** + * This returns the "aggregate total" for related events, tallied as the sum + * of their individual `event.category`s. E.g. a [DNS, Network] would count as two + * towards the aggregate total. + */ +export const relatedEventAggregateTotalByEntityId: ( + state: DataState +) => (entityId: string) => number = createSelector(relatedEventsStats, (relatedStats) => { + return (entityId) => { + const statsForEntity = relatedStats(entityId); + if (statsForEntity === undefined) { + return 0; + } + return Object.values(statsForEntity?.events?.byCategory || {}).reduce( + (sum, val) => sum + val, + 0 + ); + }; +}); + /** * returns a map of entity_ids to related event data. */ diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 909a907626f30..bdea08df3d7f5 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -114,6 +114,18 @@ export const relatedEventsStats: ( dataSelectors.relatedEventsStats ); +/** + * This returns the "aggregate total" for related events, tallied as the sum + * of their individual `event.category`s. E.g. a [DNS, Network] would count as two + * towards the aggregate total. + */ +export const relatedEventAggregateTotalByEntityId: ( + state: ResolverState +) => (nodeID: string) => number = composeSelectors( + dataStateSelector, + dataSelectors.relatedEventAggregateTotalByEntityId +); + /** * Map of related events... by entity id */ diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx index 98b737de8fa59..133dcd21e7f56 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx @@ -17,6 +17,7 @@ import { EventCountsForProcess } from './event_counts_for_process'; import { ProcessDetails } from './process_details'; import { ProcessListWithCounts } from './process_list_with_counts'; import { RelatedEventDetail } from './related_event_detail'; +import { ResolverState } from '../../types'; /** * The team decided to use this table to determine which breadcrumbs/view to display: @@ -102,6 +103,12 @@ const PanelContent = memo(function PanelContent() { ? relatedEventStats(idFromParams) : undefined; + const parentCount = useSelector((state: ResolverState) => { + if (idFromParams === '') { + return 0; + } + return selectors.relatedEventAggregateTotalByEntityId(state)(idFromParams); + }); /** * Determine which set of breadcrumbs to display based on the query parameters * for the table & breadcrumb nav. @@ -186,9 +193,6 @@ const PanelContent = memo(function PanelContent() { } if (panelToShow === 'relatedEventDetail') { - const parentCount: number = Object.values( - relatedStatsForIdFromParams?.events.byCategory || {} - ).reduce((sum, val) => sum + val, 0); return ( ; - }, [uiSelectedEvent, crumbEvent, crumbId, relatedStatsForIdFromParams, panelToShow]); + }, [uiSelectedEvent, crumbEvent, crumbId, relatedStatsForIdFromParams, panelToShow, parentCount]); return <>{panelInstance}; }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx index 01fa912caa866..4fcc557742643 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx @@ -13,6 +13,7 @@ import { EuiText, EuiTextColor, EuiDescriptionList, + EuiLink, } from '@elastic/eui'; import styled from 'styled-components'; import { FormattedMessage } from 'react-intl'; @@ -58,6 +59,9 @@ export const ProcessDetails = memo(function ProcessDetails({ const isProcessTerminated = useSelector((state: ResolverState) => selectors.isProcessTerminated(state)(entityId) ); + const relatedEventTotal = useSelector((state: ResolverState) => { + return selectors.relatedEventAggregateTotalByEntityId(state)(entityId); + }); const processInfoEntry: EuiDescriptionListProps['listItems'] = useMemo(() => { const eventTime = event.eventTimestamp(processEvent); const dateTime = eventTime === undefined ? null : formatDate(eventTime); @@ -164,6 +168,12 @@ export const ProcessDetails = memo(function ProcessDetails({ return cubeAssetsForNode(isProcessTerminated, false); }, [processEvent, cubeAssetsForNode, isProcessTerminated]); + const handleEventsLinkClick = useMemo(() => { + return () => { + pushToQueryParams({ crumbId: entityId, crumbEvent: 'all' }); + }; + }, [entityId, pushToQueryParams]); + const titleID = useMemo(() => htmlIdGenerator('resolverTable')(), []); return ( <> @@ -185,6 +195,14 @@ export const ProcessDetails = memo(function ProcessDetails({ {descriptionText} + + + + Date: Wed, 2 Sep 2020 16:00:38 +0200 Subject: [PATCH 29/53] [uiActions] notify action usage (#76294) Notify feature usage when dynamic actions with specified license requirements are executed Co-authored-by: Elastic Machine --- .../dashboard_to_url_drilldown/index.tsx | 1 + .../services/feature_usage_service.mock.ts | 4 + .../action_wizard/action_wizard.test.tsx | 8 +- .../action_wizard/action_wizard.tsx | 10 +-- .../components/action_wizard/test_data.tsx | 16 ++-- .../connected_flyout_manage_drilldowns.tsx | 2 +- .../form_drilldown_wizard.tsx | 2 +- .../public/drilldowns/drilldown_definition.ts | 9 +- .../dynamic_actions/action_factory.test.ts | 87 ++++++++++++++++--- .../public/dynamic_actions/action_factory.ts | 45 ++++++++-- .../action_factory_definition.ts | 11 ++- .../dynamic_action_manager.test.ts | 6 +- .../ui_actions_enhanced/public/mocks.ts | 4 +- .../ui_actions_enhanced/public/plugin.ts | 36 +++++--- .../ui_actions_service_enhancements.test.ts | 43 +++++++-- .../ui_actions_service_enhancements.ts | 31 +++++-- 16 files changed, 247 insertions(+), 68 deletions(-) diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx index 7d915ea23c66f..58916f26121d4 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx @@ -44,6 +44,7 @@ export class DashboardToUrlDrilldown implements Drilldown { public readonly order = 8; readonly minimalLicense = 'gold'; // example of minimal license support + readonly licenseFeatureName = 'Sample URL Drilldown'; public readonly getDisplayName = () => 'Go to URL (example)'; diff --git a/x-pack/plugins/licensing/public/services/feature_usage_service.mock.ts b/x-pack/plugins/licensing/public/services/feature_usage_service.mock.ts index fc9d4f9381151..b2390ea35c140 100644 --- a/x-pack/plugins/licensing/public/services/feature_usage_service.mock.ts +++ b/x-pack/plugins/licensing/public/services/feature_usage_service.mock.ts @@ -15,6 +15,8 @@ const createSetupMock = (): jest.Mocked => { register: jest.fn(), }; + mock.register.mockImplementation(() => Promise.resolve()); + return mock; }; @@ -23,6 +25,8 @@ const createStartMock = (): jest.Mocked => { notifyUsage: jest.fn(), }; + mock.notifyUsage.mockImplementation(() => Promise.resolve()); + return mock; }; diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.test.tsx index 78252dccd20d2..9cc64defc1795 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.test.tsx @@ -15,7 +15,7 @@ import { urlDrilldownActionFactory, } from './test_data'; import { ActionFactory } from '../../dynamic_actions'; -import { licenseMock } from '../../../../licensing/common/licensing.mock'; +import { licensingMock } from '../../../../licensing/public/mocks'; // TODO: afterEach is not available for it globally during setup // https://github.com/elastic/kibana/issues/59469 @@ -68,8 +68,12 @@ test('If not enough license, button is disabled', () => { { ...urlDrilldownActionFactory, minimalLicense: 'gold', + licenseFeatureName: 'Url Drilldown', }, - () => licenseMock.createLicense() + { + getLicense: () => licensingMock.createLicense(), + getFeatureUsageStart: () => licensingMock.createStart().featureUsage, + } ); const screen = render(); diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx index 7e4fe1de8be8d..a49251811239f 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx @@ -93,7 +93,7 @@ export const ActionWizard: React.FC = ({ if ( !currentActionFactory && actionFactories.length === 1 && - actionFactories[0].isCompatibleLicence() + actionFactories[0].isCompatibleLicense() ) { onActionFactoryChange(actionFactories[0]); } @@ -314,8 +314,8 @@ const ActionFactorySelector: React.FC = ({ * make sure not compatible factories are in the end */ const ensureOrder = (factories: ActionFactory[]) => { - const compatibleLicense = factories.filter((f) => f.isCompatibleLicence()); - const notCompatibleLicense = factories.filter((f) => !f.isCompatibleLicence()); + const compatibleLicense = factories.filter((f) => f.isCompatibleLicense()); + const notCompatibleLicense = factories.filter((f) => !f.isCompatibleLicense()); return [ ...compatibleLicense.sort((f1, f2) => f2.order - f1.order), ...notCompatibleLicense.sort((f1, f2) => f2.order - f1.order), @@ -328,7 +328,7 @@ const ActionFactorySelector: React.FC = ({ = ({ label={actionFactory.getDisplayName(context)} data-test-subj={`${TEST_SUBJ_ACTION_FACTORY_ITEM}-${actionFactory.id}`} onClick={() => onActionFactorySelected(actionFactory)} - disabled={!actionFactory.isCompatibleLicence()} + disabled={!actionFactory.isCompatibleLicense()} > {actionFactory.getIconType(context) && ( diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx index d48cb13b1a470..71286e9a59c06 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx @@ -10,7 +10,7 @@ import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/p import { ActionWizard } from './action_wizard'; import { ActionFactory, ActionFactoryDefinition } from '../../dynamic_actions'; import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public'; -import { licenseMock } from '../../../../licensing/common/licensing.mock'; +import { licensingMock } from '../../../../licensing/public/mocks'; import { APPLY_FILTER_TRIGGER, SELECT_RANGE_TRIGGER, @@ -116,9 +116,10 @@ export const dashboardDrilldownActionFactory: ActionFactoryDefinition< }, }; -export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory, () => - licenseMock.createLicense() -); +export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory, { + getLicense: () => licensingMock.createLicense(), + getFeatureUsageStart: () => licensingMock.createStart().featureUsage, +}); interface UrlDrilldownConfig { url: string; @@ -176,9 +177,10 @@ export const urlDrilldownActionFactory: ActionFactoryDefinition - licenseMock.createLicense() -); +export const urlFactory = new ActionFactory(urlDrilldownActionFactory, { + getLicense: () => licensingMock.createLicense(), + getFeatureUsageStart: () => licensingMock.createStart().featureUsage, +}); export const mockSupportedTriggers: TriggerId[] = [ VALUE_CLICK_TRIGGER, diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx index 9fca785ec9072..b708bbc57375d 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -148,7 +148,7 @@ export function createFlyoutManageDrilldowns({ icon: actionFactory?.getIconType(drilldownFactoryContext), error: !actionFactory ? invalidDrilldownType(drilldown.action.factoryId) // this shouldn't happen for the end user, but useful during development - : !actionFactory.isCompatibleLicence() + : !actionFactory.isCompatibleLicense() ? insufficientLicenseLevel : undefined, triggers: drilldown.triggers.map((trigger) => getTrigger(trigger as TriggerId)), diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx index bb3eb89d8f199..d7f94a52088b7 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -75,7 +75,7 @@ export const FormDrilldownWizard: React.FC = ({ ); const hasNotCompatibleLicenseFactory = () => - actionFactories?.some((f) => !f.isCompatibleLicence()); + actionFactories?.some((f) => !f.isCompatibleLicense()); const renderGetMoreActionsLink = () => ( diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts index ff455c6ae45b6..8faccc088a327 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts @@ -37,11 +37,18 @@ export interface DrilldownDefinition< id: string; /** - * Minimal licence level + * Minimal license level * Empty means no restrictions */ minimalLicense?: LicenseType; + /** + * Required when `minimalLicense` is used. + * Is a user-facing string. Has to be unique. Doesn't need i18n. + * The feature's name will be displayed to Cloud end-users when they're billed based on their feature usage. + */ + licenseFeatureName?: string; + /** * Determines the display order of the drilldowns in the flyout picker. * Higher numbers are displayed first. diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts index a07fed8486438..032a4a63fe2e9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts @@ -7,6 +7,7 @@ import { ActionFactory } from './action_factory'; import { ActionFactoryDefinition } from './action_factory_definition'; import { licensingMock } from '../../../licensing/public/mocks'; +import { PublicLicense } from '../../../licensing/public'; const def: ActionFactoryDefinition = { id: 'ACTION_FACTORY_1', @@ -22,34 +23,94 @@ const def: ActionFactoryDefinition = { supportedTriggers: () => [], }; +const featureUsage = licensingMock.createStart().featureUsage; + +const createActionFactory = ( + defOverride: Partial = {}, + license?: Partial +) => { + return new ActionFactory( + { ...def, ...defOverride }, + { + getLicense: () => licensingMock.createLicense({ license }), + getFeatureUsageStart: () => featureUsage, + } + ); +}; + describe('License & ActionFactory', () => { test('no license requirements', async () => { - const factory = new ActionFactory(def, () => licensingMock.createLicense()); + const factory = createActionFactory(); expect(await factory.isCompatible({ triggers: [] })).toBe(true); - expect(factory.isCompatibleLicence()).toBe(true); + expect(factory.isCompatibleLicense()).toBe(true); }); test('not enough license level', async () => { - const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () => - licensingMock.createLicense() - ); + const factory = createActionFactory({ minimalLicense: 'gold', licenseFeatureName: 'Feature' }); expect(await factory.isCompatible({ triggers: [] })).toBe(true); - expect(factory.isCompatibleLicence()).toBe(false); + expect(factory.isCompatibleLicense()).toBe(false); }); - test('licence has expired', async () => { - const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () => - licensingMock.createLicense({ license: { type: 'gold', status: 'expired' } }) + test('license has expired', async () => { + const factory = createActionFactory( + { minimalLicense: 'gold', licenseFeatureName: 'Feature' }, + { type: 'gold', status: 'expired' } ); expect(await factory.isCompatible({ triggers: [] })).toBe(true); - expect(factory.isCompatibleLicence()).toBe(false); + expect(factory.isCompatibleLicense()).toBe(false); }); test('enough license level', async () => { - const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () => - licensingMock.createLicense({ license: { type: 'gold' } }) + const factory = createActionFactory( + { minimalLicense: 'gold', licenseFeatureName: 'Feature' }, + { type: 'gold' } ); + expect(await factory.isCompatible({ triggers: [] })).toBe(true); - expect(factory.isCompatibleLicence()).toBe(true); + expect(factory.isCompatibleLicense()).toBe(true); + }); + + describe('licenseFeatureName', () => { + test('licenseFeatureName is required, if minimalLicense is provided', () => { + expect(() => { + createActionFactory(); + }).not.toThrow(); + + expect(() => { + createActionFactory({ minimalLicense: 'gold', licenseFeatureName: 'feature' }); + }).not.toThrow(); + + expect(() => { + createActionFactory({ minimalLicense: 'gold' }); + }).toThrow(); + }); + + test('"licenseFeatureName"', () => { + expect( + createActionFactory({ minimalLicense: 'gold', licenseFeatureName: 'feature' }) + .licenseFeatureName + ).toBe('feature'); + expect(createActionFactory().licenseFeatureName).toBeUndefined(); + }); + }); + + describe('notifyFeatureUsage', () => { + const spy = jest.spyOn(featureUsage, 'notifyUsage'); + beforeEach(() => { + spy.mockClear(); + }); + test('is not called if no license requirements', async () => { + const action = createActionFactory().create({ name: 'fake', config: {} }); + await action.execute({}); + expect(spy).not.toBeCalled(); + }); + test('is called if has license requirements', async () => { + const action = createActionFactory({ + minimalLicense: 'gold', + licenseFeatureName: 'feature', + }).create({ name: 'fake', config: {} }); + await action.execute({}); + expect(spy).toBeCalledWith('feature'); + }); }); }); diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts index 35e06ab036fc9..35a82adf9896d 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts @@ -13,9 +13,14 @@ import { import { ActionFactoryDefinition } from './action_factory_definition'; import { Configurable } from '../../../../../src/plugins/kibana_utils/public'; import { BaseActionFactoryContext, SerializedAction } from './types'; -import { ILicense } from '../../../licensing/public'; +import { ILicense, LicensingPluginStart } from '../../../licensing/public'; import { UiActionsActionDefinition as ActionDefinition } from '../../../../../src/plugins/ui_actions/public'; +export interface ActionFactoryDeps { + readonly getLicense: () => ILicense; + readonly getFeatureUsageStart: () => LicensingPluginStart['featureUsage']; +} + export class ActionFactory< Config extends object = object, SupportedTriggers extends TriggerId = TriggerId, @@ -31,11 +36,18 @@ export class ActionFactory< FactoryContext, ActionContext >, - protected readonly getLicence: () => ILicense - ) {} + protected readonly deps: ActionFactoryDeps + ) { + if (def.minimalLicense && !def.licenseFeatureName) { + throw new Error( + `ActionFactory [actionFactory.id = ${def.id}] "licenseFeatureName" is required, if "minimalLicense" is provided` + ); + } + } public readonly id = this.def.id; public readonly minimalLicense = this.def.minimalLicense; + public readonly licenseFeatureName = this.def.licenseFeatureName; public readonly order = this.def.order || 0; public readonly MenuItem? = this.def.MenuItem; public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; @@ -65,13 +77,13 @@ export class ActionFactory< } /** - * Does this action factory licence requirements + * Does this action factory license requirements * compatible with current license? */ - public isCompatibleLicence() { + public isCompatibleLicense() { if (!this.minimalLicense) return true; - const licence = this.getLicence(); - return licence.isAvailable && licence.isActive && licence.hasAtLeast(this.minimalLicense); + const license = this.deps.getLicense(); + return license.isAvailable && license.isActive && license.hasAtLeast(this.minimalLicense); } public create( @@ -81,14 +93,31 @@ export class ActionFactory< return { ...action, isCompatible: async (context: ActionContext): Promise => { - if (!this.isCompatibleLicence()) return false; + if (!this.isCompatibleLicense()) return false; if (!action.isCompatible) return true; return action.isCompatible(context); }, + execute: async (context: ActionContext): Promise => { + this.notifyFeatureUsage(); + return action.execute(context); + }, }; } public supportedTriggers(): SupportedTriggers[] { return this.def.supportedTriggers(); } + + private notifyFeatureUsage(): void { + if (!this.minimalLicense || !this.licenseFeatureName) return; + this.deps + .getFeatureUsageStart() + .notifyUsage(this.licenseFeatureName) + .catch(() => { + // eslint-disable-next-line no-console + console.warn( + `ActionFactory [actionFactory.id = ${this.def.id}] fail notify feature usage.` + ); + }); + } } diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts index d79614e47ccd4..91b8c8ec1e5ef 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts @@ -34,11 +34,18 @@ export interface ActionFactoryDefinition< id: string; /** - * Minimal licence level - * Empty means no licence restrictions + * Minimal license level + * Empty means no license restrictions */ readonly minimalLicense?: LicenseType; + /** + * Required when `minimalLicense` is used. + * Is a user-facing string. Has to be unique. Doesn't need i18n. + * The feature's name will be displayed to Cloud end-users when they're billed based on their feature usage. + */ + licenseFeatureName?: string; + /** * This method should return a definition of a new action, normally used to * register it in `ui_actions` registry. diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts index 0b0cd39e35e25..39d9dfeca2fd6 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts @@ -87,7 +87,9 @@ const setup = ( actions, }); const uiActionsEnhancements = new UiActionsServiceEnhancements({ - getLicenseInfo, + getLicense: getLicenseInfo, + featureUsageSetup: licensingMock.createSetup().featureUsage, + getFeatureUsageStart: () => licensingMock.createStart().featureUsage, }); const manager = new DynamicActionManager({ isCompatible, @@ -671,11 +673,13 @@ describe('DynamicActionManager', () => { const basicActionFactory: ActionFactoryDefinition = { ...actionFactoryDefinition1, minimalLicense: 'basic', + licenseFeatureName: 'Feature 1', }; const goldActionFactory: ActionFactoryDefinition = { ...actionFactoryDefinition2, minimalLicense: 'gold', + licenseFeatureName: 'Feature 2', }; uiActions.registerActionFactory(basicActionFactory); diff --git a/x-pack/plugins/ui_actions_enhanced/public/mocks.ts b/x-pack/plugins/ui_actions_enhanced/public/mocks.ts index ff07d6e74a9c0..17a6fc1b955df 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/mocks.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/mocks.ts @@ -11,6 +11,7 @@ import { embeddablePluginMock } from '../../../../src/plugins/embeddable/public/ import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '.'; import { plugin as pluginInitializer } from '.'; import { licensingMock } from '../../licensing/public/mocks'; +import { StartDependencies } from './plugin'; export type Setup = jest.Mocked; export type Start = jest.Mocked; @@ -35,7 +36,7 @@ const createStartContract = (): Start => { }; const createPlugin = ( - coreSetup: CoreSetup = coreMock.createSetup(), + coreSetup: CoreSetup = coreMock.createSetup(), coreStart: CoreStart = coreMock.createStart() ) => { const pluginInitializerContext = coreMock.createPluginInitializerContext(); @@ -47,6 +48,7 @@ const createPlugin = ( const setup = plugin.setup(coreSetup, { uiActions: uiActions.setup, embeddable: embeddable.setup, + licensing: licensingMock.createSetup(), }); return { diff --git a/x-pack/plugins/ui_actions_enhanced/public/plugin.ts b/x-pack/plugins/ui_actions_enhanced/public/plugin.ts index 5069b485b198d..015531aab9743 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/plugin.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/plugin.ts @@ -36,16 +36,17 @@ import { } from './custom_time_range_badge'; import { CommonlyUsedRange } from './types'; import { UiActionsServiceEnhancements } from './services'; -import { ILicense, LicensingPluginStart } from '../../licensing/public'; +import { ILicense, LicensingPluginSetup, LicensingPluginStart } from '../../licensing/public'; import { createFlyoutManageDrilldowns } from './drilldowns'; -import { Storage } from '../../../../src/plugins/kibana_utils/public'; +import { createStartServicesGetter, Storage } from '../../../../src/plugins/kibana_utils/public'; interface SetupDependencies { embeddable: EmbeddableSetup; // Embeddable are needed because they register basic triggers/actions. uiActions: UiActionsSetup; + licensing: LicensingPluginSetup; } -interface StartDependencies { +export interface StartDependencies { embeddable: EmbeddableStart; uiActions: UiActionsStart; licensing: LicensingPluginStart; @@ -70,23 +71,30 @@ declare module '../../../../src/plugins/ui_actions/public' { export class AdvancedUiActionsPublicPlugin implements Plugin { - readonly licenceInfo = new BehaviorSubject(undefined); + readonly licenseInfo = new BehaviorSubject(undefined); private getLicenseInfo(): ILicense { - if (!this.licenceInfo.getValue()) { + if (!this.licenseInfo.getValue()) { throw new Error( - 'AdvancedUiActionsPublicPlugin: Licence is not ready! Licence becomes available only after setup.' + 'AdvancedUiActionsPublicPlugin: License is not ready! License becomes available only after setup.' ); } - return this.licenceInfo.getValue()!; + return this.licenseInfo.getValue()!; } - private readonly enhancements = new UiActionsServiceEnhancements({ - getLicenseInfo: () => this.getLicenseInfo(), - }); + private enhancements?: UiActionsServiceEnhancements; private subs: Subscription[] = []; constructor(initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup, { uiActions }: SetupDependencies): SetupContract { + public setup( + core: CoreSetup, + { uiActions, licensing }: SetupDependencies + ): SetupContract { + const startServices = createStartServicesGetter(core.getStartServices); + this.enhancements = new UiActionsServiceEnhancements({ + getLicense: () => this.getLicenseInfo(), + featureUsageSetup: licensing.featureUsage, + getFeatureUsageStart: () => startServices().plugins.licensing.featureUsage, + }); return { ...uiActions, ...this.enhancements, @@ -94,7 +102,7 @@ export class AdvancedUiActionsPublicPlugin } public start(core: CoreStart, { uiActions, licensing }: StartDependencies): StartContract { - this.subs.push(licensing.license$.subscribe(this.licenceInfo)); + this.subs.push(licensing.license$.subscribe(this.licenseInfo)); const dateFormat = core.uiSettings.get('dateFormat') as string; const commonlyUsedRanges = core.uiSettings.get( @@ -117,9 +125,9 @@ export class AdvancedUiActionsPublicPlugin return { ...uiActions, - ...this.enhancements, + ...this.enhancements!, FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ - actionFactories: this.enhancements.getActionFactories(), + actionFactories: this.enhancements!.getActionFactories(), getTrigger: (triggerId: TriggerId) => uiActions.getTrigger(triggerId), storage: new Storage(window?.localStorage), toastService: core.notifications.toasts, diff --git a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts index 08823833b9af2..3a0b65d2ed844 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts @@ -4,11 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { UiActionsServiceEnhancements } from './ui_actions_service_enhancements'; +import { + UiActionsServiceEnhancements, + UiActionsServiceEnhancementsParams, +} from './ui_actions_service_enhancements'; import { ActionFactoryDefinition, ActionFactory } from '../dynamic_actions'; import { licensingMock } from '../../../licensing/public/mocks'; -const getLicenseInfo = () => licensingMock.createLicense(); +const deps: UiActionsServiceEnhancementsParams = { + getLicense: () => licensingMock.createLicense(), + featureUsageSetup: licensingMock.createSetup().featureUsage, + getFeatureUsageStart: () => licensingMock.createStart().featureUsage, +}; describe('UiActionsService', () => { describe('action factories', () => { @@ -34,7 +41,7 @@ describe('UiActionsService', () => { }; test('.getActionFactories() returns empty array if no action factories registered', () => { - const service = new UiActionsServiceEnhancements({ getLicenseInfo }); + const service = new UiActionsServiceEnhancements(deps); const factories = service.getActionFactories(); @@ -42,7 +49,7 @@ describe('UiActionsService', () => { }); test('can register and retrieve an action factory', () => { - const service = new UiActionsServiceEnhancements({ getLicenseInfo }); + const service = new UiActionsServiceEnhancements(deps); service.registerActionFactory(factoryDefinition1); @@ -53,7 +60,7 @@ describe('UiActionsService', () => { }); test('can retrieve all action factories', () => { - const service = new UiActionsServiceEnhancements({ getLicenseInfo }); + const service = new UiActionsServiceEnhancements(deps); service.registerActionFactory(factoryDefinition1); service.registerActionFactory(factoryDefinition2); @@ -67,7 +74,7 @@ describe('UiActionsService', () => { }); test('throws when retrieving action factory that does not exist', () => { - const service = new UiActionsServiceEnhancements({ getLicenseInfo }); + const service = new UiActionsServiceEnhancements(deps); service.registerActionFactory(factoryDefinition1); @@ -77,7 +84,7 @@ describe('UiActionsService', () => { }); test('isCompatible from definition is used on registered factory', async () => { - const service = new UiActionsServiceEnhancements({ getLicenseInfo }); + const service = new UiActionsServiceEnhancements(deps); service.registerActionFactory({ ...factoryDefinition1, @@ -88,5 +95,27 @@ describe('UiActionsService', () => { service.getActionFactory(factoryDefinition1.id).isCompatible({ triggers: [] }) ).resolves.toBe(false); }); + + describe('registerFeature for licensing', () => { + const spy = jest.spyOn(deps.featureUsageSetup, 'register'); + beforeEach(() => { + spy.mockClear(); + }); + test('registerFeature is not called if no license requirements', () => { + const service = new UiActionsServiceEnhancements(deps); + service.registerActionFactory(factoryDefinition1); + expect(spy).not.toBeCalled(); + }); + + test('registerFeature is called if has license requirements', () => { + const service = new UiActionsServiceEnhancements(deps); + service.registerActionFactory({ + ...factoryDefinition1, + minimalLicense: 'gold', + licenseFeatureName: 'a name', + }); + expect(spy).toBeCalledWith('a name', 'gold'); + }); + }); }); }); diff --git a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts index 9575329514835..b8086c16f5e71 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts @@ -13,19 +13,22 @@ import { import { DrilldownDefinition } from '../drilldowns'; import { ILicense } from '../../../licensing/common/types'; import { TriggerContextMapping, TriggerId } from '../../../../../src/plugins/ui_actions/public'; +import { LicensingPluginSetup, LicensingPluginStart } from '../../../licensing/public'; export interface UiActionsServiceEnhancementsParams { readonly actionFactories?: ActionFactoryRegistry; - readonly getLicenseInfo: () => ILicense; + readonly getLicense: () => ILicense; + readonly featureUsageSetup: LicensingPluginSetup['featureUsage']; + readonly getFeatureUsageStart: () => LicensingPluginStart['featureUsage']; } export class UiActionsServiceEnhancements { protected readonly actionFactories: ActionFactoryRegistry; - protected readonly getLicenseInfo: () => ILicense; + protected readonly deps: Omit; - constructor({ actionFactories = new Map(), getLicenseInfo }: UiActionsServiceEnhancementsParams) { + constructor({ actionFactories = new Map(), ...deps }: UiActionsServiceEnhancementsParams) { this.actionFactories = actionFactories; - this.getLicenseInfo = getLicenseInfo; + this.deps = deps; } /** @@ -51,9 +54,10 @@ export class UiActionsServiceEnhancements { SupportedTriggers, FactoryContext, ActionContext - >(definition, this.getLicenseInfo); + >(definition, this.deps); this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); + this.registerFeatureUsage(definition); }; public readonly getActionFactory = (actionFactoryId: string): ActionFactory => { @@ -94,6 +98,7 @@ export class UiActionsServiceEnhancements { execute, getHref, minimalLicense, + licenseFeatureName, supportedTriggers, isCompatible, }: DrilldownDefinition): void => { @@ -105,6 +110,7 @@ export class UiActionsServiceEnhancements { > = { id: factoryId, minimalLicense, + licenseFeatureName, order, CollectConfig, createConfig, @@ -128,4 +134,19 @@ export class UiActionsServiceEnhancements { this.registerActionFactory(actionFactory); }; + + private registerFeatureUsage = (definition: ActionFactoryDefinition): void => { + if (!definition.minimalLicense || !definition.licenseFeatureName) return; + + // Intentionally don't wait for response because + // happens in setup phase and has to be sync + this.deps.featureUsageSetup + .register(definition.licenseFeatureName, definition.minimalLicense) + .catch(() => { + // eslint-disable-next-line no-console + console.warn( + `ActionFactory [actionFactory.id = ${definition.id}] fail to register feature for featureUsage.` + ); + }); + }; } From 3e077973e8a5b73c03ab149d74c13ae2b9a700f3 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Wed, 2 Sep 2020 16:11:12 +0200 Subject: [PATCH 30/53] [APM] Immediately return terms for unbound queries (#74543) Co-authored-by: Elastic Machine --- .../apm/server/lib/environments/get_all_environments.ts | 5 +++++ .../lib/helpers/create_es_client/call_client_with_debug.ts | 2 +- .../agent_configuration/__snapshots__/queries.test.ts.snap | 2 ++ .../lib/settings/agent_configuration/get_service_names.ts | 2 ++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts b/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts index 29aaa98169fa5..8060bf10da99c 100644 --- a/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts +++ b/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts @@ -37,6 +37,9 @@ export async function getAllEnvironments({ ], }, body: { + // use timeout + min_doc_count to return as early as possible + // if filter is not defined to prevent timeouts + ...(!serviceName ? { timeout: '1ms' } : {}), size: 0, query: { bool: { @@ -48,6 +51,7 @@ export async function getAllEnvironments({ terms: { field: SERVICE_ENVIRONMENT, size: 100, + ...(!serviceName ? { min_doc_count: 0 } : {}), missing: includeMissing ? ENVIRONMENT_NOT_DEFINED.value : undefined, }, }, @@ -56,6 +60,7 @@ export async function getAllEnvironments({ }; const resp = await apmEventClient.search(params); + const environments = resp.aggregations?.environments.buckets.map( (bucket) => bucket.key as string diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_client_with_debug.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_client_with_debug.ts index c475640595227..9f7aaafbefb8c 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_client_with_debug.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_client_with_debug.ts @@ -34,7 +34,7 @@ export async function callClientWithDebug({ let res: any; let esError = null; try { - res = apiCaller(operationName, params); + res = await apiCaller(operationName, params); } catch (e) { // catch error and throw after outputting debug info esError = e; diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/settings/agent_configuration/__snapshots__/queries.test.ts.snap index 2b465a0f87475..c01e5c87eeea2 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/__snapshots__/queries.test.ts.snap @@ -127,11 +127,13 @@ Object { "services": Object { "terms": Object { "field": "service.name", + "min_doc_count": 0, "size": 50, }, }, }, "size": 0, + "timeout": "1ms", }, } `; diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts index 8b6c1d82beab0..91bdfeef003f1 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts @@ -25,12 +25,14 @@ export async function getServiceNames({ setup }: { setup: Setup }) { ], }, body: { + timeout: '1ms', size: 0, aggs: { services: { terms: { field: SERVICE_NAME, size: 50, + min_doc_count: 0, }, }, }, From 01e442081f666322a99266f443b2651228306cf4 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 2 Sep 2020 10:34:37 -0400 Subject: [PATCH 31/53] [Visualize] Remove alias icon for Lens and Maps (#76418) * [Visualize] Remove alias icon for Lens and Maps * Fix snapshots * Fix translations --- .../__snapshots__/new_vis_modal.test.tsx.snap | 140 +----------------- .../wizard/type_selection/type_selection.tsx | 42 ++---- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 4 files changed, 16 insertions(+), 170 deletions(-) diff --git a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap index 6aed16e937713..3c4c983efa9fa 100644 --- a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap +++ b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap @@ -248,7 +248,7 @@ exports[`NewVisModal filter for visualization types should render as expected 1`