diff --git a/.eslintrc.js b/.eslintrc.js index 40dd6a55a2a3f6..c64f03a8398e54 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -893,6 +893,8 @@ module.exports = { files: [ 'x-pack/plugins/security_solution/public/**/*.{js,mjs,ts,tsx}', 'x-pack/plugins/security_solution/common/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/timelines/public/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/timelines/common/**/*.{js,mjs,ts,tsx}', ], rules: { 'import/no-nodejs-modules': 'error', @@ -907,7 +909,10 @@ module.exports = { }, { // typescript only for front and back end - files: ['x-pack/plugins/security_solution/**/*.{ts,tsx}'], + files: [ + 'x-pack/plugins/security_solution/**/*.{ts,tsx}', + 'x-pack/plugins/timelines/**/*.{ts,tsx}', + ], rules: { '@typescript-eslint/no-this-alias': 'error', '@typescript-eslint/no-explicit-any': 'error', @@ -917,7 +922,10 @@ module.exports = { }, { // typescript and javascript for front and back end - files: ['x-pack/plugins/security_solution/**/*.{js,mjs,ts,tsx}'], + files: [ + 'x-pack/plugins/security_solution/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/timelines/**/*.{js,mjs,ts,tsx}', + ], plugins: ['eslint-plugin-node', 'react'], env: { jest: true, diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index e8b950a696f55d..5d7ba22841aa16 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -86,6 +86,7 @@ yarn kbn watch-bazel - @kbn/logging - @kbn/mapbox-gl - @kbn/monaco +- @kbn/optimizer - @kbn/rule-data-utils - @kbn/securitysolution-es-utils - @kbn/securitysolution-hook-utils diff --git a/package.json b/package.json index 36fa086657adff..26465133569cd9 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api", "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks", "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils", + "@kbn/securitysolution-t-grid": "link:bazel-bin/packages/kbn-securitysolution-t-grid", "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils", "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository", @@ -217,6 +218,8 @@ "cytoscape-dagre": "^2.2.2", "d3": "3.5.17", "d3-array": "1.2.4", + "d3-cloud": "1.2.5", + "d3-interpolate": "^3.0.1", "d3-scale": "1.0.7", "d3-shape": "^1.1.0", "d3-time": "^1.1.0", @@ -462,7 +465,7 @@ "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana", "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint", "@kbn/expect": "link:bazel-bin/packages/kbn-expect", - "@kbn/optimizer": "link:packages/kbn-optimizer", + "@kbn/optimizer": "link:bazel-bin/packages/kbn-optimizer", "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", @@ -511,6 +514,7 @@ "@types/cytoscape": "^3.14.0", "@types/d3": "^3.5.43", "@types/d3-array": "^1.2.7", + "@types/d3-interpolate": "^2.0.0", "@types/d3-scale": "^2.1.1", "@types/d3-shape": "^1.3.1", "@types/d3-time": "^1.0.10", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index b1c3f580c6baf8..d9e2f0e1f99854 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -3,7 +3,7 @@ filegroup( name = "build", srcs = [ - "//packages/elastic-datemath:build", + "//packages/elastic-datemath:build", "//packages/elastic-eslint-config-kibana:build", "//packages/elastic-safer-lodash-set:build", "//packages/kbn-ace:build", @@ -29,6 +29,7 @@ filegroup( "//packages/kbn-logging:build", "//packages/kbn-mapbox-gl:build", "//packages/kbn-monaco:build", + "//packages/kbn-optimizer:build", "//packages/kbn-plugin-generator:build", "//packages/kbn-rule-data-utils:build", "//packages/kbn-securitysolution-list-constants:build", @@ -41,6 +42,7 @@ filegroup( "//packages/kbn-securitysolution-list-utils:build", "//packages/kbn-securitysolution-utils:build", "//packages/kbn-securitysolution-es-utils:build", + "//packages/kbn-securitysolution-t-grid:build", "//packages/kbn-securitysolution-hook-utils:build", "//packages/kbn-server-http-tools:build", "//packages/kbn-server-route-repository:build", diff --git a/packages/kbn-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json index dd491de55c075d..cf6fcfd88a26da 100644 --- a/packages/kbn-cli-dev-mode/package.json +++ b/packages/kbn-cli-dev-mode/package.json @@ -12,8 +12,5 @@ }, "kibana": { "devOnly": true - }, - "dependencies": { - "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-optimizer/BUILD.bazel b/packages/kbn-optimizer/BUILD.bazel new file mode 100644 index 00000000000000..3809c2b33d5009 --- /dev/null +++ b/packages/kbn-optimizer/BUILD.bazel @@ -0,0 +1,120 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-optimizer" +PKG_REQUIRE_NAME = "@kbn/optimizer" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*", + "**/__fixtures__/**", + "**/__snapshots__/**", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "limits.yml", + "package.json", + "postcss.config.js", + "README.md" +] + +SRC_DEPS = [ + "//packages/kbn-config", + "//packages/kbn-dev-utils", + "//packages/kbn-std", + "//packages/kbn-ui-shared-deps", + "//packages/kbn-utils", + "@npm//chalk", + "@npm//clean-webpack-plugin", + "@npm//compression-webpack-plugin", + "@npm//cpy", + "@npm//del", + "@npm//execa", + "@npm//jest-diff", + "@npm//json-stable-stringify", + "@npm//lmdb-store", + "@npm//loader-utils", + "@npm//node-sass", + "@npm//normalize-path", + "@npm//pirates", + "@npm//resize-observer-polyfill", + "@npm//rxjs", + "@npm//source-map-support", + "@npm//watchpack", + "@npm//webpack", + "@npm//webpack-merge", + "@npm//webpack-sources", + "@npm//zlib" +] + +TYPES_DEPS = [ + "@npm//@types/compression-webpack-plugin", + "@npm//@types/jest", + "@npm//@types/json-stable-stringify", + "@npm//@types/loader-utils", + "@npm//@types/node", + "@npm//@types/normalize-path", + "@npm//@types/source-map-support", + "@npm//@types/watchpack", + "@npm//@types/webpack", + "@npm//@types/webpack-merge", + "@npm//@types/webpack-sources", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index f9127e4629f43e..c6960621359c78 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -67,7 +67,7 @@ pageLoadAssetSize: searchprofiler: 67080 security: 95864 securityOss: 30806 - securitySolution: 76000 + securitySolution: 217673 share: 99061 snapshotRestore: 79032 spaces: 57868 @@ -107,7 +107,7 @@ pageLoadAssetSize: dataVisualizer: 27530 banners: 17946 mapsEms: 26072 - timelines: 28613 + timelines: 230410 screenshotMode: 17856 visTypePie: 35583 cases: 144442 diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index a6c8284ad15f64..d23512f7c418d6 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -4,10 +4,5 @@ "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target/index.js", - "types": "./target/index.d.ts", - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "types": "./target/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index c175979f0e820e..1f1e33d3dda7c8 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -123,7 +123,7 @@ exports[`prepares assets for distribution: metrics.json 1`] = ` \\"group\\": \\"page load bundle size\\", \\"id\\": \\"foo\\", \\"value\\": 4627, - \\"limitConfigPath\\": \\"packages/kbn-optimizer/limits.yml\\" + \\"limitConfigPath\\": \\"node_modules/@kbn/optimizer/limits.yml\\" }, { \\"group\\": \\"async chunks size\\", diff --git a/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts b/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts index 92875d3f69e465..d9e1bee22557bf 100644 --- a/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts +++ b/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts @@ -79,7 +79,7 @@ export class BundleMetricsPlugin { id: bundle.id, value: entry.size, limit: bundle.pageLoadAssetSizeLimit, - limitConfigPath: `packages/kbn-optimizer/limits.yml`, + limitConfigPath: `node_modules/@kbn/optimizer/limits.yml`, }, { group: `async chunks size`, diff --git a/packages/kbn-optimizer/tsconfig.json b/packages/kbn-optimizer/tsconfig.json index f2d508cf14a55e..76beaf7689fd41 100644 --- a/packages/kbn-optimizer/tsconfig.json +++ b/packages/kbn-optimizer/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "declaration": true, "declarationMap": true, + "rootDir": "./src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-optimizer/src" }, diff --git a/packages/kbn-plugin-helpers/package.json b/packages/kbn-plugin-helpers/package.json index 2d642d9ede13bc..36a37075191a37 100644 --- a/packages/kbn-plugin-helpers/package.json +++ b/packages/kbn-plugin-helpers/package.json @@ -15,8 +15,5 @@ "scripts": { "kbn:bootstrap": "rm -rf target && ../../node_modules/.bin/tsc", "kbn:watch": "../../node_modules/.bin/tsc --watch" - }, - "dependencies": { - "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-securitysolution-t-grid/BUILD.bazel b/packages/kbn-securitysolution-t-grid/BUILD.bazel new file mode 100644 index 00000000000000..5cf1081bdd32e9 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/BUILD.bazel @@ -0,0 +1,125 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-securitysolution-t-grid" + +PKG_REQUIRE_NAME = "@kbn/securitysolution-t-grid" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + ], + exclude = [ + "**/*.test.*", + "**/*.mock.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "react/package.json", + "package.json", + "README.md", +] + +SRC_DEPS = [ + "//packages/kbn-babel-preset", + "//packages/kbn-dev-utils", + "//packages/kbn-i18n", + "@npm//@babel/core", + "@npm//babel-loader", + "@npm//enzyme", + "@npm//jest", + "@npm//lodash", + "@npm//react", + "@npm//react-beautiful-dnd", + "@npm//tslib", +] + +TYPES_DEPS = [ + "@npm//typescript", + "@npm//@types/enzyme", + "@npm//@types/jest", + "@npm//@types/lodash", + "@npm//@types/node", + "@npm//@types/react", + "@npm//@types/react-beautiful-dnd", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_config( + name = "tsconfig_browser", + src = "tsconfig.browser.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.browser.json", + ], +) + +ts_project( + name = "tsc", + args = ["--pretty"], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_dir = "target_types", + declaration_map = True, + incremental = True, + out_dir = "target_node", + root_dir = "src", + source_map = True, + tsconfig = ":tsconfig", +) + +ts_project( + name = "tsc_browser", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + allow_js = True, + declaration = False, + incremental = True, + out_dir = "target_web", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig_browser", +) + +js_library( + name = PKG_BASE_NAME, + package_name = PKG_REQUIRE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + visibility = ["//visibility:public"], + deps = [":tsc", ":tsc_browser"] + DEPS, +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ], +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-securitysolution-t-grid/README.md b/packages/kbn-securitysolution-t-grid/README.md new file mode 100644 index 00000000000000..a49669c81689a2 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/README.md @@ -0,0 +1,3 @@ +# kbn-securitysolution-t-grid + +We do not want to create circular dependencies between security_solution and timelines plugins. Therefore , we will use this packages to share components between these two plugins. diff --git a/packages/kbn-securitysolution-t-grid/babel.config.js b/packages/kbn-securitysolution-t-grid/babel.config.js new file mode 100644 index 00000000000000..b4a118df51af51 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/babel.config.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + env: { + web: { + presets: ['@kbn/babel-preset/webpack_preset'], + }, + node: { + presets: ['@kbn/babel-preset/node_preset'], + }, + }, + ignore: ['**/*.test.ts', '**/*.test.tsx'], +}; diff --git a/packages/kbn-securitysolution-t-grid/jest.config.js b/packages/kbn-securitysolution-t-grid/jest.config.js new file mode 100644 index 00000000000000..21e7d2d71b61a1 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-securitysolution-t-grid'], +}; diff --git a/packages/kbn-securitysolution-t-grid/package.json b/packages/kbn-securitysolution-t-grid/package.json new file mode 100644 index 00000000000000..68d3a8c71e7cac --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/package.json @@ -0,0 +1,10 @@ +{ + "name": "@kbn/securitysolution-t-grid", + "version": "1.0.0", + "description": "security solution t-grid packages will allow sharing components between timelines and security_solution plugin until we transfer all functionality to timelines plugin", + "license": "SSPL-1.0 OR Elastic License 2.0", + "browser": "./target_web/browser.js", + "main": "./target_node/index.js", + "types": "./target_types/index.d.ts", + "private": true +} diff --git a/packages/kbn-securitysolution-t-grid/react/package.json b/packages/kbn-securitysolution-t-grid/react/package.json new file mode 100644 index 00000000000000..c29ddd45f084d8 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/react/package.json @@ -0,0 +1,5 @@ +{ + "browser": "../target_web/react", + "main": "../target_node/react", + "types": "../target_types/react/index.d.ts" +} \ No newline at end of file diff --git a/packages/kbn-securitysolution-t-grid/src/constants/index.ts b/packages/kbn-securitysolution-t-grid/src/constants/index.ts new file mode 100644 index 00000000000000..c03c0093d98392 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/constants/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const HIGHLIGHTED_DROP_TARGET_CLASS_NAME = 'highlighted-drop-target'; +export const EMPTY_PROVIDERS_GROUP_CLASS_NAME = 'empty-providers-group'; + +/** The draggable will move this many pixels via the keyboard when the arrow key is pressed */ +export const KEYBOARD_DRAG_OFFSET = 20; + +export const DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME = 'draggable-keyboard-wrapper'; + +export const ROW_RENDERER_CLASS_NAME = 'row-renderer'; + +export const NOTES_CONTAINER_CLASS_NAME = 'notes-container'; + +export const NOTE_CONTENT_CLASS_NAME = 'note-content'; + +/** This class is added to the document body while dragging */ +export const IS_DRAGGING_CLASS_NAME = 'is-dragging'; + +export const HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME = 'hover-actions-always-show'; diff --git a/packages/kbn-securitysolution-t-grid/src/index.ts b/packages/kbn-securitysolution-t-grid/src/index.ts new file mode 100644 index 00000000000000..0c2e9a7dbea8bf --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './constants'; +export * from './utils'; +export * from './mock'; diff --git a/packages/kbn-securitysolution-t-grid/src/mock/index.ts b/packages/kbn-securitysolution-t-grid/src/mock/index.ts new file mode 100644 index 00000000000000..dc1b63dfc33b03 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/mock/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './mock_event_details'; diff --git a/x-pack/plugins/security_solution/common/utils/mock_event_details.ts b/packages/kbn-securitysolution-t-grid/src/mock/mock_event_details.ts similarity index 97% rename from x-pack/plugins/security_solution/common/utils/mock_event_details.ts rename to packages/kbn-securitysolution-t-grid/src/mock/mock_event_details.ts index 7dc257ebb3feff..167fc9dd17a2ac 100644 --- a/x-pack/plugins/security_solution/common/utils/mock_event_details.ts +++ b/packages/kbn-securitysolution-t-grid/src/mock/mock_event_details.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ export const eventHit = { diff --git a/packages/kbn-securitysolution-t-grid/src/utils/api/index.ts b/packages/kbn-securitysolution-t-grid/src/utils/api/index.ts new file mode 100644 index 00000000000000..34e448419693bb --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/utils/api/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { has } from 'lodash/fp'; + +export interface AppError extends Error { + body: { + message: string; + }; +} + +export interface KibanaError extends AppError { + body: { + message: string; + statusCode: number; + }; +} + +export interface SecurityAppError extends AppError { + body: { + message: string; + status_code: number; + }; +} + +export const isKibanaError = (error: unknown): error is KibanaError => + has('message', error) && has('body.message', error) && has('body.statusCode', error); + +export const isSecurityAppError = (error: unknown): error is SecurityAppError => + has('message', error) && has('body.message', error) && has('body.status_code', error); + +export const isAppError = (error: unknown): error is AppError => + isKibanaError(error) || isSecurityAppError(error); + +export const isNotFoundError = (error: unknown) => + (isKibanaError(error) && error.body.statusCode === 404) || + (isSecurityAppError(error) && error.body.status_code === 404); diff --git a/packages/kbn-securitysolution-t-grid/src/utils/drag_and_drop/index.ts b/packages/kbn-securitysolution-t-grid/src/utils/drag_and_drop/index.ts new file mode 100644 index 00000000000000..91b2e88d973589 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/utils/drag_and_drop/index.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DropResult } from 'react-beautiful-dnd'; + +export const draggableIdPrefix = 'draggableId'; + +export const droppableIdPrefix = 'droppableId'; + +export const draggableContentPrefix = `${draggableIdPrefix}.content.`; + +export const draggableTimelineProvidersPrefix = `${draggableIdPrefix}.timelineProviders.`; + +export const draggableFieldPrefix = `${draggableIdPrefix}.field.`; + +export const droppableContentPrefix = `${droppableIdPrefix}.content.`; + +export const droppableFieldPrefix = `${droppableIdPrefix}.field.`; + +export const droppableTimelineProvidersPrefix = `${droppableIdPrefix}.timelineProviders.`; + +export const droppableTimelineColumnsPrefix = `${droppableIdPrefix}.timelineColumns.`; + +export const droppableTimelineFlyoutBottomBarPrefix = `${droppableIdPrefix}.flyoutButton.`; + +export const getDraggableId = (dataProviderId: string): string => + `${draggableContentPrefix}${dataProviderId}`; + +export const getDraggableFieldId = ({ + contextId, + fieldId, +}: { + contextId: string; + fieldId: string; +}): string => `${draggableFieldPrefix}${escapeContextId(contextId)}.${escapeFieldId(fieldId)}`; + +export const getTimelineProviderDroppableId = ({ + groupIndex, + timelineId, +}: { + groupIndex: number; + timelineId: string; +}): string => `${droppableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}`; + +export const getTimelineProviderDraggableId = ({ + dataProviderId, + groupIndex, + timelineId, +}: { + dataProviderId: string; + groupIndex: number; + timelineId: string; +}): string => + `${draggableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}.${dataProviderId}`; + +export const getDroppableId = (visualizationPlaceholderId: string): string => + `${droppableContentPrefix}${visualizationPlaceholderId}`; + +export const sourceIsContent = (result: DropResult): boolean => + result.source.droppableId.startsWith(droppableContentPrefix); + +export const sourceAndDestinationAreSameTimelineProviders = (result: DropResult): boolean => { + const regex = /^droppableId\.timelineProviders\.(\S+)\./; + const sourceMatches = result.source.droppableId.match(regex) || []; + const destinationMatches = + (result.destination && result.destination.droppableId.match(regex)) || []; + + return ( + sourceMatches.length >= 2 && + destinationMatches.length >= 2 && + sourceMatches[1] === destinationMatches[1] + ); +}; + +export const draggableIsContent = (result: DropResult | { draggableId: string }): boolean => + result.draggableId.startsWith(draggableContentPrefix); + +export const draggableIsField = (result: DropResult | { draggableId: string }): boolean => + result.draggableId.startsWith(draggableFieldPrefix); + +export const reasonIsDrop = (result: DropResult): boolean => result.reason === 'DROP'; + +export const destinationIsTimelineProviders = (result: DropResult): boolean => + result.destination != null && + result.destination.droppableId.startsWith(droppableTimelineProvidersPrefix); + +export const destinationIsTimelineColumns = (result: DropResult): boolean => + result.destination != null && + result.destination.droppableId.startsWith(droppableTimelineColumnsPrefix); + +export const destinationIsTimelineButton = (result: DropResult): boolean => + result.destination != null && + result.destination.droppableId.startsWith(droppableTimelineFlyoutBottomBarPrefix); + +export const getProviderIdFromDraggable = (result: DropResult): string => + result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1); + +export const getFieldIdFromDraggable = (result: DropResult): string => + unEscapeFieldId(result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1)); + +export const escapeDataProviderId = (path: string) => path.replace(/\./g, '_'); + +export const escapeContextId = (path: string) => path.replace(/\./g, '_'); + +export const escapeFieldId = (path: string) => path.replace(/\./g, '!!!DOT!!!'); + +export const unEscapeFieldId = (path: string) => path.replace(/!!!DOT!!!/g, '.'); + +export const providerWasDroppedOnTimeline = (result: DropResult): boolean => + reasonIsDrop(result) && + draggableIsContent(result) && + sourceIsContent(result) && + destinationIsTimelineProviders(result); + +export const userIsReArrangingProviders = (result: DropResult): boolean => + reasonIsDrop(result) && sourceAndDestinationAreSameTimelineProviders(result); + +export const fieldWasDroppedOnTimelineColumns = (result: DropResult): boolean => + reasonIsDrop(result) && draggableIsField(result) && destinationIsTimelineColumns(result); + +/** + * Prevents fields from being dragged or dropped to any area other than column + * header drop zone in the timeline + */ +export const DRAG_TYPE_FIELD = 'drag-type-field'; + +/** This class is added to the document body while timeline field dragging */ +export const IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME = 'is-timeline-field-dragging'; diff --git a/packages/kbn-securitysolution-t-grid/src/utils/index.ts b/packages/kbn-securitysolution-t-grid/src/utils/index.ts new file mode 100644 index 00000000000000..39629a990c539c --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/src/utils/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './api'; +export * from './drag_and_drop'; diff --git a/packages/kbn-securitysolution-t-grid/tsconfig.browser.json b/packages/kbn-securitysolution-t-grid/tsconfig.browser.json new file mode 100644 index 00000000000000..a5183ba4fd4576 --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/tsconfig.browser.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.browser.json", + "compilerOptions": { + "allowJs": true, + "incremental": true, + "outDir": "./target_web", + "declaration": false, + "isolatedModules": true, + "sourceMap": true, + "sourceRoot": "../../../../../packages/kbn-securitysolution-t-grid/src", + "types": [ + "jest", + "node" + ], + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + ], + "exclude": [ + "**/__fixtures__/**/*" + ] +} diff --git a/packages/kbn-securitysolution-t-grid/tsconfig.json b/packages/kbn-securitysolution-t-grid/tsconfig.json new file mode 100644 index 00000000000000..8cda578edede4c --- /dev/null +++ b/packages/kbn-securitysolution-t-grid/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "incremental": true, + "outDir": "target", + "rootDir": "src", + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-securitysolution-t-grid/src", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index 225f93d4878238..5baff607704c78 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -94,7 +94,7 @@ module.exports = { transformIgnorePatterns: [ // ignore all node_modules except monaco-editor and react-monaco-editor which requires babel transforms to handle dynamic import() // since ESM modules are not natively supported in Jest yet (https://github.com/facebook/jest/issues/4842) - '[/\\\\]node_modules(?![\\/\\\\](monaco-editor|react-monaco-editor))[/\\\\].+\\.js$', + '[/\\\\]node_modules(?![\\/\\\\](monaco-editor|react-monaco-editor|d3-interpolate|d3-color))[/\\\\].+\\.js$', 'packages/kbn-pm/dist/index.js', ], diff --git a/packages/kbn-test/package.json b/packages/kbn-test/package.json index 275d9fac73c58d..aaff513f1591f2 100644 --- a/packages/kbn-test/package.json +++ b/packages/kbn-test/package.json @@ -12,8 +12,5 @@ }, "kibana": { "devOnly": true - }, - "dependencies": { - "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-test/src/jest/setup/babel_polyfill.js b/packages/kbn-test/src/jest/setup/babel_polyfill.js index d112e4d4fcb393..7dda4cceec65cd 100644 --- a/packages/kbn-test/src/jest/setup/babel_polyfill.js +++ b/packages/kbn-test/src/jest/setup/babel_polyfill.js @@ -9,4 +9,4 @@ // Note: In theory importing the polyfill should not be needed, as Babel should // include the necessary polyfills when using `@babel/preset-env`, but for some // reason it did not work. See https://github.com/elastic/kibana/issues/14506 -import '@kbn/optimizer/src/node/polyfill'; +import '@kbn/optimizer/target/node/polyfill'; diff --git a/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat new file mode 100755 index 00000000000000..9221af3142e613 --- /dev/null +++ b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat @@ -0,0 +1,35 @@ +@echo off + +SETLOCAL ENABLEDELAYEDEXPANSION + +set SCRIPT_DIR=%~dp0 +for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI + +set NODE=%DIR%\node\node.exe + +If Not Exist "%NODE%" ( + Echo unable to find usable node.js executable. + Exit /B 1 +) + +set CONFIG_DIR=%KBN_PATH_CONF% +If [%KBN_PATH_CONF%] == [] ( + set "CONFIG_DIR=%DIR%\config" +) + +IF EXIST "%CONFIG_DIR%\node.options" ( + for /F "usebackq eol=# tokens=*" %%i in ("%CONFIG_DIR%\node.options") do ( + If [!NODE_OPTIONS!] == [] ( + set "NODE_OPTIONS=%%i" + ) Else ( + set "NODE_OPTIONS=!NODE_OPTIONS! %%i" + ) + ) +) + +TITLE Kibana Encryption Keys +"%NODE%" "%DIR%\src\cli_encryption_keys\dist" %* + +:finally + +ENDLOCAL diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx index 0a27b4098681b6..732aa35b052370 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx @@ -13,7 +13,7 @@ import { Error } from '../types'; interface Props { title: React.ReactNode; - error: Error; + error?: Error; actions?: JSX.Element; isCentered?: boolean; } @@ -32,30 +32,30 @@ export const PageError: React.FunctionComponent = ({ isCentered, ...rest }) => { - const { - error: errorString, - cause, // wrapEsError() on the server adds a "cause" array - message, - } = error; + const errorString = error?.error; + const cause = error?.cause; // wrapEsError() on the server adds a "cause" array + const message = error?.message; const errorContent = ( {title}} body={ - <> - {cause ? message || errorString :

{message || errorString}

} - {cause && ( - <> - -
    - {cause.map((causeMsg, i) => ( -
  • {causeMsg}
  • - ))} -
- - )} - + error && ( + <> + {cause ? message || errorString :

{message || errorString}

} + {cause && ( + <> + +
    + {cause.map((causeMsg, i) => ( +
  • {causeMsg}
  • + ))} +
+ + )} + + ) } iconType="alert" actions={actions} diff --git a/src/plugins/es_ui_shared/public/components/page_loading/index.ts b/src/plugins/es_ui_shared/public/components/page_loading/index.ts new file mode 100644 index 00000000000000..3e7b93bb4e7c31 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/page_loading/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { PageLoading } from './page_loading'; diff --git a/src/plugins/es_ui_shared/public/components/page_loading/page_loading.tsx b/src/plugins/es_ui_shared/public/components/page_loading/page_loading.tsx new file mode 100644 index 00000000000000..2fb99208e58ac0 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/page_loading/page_loading.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText, EuiPageContent } from '@elastic/eui'; + +export const PageLoading: React.FunctionComponent = ({ children }) => { + return ( + + } + body={{children}} + data-test-subj="sectionLoading" + /> + + ); +}; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 7b9013c043a0e1..ef2e2daa254689 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -17,6 +17,7 @@ import * as XJson from './xjson'; export { JsonEditor, OnJsonEditorUpdateHandler, JsonEditorState } from './components/json_editor'; +export { PageLoading } from './components/page_loading'; export { SectionLoading } from './components/section_loading'; export { Frequency, CronEditor } from './components/cron_editor'; diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 8f5356f6a22012..5ee3156534c5ef 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -7,7 +7,8 @@ */ export { CSV_QUOTE_VALUES_SETTING, CSV_SEPARATOR_SETTING } from '../common/constants'; -export { LocatorDefinition } from '../common/url_service'; + +export { LocatorDefinition, LocatorPublic, KibanaLocation } from '../common/url_service'; export { UrlGeneratorStateMapping } from './url_generators/url_generator_definition'; diff --git a/test/functional/apps/discover/_huge_fields.ts b/test/functional/apps/discover/_huge_fields.ts index c7fe0a94b6019b..24b10e1df04956 100644 --- a/test/functional/apps/discover/_huge_fields.ts +++ b/test/functional/apps/discover/_huge_fields.ts @@ -15,21 +15,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); - // FLAKY: https://github.com/elastic/kibana/issues/96113 - describe.skip('test large number of fields in sidebar', function () { + describe('test large number of fields in sidebar', function () { before(async function () { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/huge_fields'); await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader'], false); - await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/large_fields'); - await PageObjects.settings.navigateTo(); await kibanaServer.uiSettings.update({ 'timepicker:timeDefaults': `{ "from": "2016-10-05T00:00:00", "to": "2016-10-06T00:00:00"}`, }); - await PageObjects.settings.createIndexPattern('*huge*', 'date', true); await PageObjects.common.navigateToApp('discover'); }); it('test_huge data should have expected number of fields', async function () { - await PageObjects.discover.selectIndexPattern('*huge*'); + await PageObjects.discover.selectIndexPattern('testhuge*'); // initially this field should not be rendered const fieldExistsBeforeScrolling = await testSubjects.exists('field-myvar1050'); expect(fieldExistsBeforeScrolling).to.be(false); @@ -41,8 +38,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); - await esArchiver.unload('test/functional/fixtures/es_archiver/large_fields'); - await kibanaServer.uiSettings.replace({}); + await esArchiver.unload('test/functional/fixtures/es_archiver/huge_fields'); + await kibanaServer.uiSettings.unset('timepicker:timeDefaults'); }); }); } diff --git a/test/functional/fixtures/es_archiver/huge_fields/data.json.gz b/test/functional/fixtures/es_archiver/huge_fields/data.json.gz new file mode 100644 index 00000000000000..1ce42c64c53a34 Binary files /dev/null and b/test/functional/fixtures/es_archiver/huge_fields/data.json.gz differ diff --git a/test/functional/fixtures/es_archiver/huge_fields/mappings.json b/test/functional/fixtures/es_archiver/huge_fields/mappings.json new file mode 100644 index 00000000000000..49a677a42f2ba6 --- /dev/null +++ b/test/functional/fixtures/es_archiver/huge_fields/mappings.json @@ -0,0 +1,24 @@ +{ + "type": "index", + "value": { + "index": "testhuge", + "mappings": { + "properties": { + "date": { + "type": "date" + } + } + }, + "settings": { + "index": { + "mapping": { + "total_fields": { + "limit": "50000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "5" + } + } + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c91f7b768a5c4e..f6df8fcbb64064 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -70,7 +70,6 @@ { "path": "./src/plugins/visualize/tsconfig.json" }, { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, { "path": "./src/plugins/index_pattern_field_editor/tsconfig.json" }, - { "path": "./x-pack/plugins/actions/tsconfig.json" }, { "path": "./x-pack/plugins/alerting/tsconfig.json" }, { "path": "./x-pack/plugins/apm/tsconfig.json" }, diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 3baf5c323ef81e..e08b50cc055c1c 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -105,6 +105,7 @@ { "path": "./x-pack/plugins/stack_alerts/tsconfig.json" }, { "path": "./x-pack/plugins/task_manager/tsconfig.json" }, { "path": "./x-pack/plugins/telemetry_collection_xpack/tsconfig.json" }, + { "path": "./x-pack/plugins/timelines/tsconfig.json" }, { "path": "./x-pack/plugins/transform/tsconfig.json" }, { "path": "./x-pack/plugins/translations/tsconfig.json" }, { "path": "./x-pack/plugins/triggers_actions_ui/tsconfig.json" }, diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.stories.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.stories.tsx new file mode 100644 index 00000000000000..8cc16dd801c25d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.stories.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ComponentType } from 'react'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; +import { + ApmPluginContext, + ApmPluginContextValue, +} from '../../../../context/apm_plugin/apm_plugin_context'; +import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; +import { ErrorDistribution } from './'; + +export default { + title: 'app/ErrorGroupDetails/Distribution', + component: ErrorDistribution, + decorators: [ + (Story: ComponentType) => { + const apmPluginContextMock = ({ + observabilityRuleTypeRegistry: { getFormatter: () => undefined }, + } as unknown) as ApmPluginContextValue; + + const kibanaContextServices = { + uiSettings: { get: () => {} }, + }; + + return ( + + + + + + + + ); + }, + ], +}; + +export function Example() { + const distribution = { + noHits: false, + bucketSize: 62350, + buckets: [ + { key: 1624279912350, count: 6 }, + { key: 1624279974700, count: 1 }, + { key: 1624280037050, count: 2 }, + { key: 1624280099400, count: 3 }, + { key: 1624280161750, count: 13 }, + { key: 1624280224100, count: 1 }, + { key: 1624280286450, count: 2 }, + { key: 1624280348800, count: 0 }, + { key: 1624280411150, count: 4 }, + { key: 1624280473500, count: 4 }, + { key: 1624280535850, count: 1 }, + { key: 1624280598200, count: 4 }, + { key: 1624280660550, count: 0 }, + { key: 1624280722900, count: 2 }, + { key: 1624280785250, count: 3 }, + { key: 1624280847600, count: 0 }, + ], + }; + + return ; +} + +export function EmptyState() { + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index 643653c24aeb3a..e53aaf97cdf757 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -67,6 +67,7 @@ export function ErrorDistribution({ distribution, title }: Props) { const xFormatter = niceTimeFormatter([xMin, xMax]); const { observabilityRuleTypeRegistry } = useApmPluginContext(); + const { alerts } = useApmServiceContext(); const { getFormatter } = observabilityRuleTypeRegistry; const [selectedAlertId, setSelectedAlertId] = useState( @@ -84,7 +85,7 @@ export function ErrorDistribution({ distribution, title }: Props) { }; return ( -
+ <> {title} @@ -124,7 +125,7 @@ export function ErrorDistribution({ distribution, title }: Props) { alerts: alerts?.filter( (alert) => alert[RULE_ID]?.[0] === AlertType.ErrorCount ), - chartStartTime: buckets[0].x0, + chartStartTime: buckets[0]?.x0, getFormatter, selectedAlertId, setSelectedAlertId, @@ -143,6 +144,6 @@ export function ErrorDistribution({ distribution, title }: Props) {
- + ); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx index 1b5a8084f5b594..d5bb525cfd3328 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx @@ -26,6 +26,8 @@ import { ApiLogsLogic } from '../index'; import { ApiLog } from '../types'; import { getStatusColor } from '../utils'; +import { EmptyState } from './'; + import './api_logs_table.scss'; interface Props { @@ -108,6 +110,7 @@ export const ApiLogsTable: React.FC = ({ hasPagination }) => { items={apiLogs} responsive loading={dataLoading} + noItemsMessage={} {...paginationProps} /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx index 3ad22ceac5840d..19f45ced5dc5dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx @@ -19,7 +19,7 @@ describe('EmptyState', () => { .find(EuiEmptyPrompt) .dive(); - expect(wrapper.find('h2').text()).toEqual('Perform your first API call'); + expect(wrapper.find('h2').text()).toEqual('No API events in the last 24 hours'); expect(wrapper.find(EuiButton).prop('href')).toEqual( expect.stringContaining('/api-reference.html') ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx index 3f6f44adefc71d..76bd0cba1731f8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx @@ -18,14 +18,14 @@ export const EmptyState: React.FC = () => ( title={

{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyTitle', { - defaultMessage: 'Perform your first API call', + defaultMessage: 'No API events in the last 24 hours', })}

} body={

{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyDescription', { - defaultMessage: "Check back after you've performed some API calls.", + defaultMessage: 'Logs will update in real-time when an API request occurs.', })}

} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx index a2993b4d86d5a1..91a0a7c5edcc0f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx @@ -7,29 +7,25 @@ import React from 'react'; -import { - EuiButton, - EuiLink, - EuiPageHeader, - EuiPanel, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; +import { EuiButton, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { getAppSearchUrl } from '../../../shared/enterprise_search_url'; import { DOCS_PREFIX, ENGINE_CRAWLER_PATH } from '../../routes'; -import { generateEnginePath } from '../engine'; +import { generateEnginePath, getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import './crawler_landing.scss'; import { CRAWLER_TITLE } from '.'; export const CrawlerLanding: React.FC = () => ( -
- - - + +

@@ -81,5 +77,5 @@ export const CrawlerLanding: React.FC = () => (

-
+ ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx index affc2fd08e34c8..3804ecfe7c67db 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx @@ -7,14 +7,12 @@ import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; import '../../../__mocks__/shallow_useeffect.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { Loading } from '../../../shared/loading'; -import { rerender } from '../../../test_helpers'; - import { DomainsTable } from './components/domains_table'; import { CrawlerOverview } from './crawler_overview'; @@ -50,11 +48,4 @@ describe('CrawlerOverview', () => { // TODO test for empty state after it is built in a future PR }); - - it('shows a loading state when data is loading', () => { - setMockValues({ dataLoading: true }); - rerender(wrapper); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx index 14906378692ed9..9e484df35e7a20 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx @@ -9,10 +9,8 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiPageHeader } from '@elastic/eui'; - -import { FlashMessages } from '../../../shared/flash_messages'; -import { Loading } from '../../../shared/loading'; +import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { DomainsTable } from './components/domains_table'; import { CRAWLER_TITLE } from './constants'; @@ -27,15 +25,13 @@ export const CrawlerOverview: React.FC = () => { fetchCrawlerData(); }, []); - if (dataLoading) { - return ; - } - return ( - <> - - + - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx index c11c6563330104..587ba61ce27e91 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx @@ -5,9 +5,6 @@ * 2.0. */ -import { setMockValues } from '../../../__mocks__/kea_logic'; -import { mockEngineValues } from '../../__mocks__'; - import React from 'react'; import { Switch } from 'react-router-dom'; @@ -22,7 +19,6 @@ describe('CrawlerRouter', () => { beforeEach(() => { jest.clearAllMocks(); - setMockValues({ ...mockEngineValues }); }); afterEach(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx index 926c45b4379377..a0145cf76908a0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx @@ -8,11 +8,6 @@ import React from 'react'; import { Route, Switch } from 'react-router-dom'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; - -import { getEngineBreadcrumbs } from '../engine'; - -import { CRAWLER_TITLE } from './constants'; import { CrawlerLanding } from './crawler_landing'; import { CrawlerOverview } from './crawler_overview'; @@ -20,7 +15,6 @@ export const CrawlerRouter: React.FC = () => { return ( - {process.env.NODE_ENV === 'development' ? : } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts index 37c1e9a7a1a2e6..c490910184a693 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/constants.ts @@ -18,7 +18,7 @@ export const CURATIONS_OVERVIEW_TITLE = i18n.translate( ); export const CREATE_NEW_CURATION_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.create.title', - { defaultMessage: 'Create new curation' } + { defaultMessage: 'Create a curation' } ); export const MANAGE_CURATION_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.manage.title', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx index 937acfd84ce83d..2efe1f2ffe86fc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.test.tsx @@ -8,16 +8,13 @@ import '../../../../__mocks__/shallow_useeffect.mock'; import { setMockActions, setMockValues } from '../../../../__mocks__/kea_logic'; import { mockUseParams } from '../../../../__mocks__/react_router'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; - -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { Loading } from '../../../../shared/loading'; -import { rerender } from '../../../../test_helpers'; +import { rerender, getPageTitle, getPageHeaderActions } from '../../../../test_helpers'; jest.mock('./curation_logic', () => ({ CurationLogic: jest.fn() })); import { CurationLogic } from './curation_logic'; @@ -27,9 +24,6 @@ import { AddResultFlyout } from './results'; import { Curation } from './'; describe('Curation', () => { - const props = { - curationsBreadcrumb: ['Engines', 'some-engine', 'Curations'], - }; const values = { dataLoading: false, queries: ['query A', 'query B'], @@ -47,39 +41,34 @@ describe('Curation', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); - expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Manage curation'); - expect(wrapper.find(SetPageChrome).prop('trail')).toEqual([ - ...props.curationsBreadcrumb, + expect(getPageTitle(wrapper)).toEqual('Manage curation'); + expect(wrapper.prop('pageChrome')).toEqual([ + 'Engines', + 'some-engine', + 'Curations', 'query A, query B', ]); }); - it('renders a loading component on page load', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders the add result flyout when open', () => { setMockValues({ ...values, isFlyoutOpen: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(AddResultFlyout)).toHaveLength(1); }); it('initializes CurationLogic with a curationId prop from URL param', () => { mockUseParams.mockReturnValueOnce({ curationId: 'hello-world' }); - shallow(); + shallow(); expect(CurationLogic).toHaveBeenCalledWith({ curationId: 'hello-world' }); }); it('calls loadCuration on page load & whenever the curationId URL param changes', () => { mockUseParams.mockReturnValueOnce({ curationId: 'cur-123456789' }); - const wrapper = shallow(); + const wrapper = shallow(); expect(actions.loadCuration).toHaveBeenCalledTimes(1); mockUseParams.mockReturnValueOnce({ curationId: 'cur-987654321' }); @@ -92,9 +81,8 @@ describe('Curation', () => { let confirmSpy: jest.SpyInstance; beforeAll(() => { - const wrapper = shallow(); - const headerActions = wrapper.find(EuiPageHeader).prop('rightSideItems'); - restoreDefaultsButton = shallow(headerActions![0] as React.ReactElement); + const wrapper = shallow(); + restoreDefaultsButton = getPageHeaderActions(wrapper).childAt(0); confirmSpy = jest.spyOn(window, 'confirm'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx index ffa9fd8422a1bc..2a01c0db049ab1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/curation.tsx @@ -10,26 +10,19 @@ import { useParams } from 'react-router-dom'; import { useValues, useActions } from 'kea'; -import { EuiPageHeader, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; - -import { FlashMessages } from '../../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; -import { Loading } from '../../../../shared/loading'; +import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../../constants'; +import { AppSearchPageTemplate } from '../../layout'; import { MANAGE_CURATION_TITLE, RESTORE_CONFIRMATION } from '../constants'; +import { getCurationsBreadcrumbs } from '../utils'; import { CurationLogic } from './curation_logic'; import { PromotedDocuments, OrganicDocuments, HiddenDocuments } from './documents'; import { ActiveQuerySelect, ManageQueriesModal } from './queries'; import { AddResultLogic, AddResultFlyout } from './results'; -interface Props { - curationsBreadcrumb: BreadcrumbTrail; -} - -export const Curation: React.FC = ({ curationsBreadcrumb }) => { +export const Curation: React.FC = () => { const { curationId } = useParams() as { curationId: string }; const { loadCuration, resetCuration } = useActions(CurationLogic({ curationId })); const { dataLoading, queries } = useValues(CurationLogic({ curationId })); @@ -39,14 +32,12 @@ export const Curation: React.FC = ({ curationsBreadcrumb }) => { loadCuration(); }, [curationId]); - if (dataLoading) return ; - return ( - <> - - { @@ -55,10 +46,10 @@ export const Curation: React.FC = ({ curationsBreadcrumb }) => { > {RESTORE_DEFAULTS_BUTTON_LABEL} , - ]} - responsive={false} - /> - + ], + }} + isLoading={dataLoading} + > @@ -69,7 +60,6 @@ export const Curation: React.FC = ({ curationsBreadcrumb }) => { - @@ -78,6 +68,6 @@ export const Curation: React.FC = ({ curationsBreadcrumb }) => { {isFlyoutOpen && } - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/hidden_documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/hidden_documents.tsx index f2bc416b00341d..8cb06f32d9e4ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/hidden_documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/documents/hidden_documents.tsx @@ -80,7 +80,7 @@ export const HiddenDocuments: React.FC = () => {

{i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.hiddenDocuments.emptyTitle', - { defaultMessage: 'No documents are being hidden for this query' } + { defaultMessage: "You haven't hidden any documents yet" } )}

} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx index 9598212d3e0c98..a241edb8020a4c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx @@ -19,6 +19,6 @@ describe('CurationsRouter', () => { const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(4); + expect(wrapper.find(Route)).toHaveLength(3); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx index 28ce311b438875..40f2d07ab61ab6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx @@ -8,38 +8,26 @@ import React from 'react'; import { Route, Switch } from 'react-router-dom'; -import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { NotFound } from '../../../shared/not_found'; import { ENGINE_CURATIONS_PATH, ENGINE_CURATIONS_NEW_PATH, ENGINE_CURATION_PATH, } from '../../routes'; -import { getEngineBreadcrumbs } from '../engine'; -import { CURATIONS_TITLE, CREATE_NEW_CURATION_TITLE } from './constants'; import { Curation } from './curation'; import { Curations, CurationCreation } from './views'; export const CurationsRouter: React.FC = () => { - const CURATIONS_BREADCRUMB = getEngineBreadcrumbs([CURATIONS_TITLE]); - return ( - - - - - - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/utils.test.ts index 51618ed4e37419..02641b09255e53 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/utils.test.ts @@ -5,7 +5,21 @@ * 2.0. */ -import { convertToDate, addDocument, removeDocument } from './utils'; +import '../../__mocks__/engine_logic.mock'; + +import { getCurationsBreadcrumbs, convertToDate, addDocument, removeDocument } from './utils'; + +describe('getCurationsBreadcrumbs', () => { + it('generates curation-prefixed breadcrumbs', () => { + expect(getCurationsBreadcrumbs()).toEqual(['Engines', 'some-engine', 'Curations']); + expect(getCurationsBreadcrumbs(['Some page'])).toEqual([ + 'Engines', + 'some-engine', + 'Curations', + 'Some page', + ]); + }); +}); describe('convertToDate', () => { it('converts the English-only server timestamps to a parseable Date', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/utils.ts index 8af2636128304b..978b63885fbdd3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/utils.ts @@ -5,6 +5,14 @@ * 2.0. */ +import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; +import { getEngineBreadcrumbs } from '../engine'; + +import { CURATIONS_TITLE } from './constants'; + +export const getCurationsBreadcrumbs = (breadcrumbs: BreadcrumbTrail = []) => + getEngineBreadcrumbs([CURATIONS_TITLE, ...breadcrumbs]); + // The server API feels us an English datestring, but we want to convert // it to an actual Date() instance so that we can localize date formats. export const convertToDate = (serverDateString: string): Date => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx index ad306dfc730802..33aab9943cc832 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx @@ -6,6 +6,7 @@ */ import { setMockActions } from '../../../../__mocks__/kea_logic'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx index 32d46775a2125e..9aa1759cec5c07 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx @@ -9,10 +9,10 @@ import React from 'react'; import { useActions } from 'kea'; -import { EuiPageHeader, EuiPageContent, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import { EuiPanel, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../../shared/flash_messages'; +import { AppSearchPageTemplate } from '../../layout'; import { MultiInputRows } from '../../multi_input_rows'; import { @@ -21,15 +21,17 @@ import { QUERY_INPUTS_PLACEHOLDER, } from '../constants'; import { CurationsLogic } from '../index'; +import { getCurationsBreadcrumbs } from '../utils'; export const CurationCreation: React.FC = () => { const { createCuration } = useActions(CurationsLogic); return ( - <> - - - + +

{i18n.translate( @@ -56,7 +58,7 @@ export const CurationCreation: React.FC = () => { inputPlaceholder={QUERY_INPUTS_PLACEHOLDER} onSubmit={(queries) => createCuration(queries)} /> - - + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx index bcc402d6eea273..85827d53741793 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx @@ -6,17 +6,16 @@ */ import { mockKibanaValues, setMockActions, setMockValues } from '../../../../__mocks__/kea_logic'; +import '../../../../__mocks__/react_router'; import '../../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow, ReactWrapper } from 'enzyme'; -import { EuiPageHeader, EuiBasicTable } from '@elastic/eui'; +import { EuiBasicTable } from '@elastic/eui'; -import { Loading } from '../../../../shared/loading'; -import { mountWithIntl } from '../../../../test_helpers'; -import { EmptyState } from '../components'; +import { mountWithIntl, getPageTitle } from '../../../../test_helpers'; import { Curations, CurationsTable } from './curations'; @@ -61,32 +60,34 @@ describe('Curations', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Curated results'); + expect(getPageTitle(wrapper)).toEqual('Curated results'); expect(wrapper.find(CurationsTable)).toHaveLength(1); }); - it('renders a loading component on page load', () => { - setMockValues({ ...values, dataLoading: true, curations: [] }); - const wrapper = shallow(); + describe('loading state', () => { + it('renders a full-page loading state on initial page load', () => { + setMockValues({ ...values, dataLoading: true, curations: [] }); + const wrapper = shallow(); + + expect(wrapper.prop('isLoading')).toEqual(true); + }); + + it('does not re-render a full-page loading state after initial page load (uses component-level loading state instead)', () => { + setMockValues({ ...values, dataLoading: true, curations: [{}] }); + const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(false); + }); }); it('calls loadCurations on page load', () => { + setMockValues({ ...values, myRole: {} }); // Required for AppSearchPageTemplate to load mountWithIntl(); expect(actions.loadCurations).toHaveBeenCalledTimes(1); }); describe('CurationsTable', () => { - it('renders an empty state', () => { - setMockValues({ ...values, curations: [] }); - const table = shallow().find(EuiBasicTable); - const noItemsMessage = table.prop('noItemsMessage') as React.ReactElement; - - expect(noItemsMessage.type).toEqual(EmptyState); - }); - it('passes loading prop based on dataLoading', () => { setMockValues({ ...values, dataLoading: true }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx index 80de9aba772585..12497ab52baf6e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx @@ -9,25 +9,24 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { EuiBasicTable, EuiBasicTableColumn, EuiPageContent, EuiPageHeader } from '@elastic/eui'; +import { EuiBasicTable, EuiBasicTableColumn, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EDIT_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../../../../shared/constants'; -import { FlashMessages } from '../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../shared/kibana'; -import { Loading } from '../../../../shared/loading'; import { EuiButtonTo, EuiLinkTo } from '../../../../shared/react_router_helpers'; import { convertMetaToPagination, handlePageChange } from '../../../../shared/table_pagination'; import { ENGINE_CURATIONS_NEW_PATH, ENGINE_CURATION_PATH } from '../../../routes'; import { FormattedDateTime } from '../../../utils/formatted_date_time'; import { generateEnginePath } from '../../engine'; +import { AppSearchPageTemplate } from '../../layout'; import { EmptyState } from '../components'; import { CURATIONS_OVERVIEW_TITLE, CREATE_NEW_CURATION_TITLE } from '../constants'; import { CurationsLogic } from '../curations_logic'; import { Curation } from '../types'; -import { convertToDate } from '../utils'; +import { getCurationsBreadcrumbs, convertToDate } from '../utils'; export const Curations: React.FC = () => { const { dataLoading, curations, meta } = useValues(CurationsLogic); @@ -37,23 +36,29 @@ export const Curations: React.FC = () => { loadCurations(); }, [meta.page.current]); - if (dataLoading && !curations.length) return ; - return ( - <> - + {CREATE_NEW_CURATION_TITLE} , - ]} - /> - - + ], + }} + isLoading={dataLoading && !curations.length} + isEmptyState={!curations.length} + emptyState={} + > + - - + + ); }; @@ -139,7 +144,6 @@ export const CurationsTable: React.FC = () => { responsive hasActions loading={dataLoading} - noItemsMessage={} pagination={{ ...convertMetaToPagination(meta), hidePerPageOptions: true, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 91a21847107a95..0f42483f44e0c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -13,9 +13,7 @@ import { useValues, useActions } from 'kea'; import { i18n } from '@kbn/i18n'; import { setQueuedErrorMessage } from '../../../shared/flash_messages'; -import { Layout } from '../../../shared/layout'; import { AppLogic } from '../../app_logic'; -import { AppSearchNav } from '../../index'; import { ENGINE_PATH, @@ -114,6 +112,36 @@ export const EngineRouter: React.FC = () => { )} + {canViewMetaEngineSourceEngines && ( + + + + )} + {canViewEngineCrawler && ( + + + + )} + {canManageEngineRelevanceTuning && ( + + + + )} + {canManageEngineSynonyms && ( + + + + )} + {canManageEngineCurations && ( + + + + )} + {canManageEngineResultSettings && ( + + + + )} {canManageEngineSearchUi && ( @@ -124,39 +152,6 @@ export const EngineRouter: React.FC = () => { )} - {/* TODO: Remove layout once page template migration is over */} - }> - {canManageEngineCurations && ( - - - - )} - {canManageEngineRelevanceTuning && ( - - - - )} - {canManageEngineSynonyms && ( - - - - )} - {canManageEngineResultSettings && ( - - - - )} - {canViewMetaEngineSourceEngines && ( - - - - )} - {canViewEngineCrawler && ( - - - - )} - ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx index e6a14d7b5cd725..df29010bd682ff 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx @@ -7,42 +7,40 @@ import React from 'react'; -import { EuiButton, EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DOCS_PREFIX } from '../../../routes'; export const EmptyState: React.FC = () => ( - - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.title', { - defaultMessage: 'Add documents to tune relevance', - })} -

+ + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.title', { + defaultMessage: 'Add documents to tune relevance', + })} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.description', + { + defaultMessage: + 'A schema will be automatically created for you after you index some documents.', } - body={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.description', - { - defaultMessage: - 'A schema will be automatically created for you after you index some documents.', - } - )} - actions={ - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.buttonLabel', - { defaultMessage: 'Read the relevance tuning guide' } - )} - - } - /> -
+ )} + actions={ + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.buttonLabel', + { defaultMessage: 'Read the relevance tuning guide' } + )} + + } + /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx index 092740ac5d3cc6..48b536a954ed59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx @@ -13,14 +13,14 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt } from '@elastic/eui'; - -import { Loading } from '../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; +import { getPageHeaderActions } from '../../../test_helpers'; -import { EmptyState } from './components'; import { RelevanceTuning } from './relevance_tuning'; + +import { RelevanceTuningCallouts } from './relevance_tuning_callouts'; import { RelevanceTuningForm } from './relevance_tuning_form'; +import { RelevanceTuningPreview } from './relevance_tuning_preview'; describe('RelevanceTuning', () => { const values = { @@ -50,9 +50,9 @@ describe('RelevanceTuning', () => { it('renders', () => { const wrapper = subject(); + expect(wrapper.find(RelevanceTuningCallouts).exists()).toBe(true); expect(wrapper.find(RelevanceTuningForm).exists()).toBe(true); - expect(wrapper.find(Loading).exists()).toBe(false); - expect(wrapper.find(EmptyState).exists()).toBe(false); + expect(wrapper.find(RelevanceTuningPreview).exists()).toBe(true); }); it('initializes relevance tuning data', () => { @@ -60,33 +60,38 @@ describe('RelevanceTuning', () => { expect(actions.initializeRelevanceTuning).toHaveBeenCalled(); }); - it('will render an empty message when the engine has no schema', () => { + it('will prevent user from leaving the page if there are unsaved changes', () => { setMockValues({ ...values, - engineHasSchemaFields: false, + unsavedChanges: true, }); - const wrapper = subject(); - expect(wrapper.find(EmptyState).dive().find(EuiEmptyPrompt).exists()).toBe(true); - expect(wrapper.find(Loading).exists()).toBe(false); - expect(wrapper.find(RelevanceTuningForm).exists()).toBe(false); + expect(subject().find(UnsavedChangesPrompt).prop('hasUnsavedChanges')).toBe(true); }); - it('will show a loading message if data is loading', () => { - setMockValues({ - ...values, - dataLoading: true, + describe('header actions', () => { + it('renders a Save button that will save the current changes', () => { + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(2); + const saveButton = buttons.find('[data-test-subj="SaveRelevanceTuning"]'); + saveButton.simulate('click'); + expect(actions.updateSearchSettings).toHaveBeenCalled(); }); - const wrapper = subject(); - expect(wrapper.find(Loading).exists()).toBe(true); - expect(wrapper.find(EmptyState).exists()).toBe(false); - expect(wrapper.find(RelevanceTuningForm).exists()).toBe(false); - }); - it('will prevent user from leaving the page if there are unsaved changes', () => { - setMockValues({ - ...values, - unsavedChanges: true, + it('renders a Reset button that will remove all weights and boosts', () => { + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(2); + const resetButton = buttons.find('[data-test-subj="ResetRelevanceTuning"]'); + resetButton.simulate('click'); + expect(actions.resetSearchSettings).toHaveBeenCalled(); + }); + + it('will not render buttons if the engine has no schema', () => { + setMockValues({ + ...values, + engineHasSchemaFields: false, + }); + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(0); }); - expect(subject().find(UnsavedChangesPrompt).prop('hasUnsavedChanges')).toBe(true); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx index b98541a9638901..2e87d6836199bd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx @@ -9,43 +9,77 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; -import { Loading } from '../../../shared/loading'; +import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; +import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; +import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { EmptyState } from './components'; +import { RELEVANCE_TUNING_TITLE } from './constants'; +import { RelevanceTuningCallouts } from './relevance_tuning_callouts'; import { RelevanceTuningForm } from './relevance_tuning_form'; -import { RelevanceTuningLayout } from './relevance_tuning_layout'; import { RelevanceTuningPreview } from './relevance_tuning_preview'; import { RelevanceTuningLogic } from '.'; export const RelevanceTuning: React.FC = () => { const { dataLoading, engineHasSchemaFields, unsavedChanges } = useValues(RelevanceTuningLogic); - const { initializeRelevanceTuning } = useActions(RelevanceTuningLogic); + const { initializeRelevanceTuning, resetSearchSettings, updateSearchSettings } = useActions( + RelevanceTuningLogic + ); useEffect(() => { initializeRelevanceTuning(); }, []); - if (dataLoading) return ; - return ( - + + {SAVE_BUTTON_LABEL} + , + + {RESTORE_DEFAULTS_BUTTON_LABEL} + , + ] + : [], + }} + isLoading={dataLoading} + isEmptyState={!engineHasSchemaFields} + emptyState={} + > - {engineHasSchemaFields ? ( - - - - - - - - - ) : ( - - )} - + + + + + + + + + + +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.tsx index 5cbd291f85debf..c35cd280c7a058 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.tsx @@ -42,7 +42,7 @@ export const RelevanceTuningForm: React.FC = () => { return (
- +

{i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx deleted file mode 100644 index 20b1a16879234e..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; -import '../../__mocks__/engine_logic.mock'; - -import React from 'react'; - -import { shallow, ShallowWrapper } from 'enzyme'; - -import { EuiPageHeader } from '@elastic/eui'; - -import { RelevanceTuningLayout } from './relevance_tuning_layout'; - -describe('RelevanceTuningLayout', () => { - const values = { - engineHasSchemaFields: true, - schemaFieldsWithConflicts: [], - }; - - const actions = { - updateSearchSettings: jest.fn(), - resetSearchSettings: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - setMockValues(values); - setMockActions(actions); - }); - - const subject = () => shallow(); - const findButtons = (wrapper: ShallowWrapper) => - wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; - - it('renders a Save button that will save the current changes', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(2); - const saveButton = shallow(buttons[0]); - saveButton.simulate('click'); - expect(actions.updateSearchSettings).toHaveBeenCalled(); - }); - - it('renders a Reset button that will remove all weights and boosts', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(2); - const resetButton = shallow(buttons[1]); - resetButton.simulate('click'); - expect(actions.resetSearchSettings).toHaveBeenCalled(); - }); - - it('will not render buttons if the engine has no schema', () => { - setMockValues({ - ...values, - engineHasSchemaFields: false, - }); - const buttons = findButtons(subject()); - expect(buttons.length).toBe(0); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx deleted file mode 100644 index 4fa694300a7795..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { useActions, useValues } from 'kea'; - -import { EuiPageHeader, EuiButton } from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; -import { getEngineBreadcrumbs } from '../engine'; - -import { RELEVANCE_TUNING_TITLE } from './constants'; -import { RelevanceTuningCallouts } from './relevance_tuning_callouts'; -import { RelevanceTuningLogic } from './relevance_tuning_logic'; - -export const RelevanceTuningLayout: React.FC = ({ children }) => { - const { resetSearchSettings, updateSearchSettings } = useActions(RelevanceTuningLogic); - const { engineHasSchemaFields } = useValues(RelevanceTuningLogic); - - const pageHeader = () => ( - - {SAVE_BUTTON_LABEL} - , - - {RESTORE_DEFAULTS_BUTTON_LABEL} - , - ] - : [] - } - /> - ); - - return ( - <> - - {pageHeader()} - - - {children} - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_preview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_preview.tsx index 911e97de5b53f5..4f3b20b419e802 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_preview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_preview.tsx @@ -21,6 +21,7 @@ import { RelevanceTuningLogic } from '.'; const emptyCallout = ( ( - - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.title', { - defaultMessage: 'Add documents to adjust settings', - })} -

+ + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.title', { + defaultMessage: 'Add documents to adjust settings', + })} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.description', + { + defaultMessage: + 'A schema will be automatically created for you after you index some documents.', } - body={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.description', - { - defaultMessage: - 'A schema will be automatically created for you after you index some documents.', - } - )} - actions={ - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.buttonLabel', - { defaultMessage: 'Read the result settings guide' } - )} - - } - /> - + )} + actions={ + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.buttonLabel', + { defaultMessage: 'Read the result settings guide' } + )} + + } + /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx index ec521b4959535c..440acaf136ddac 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx @@ -13,11 +13,9 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; - import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; +import { getPageHeaderActions } from '../../../test_helpers'; -import { EmptyState } from './components'; import { ResultSettings } from './result_settings'; import { ResultSettingsTable } from './result_settings_table'; import { SampleResponse } from './sample_response'; @@ -46,8 +44,6 @@ describe('ResultSettings', () => { }); const subject = () => shallow(); - const findButtons = (wrapper: ShallowWrapper) => - wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; it('renders', () => { const wrapper = subject(); @@ -60,19 +56,10 @@ describe('ResultSettings', () => { expect(actions.initializeResultSettingsData).toHaveBeenCalled(); }); - it('renders a loading screen if data has not loaded yet', () => { - setMockValues({ - dataLoading: true, - }); - const wrapper = subject(); - expect(wrapper.find(ResultSettingsTable).exists()).toBe(false); - expect(wrapper.find(SampleResponse).exists()).toBe(false); - }); - it('renders a "save" button that will save the current changes', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(3); - const saveButton = shallow(buttons[0]); + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(3); + const saveButton = buttons.find('[data-test-subj="SaveResultSettings"]'); saveButton.simulate('click'); expect(actions.saveResultSettings).toHaveBeenCalled(); }); @@ -82,8 +69,8 @@ describe('ResultSettings', () => { ...values, stagedUpdates: false, }); - const buttons = findButtons(subject()); - const saveButton = shallow(buttons[0]); + const buttons = getPageHeaderActions(subject()); + const saveButton = buttons.find('[data-test-subj="SaveResultSettings"]'); expect(saveButton.prop('disabled')).toBe(true); }); @@ -93,15 +80,15 @@ describe('ResultSettings', () => { stagedUpdates: true, resultFieldsEmpty: true, }); - const buttons = findButtons(subject()); - const saveButton = shallow(buttons[0]); + const buttons = getPageHeaderActions(subject()); + const saveButton = buttons.find('[data-test-subj="SaveResultSettings"]'); expect(saveButton.prop('disabled')).toBe(true); }); it('renders a "restore defaults" button that will reset all values to their defaults', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(3); - const resetButton = shallow(buttons[1]); + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(3); + const resetButton = buttons.find('[data-test-subj="ResetResultSettings"]'); resetButton.simulate('click'); expect(actions.confirmResetAllFields).toHaveBeenCalled(); }); @@ -111,15 +98,15 @@ describe('ResultSettings', () => { ...values, resultFieldsAtDefaultSettings: true, }); - const buttons = findButtons(subject()); - const resetButton = shallow(buttons[1]); + const buttons = getPageHeaderActions(subject()); + const resetButton = buttons.find('[data-test-subj="ResetResultSettings"]'); expect(resetButton.prop('disabled')).toBe(true); }); it('renders a "clear" button that will remove all selected options', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(3); - const clearButton = shallow(buttons[2]); + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(3); + const clearButton = buttons.find('[data-test-subj="ClearResultSettings"]'); clearButton.simulate('click'); expect(actions.clearAllFields).toHaveBeenCalled(); }); @@ -143,17 +130,12 @@ describe('ResultSettings', () => { }); it('will not render action buttons', () => { - const buttons = findButtons(wrapper); - expect(buttons.length).toBe(0); - }); - - it('will not render the main page content', () => { - expect(wrapper.find(ResultSettingsTable).exists()).toBe(false); - expect(wrapper.find(SampleResponse).exists()).toBe(false); + const buttons = getPageHeaderActions(wrapper); + expect(buttons.children().length).toBe(0); }); it('will render an empty state', () => { - expect(wrapper.find(EmptyState).exists()).toBe(true); + expect(wrapper.prop('isEmptyState')).toBe(true); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx index 45cb9ea1cfcb4c..c315927433a0a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx @@ -9,17 +9,15 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiPageHeader, EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { EmptyState } from './components'; import { RESULT_SETTINGS_TITLE } from './constants'; @@ -57,59 +55,56 @@ export const ResultSettings: React.FC = () => { initializeResultSettingsData(); }, []); - if (dataLoading) return ; const hasSchema = Object.keys(schema).length > 0; return ( - <> - - - - {SAVE_BUTTON_LABEL} - , - - {RESTORE_DEFAULTS_BUTTON_LABEL} - , - - {CLEAR_BUTTON_LABEL} - , - ] - : [] - } - /> - - {hasSchema ? ( - - - - - - - - - ) : ( - - )} - + ), + rightSideItems: hasSchema + ? [ + + {SAVE_BUTTON_LABEL} + , + + {RESTORE_DEFAULTS_BUTTON_LABEL} + , + + {CLEAR_BUTTON_LABEL} + , + ] + : [], + }} + isLoading={dataLoading} + isEmptyState={!hasSchema} + emptyState={} + > + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/components/add_source_engines_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/components/add_source_engines_button.tsx index 004217d88987bd..3076e14d6329b5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/components/add_source_engines_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/components/add_source_engines_button.tsx @@ -18,7 +18,7 @@ export const AddSourceEnginesButton: React.FC = () => { const { openModal } = useActions(SourceEnginesLogic); return ( - + {ADD_SOURCE_ENGINES_BUTTON_LABEL} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx index 9d2fe653150c33..e2398209e630d0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.test.tsx @@ -11,11 +11,9 @@ import '../../__mocks__/engine_logic.mock'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; - -import { Loading } from '../../../shared/loading'; +import { getPageHeaderActions } from '../../../test_helpers'; import { AddSourceEnginesButton, AddSourceEnginesModal, SourceEnginesTable } from './components'; @@ -61,20 +59,10 @@ describe('SourceEngines', () => { expect(wrapper.find(AddSourceEnginesModal)).toHaveLength(1); }); - it('renders a loading component before data has loaded', () => { - setMockValues({ ...MOCK_VALUES, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - describe('page actions', () => { - const getPageHeader = (wrapper: ShallowWrapper) => - wrapper.find(EuiPageHeader).dive().children().dive(); - it('contains a button to add source engines', () => { const wrapper = shallow(); - expect(getPageHeader(wrapper).find(AddSourceEnginesButton)).toHaveLength(1); + expect(getPageHeaderActions(wrapper).find(AddSourceEnginesButton)).toHaveLength(1); }); it('hides the add source engines button if the user does not have permissions', () => { @@ -86,7 +74,7 @@ describe('SourceEngines', () => { }); const wrapper = shallow(); - expect(getPageHeader(wrapper).find(AddSourceEnginesButton)).toHaveLength(0); + expect(getPageHeaderActions(wrapper).find(AddSourceEnginesButton)).toHaveLength(0); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.tsx index 190c44c9190204..d2476faf4f3f50 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/source_engines/source_engines.tsx @@ -9,13 +9,11 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiPageHeader, EuiPageContent } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; import { AppLogic } from '../../app_logic'; import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { AddSourceEnginesButton, AddSourceEnginesModal, SourceEnginesTable } from './components'; import { SOURCE_ENGINES_TITLE } from './i18n'; @@ -33,20 +31,19 @@ export const SourceEngines: React.FC = () => { fetchSourceEngines(); }, []); - if (dataLoading) return ; - return ( - <> - - ] : []} - /> - - + ] : [], + }} + isLoading={dataLoading} + > + {isModalOpen && } - - + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx index f1382bb5972b21..a43f170e5822f8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx @@ -11,7 +11,7 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; -import { EmptyState } from './'; +import { EmptyState, SynonymModal } from './'; describe('EmptyState', () => { it('renders', () => { @@ -24,4 +24,10 @@ describe('EmptyState', () => { expect.stringContaining('/synonyms-guide.html') ); }); + + it('renders the add synonym modal', () => { + const wrapper = shallow(); + + expect(wrapper.find(SynonymModal)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx index 2eb6643bda5032..f856a5c035f811 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx @@ -7,16 +7,16 @@ import React from 'react'; -import { EuiPanel, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DOCS_PREFIX } from '../../../routes'; -import { SynonymIcon } from './'; +import { SynonymModal, SynonymIcon } from './'; export const EmptyState: React.FC = () => { return ( - + <> { } /> - + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx index c8f65c4bdbc6c4..64ac3066b51a51 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx @@ -13,12 +13,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader, EuiButton, EuiPagination } from '@elastic/eui'; +import { EuiButton, EuiPagination } from '@elastic/eui'; -import { Loading } from '../../../shared/loading'; -import { rerender } from '../../../test_helpers'; +import { rerender, getPageHeaderActions } from '../../../test_helpers'; -import { SynonymCard, SynonymModal, EmptyState } from './components'; +import { SynonymCard, SynonymModal } from './components'; import { Synonyms } from './'; @@ -53,21 +52,9 @@ describe('Synonyms', () => { }); it('renders a create action button', () => { - const wrapper = shallow() - .find(EuiPageHeader) - .dive() - .children() - .dive(); - - wrapper.find(EuiButton).simulate('click'); - expect(actions.openModal).toHaveBeenCalled(); - }); - - it('renders an empty state if no synonyms exist', () => { - setMockValues({ ...values, synonymSets: [] }); const wrapper = shallow(); - - expect(wrapper.find(EmptyState)).toHaveLength(1); + getPageHeaderActions(wrapper).find(EuiButton).simulate('click'); + expect(actions.openModal).toHaveBeenCalled(); }); describe('loading', () => { @@ -75,14 +62,14 @@ describe('Synonyms', () => { setMockValues({ ...values, synonymSets: [], dataLoading: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(true); }); it('does not render a full loading state after initial page load', () => { setMockValues({ ...values, synonymSets: [MOCK_SYNONYM_SET], dataLoading: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(0); + expect(wrapper.prop('isLoading')).toEqual(false); }); }); @@ -108,7 +95,7 @@ describe('Synonyms', () => { const wrapper = shallow(); expect(actions.onPaginate).not.toHaveBeenCalled(); - expect(wrapper.find(EmptyState)).toHaveLength(1); + expect(wrapper.prop('isEmptyState')).toEqual(true); }); it('handles off-by-one shenanigans between EuiPagination and our API', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx index d3ba53819f7de2..4a68bc381f7641 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx @@ -9,21 +9,11 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { - EuiPageHeader, - EuiButton, - EuiPageContentBody, - EuiSpacer, - EuiFlexGrid, - EuiFlexItem, - EuiPagination, -} from '@elastic/eui'; +import { EuiButton, EuiSpacer, EuiFlexGrid, EuiFlexItem, EuiPagination } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { SynonymCard, SynonymModal, EmptyState } from './components'; import { SYNONYMS_TITLE } from './constants'; @@ -46,46 +36,45 @@ export const Synonyms: React.FC = () => { } }, [synonymSets]); - if (dataLoading && !hasSynonyms) return ; - return ( - <> - - openModal(null)}> + openModal(null)}> {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.synonyms.createSynonymSetButtonLabel', { defaultMessage: 'Create a synonym set' } )} , - ]} + ], + }} + isLoading={dataLoading && !hasSynonyms} + isEmptyState={!hasSynonyms} + emptyState={} + > + + {synonymSets.map(({ id, synonyms }) => ( + + + + ))} + + + onPaginate(pageIndex + 1)} /> - - - - {hasSynonyms ? ( - <> - - {synonymSets.map(({ id, synonyms }) => ( - - - - ))} - - - onPaginate(pageIndex + 1)} - /> - - ) : ( - - )} - - - + + ); }; diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index aece6580831960..c4441fb6e0d95b 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; // Follow pattern from https://github.com/elastic/kibana/pull/52447 // TODO: Update when https://github.com/elastic/kibana/issues/53021 is closed import type { SavedObject, SavedObjectAttributes, SavedObjectReference } from 'src/core/public'; @@ -299,8 +300,8 @@ export interface RegistryDataStream { } export interface RegistryElasticsearch { - 'index_template.settings'?: object; - 'index_template.mappings'?: object; + 'index_template.settings'?: estypes.IndicesIndexSettings; + 'index_template.mappings'?: estypes.MappingTypeMapping; } export interface RegistryDataStreamPermissions { @@ -425,7 +426,7 @@ export interface IndexTemplate { _meta: object; } -export interface TemplateRef { +export interface IndexTemplateEntry { templateName: string; indexTemplate: IndexTemplate; } diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index d202dab54f5bdc..db1fba1eedccde 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -11,7 +11,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/s import { ElasticsearchAssetType } from '../../../../types'; import type { RegistryDataStream, - TemplateRef, + IndexTemplateEntry, RegistryElasticsearch, InstallablePackage, } from '../../../../types'; @@ -19,7 +19,7 @@ import { loadFieldsFromYaml, processFields } from '../../fields/field'; import type { Field } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; import { getAsset, getPathParts } from '../../archive'; -import { removeAssetsFromInstalledEsByType, saveInstalledEsRefs } from '../../packages/install'; +import { removeAssetTypesFromInstalledEs, saveInstalledEsRefs } from '../../packages/install'; import { generateMappings, @@ -34,7 +34,7 @@ export const installTemplates = async ( esClient: ElasticsearchClient, paths: string[], savedObjectsClient: SavedObjectsClientContract -): Promise => { +): Promise => { // install any pre-built index template assets, // atm, this is only the base package's global index templates // Install component templates first, as they are used by the index templates @@ -42,44 +42,36 @@ export const installTemplates = async ( await installPreBuiltTemplates(paths, esClient); // remove package installation's references to index templates - await removeAssetsFromInstalledEsByType( - savedObjectsClient, - installablePackage.name, - ElasticsearchAssetType.indexTemplate - ); + await removeAssetTypesFromInstalledEs(savedObjectsClient, installablePackage.name, [ + ElasticsearchAssetType.indexTemplate, + ElasticsearchAssetType.componentTemplate, + ]); // build templates per data stream from yml files const dataStreams = installablePackage.data_streams; if (!dataStreams) return []; + + const installedTemplatesNested = await Promise.all( + dataStreams.map((dataStream) => + installTemplateForDataStream({ + pkg: installablePackage, + esClient, + dataStream, + }) + ) + ); + const installedTemplates = installedTemplatesNested.flat(); + // get template refs to save - const installedTemplateRefs = dataStreams.map((dataStream) => ({ - id: generateTemplateName(dataStream), - type: ElasticsearchAssetType.indexTemplate, - })); + const installedIndexTemplateRefs = getAllTemplateRefs(installedTemplates); // add package installation's references to index templates - await saveInstalledEsRefs(savedObjectsClient, installablePackage.name, installedTemplateRefs); - - if (dataStreams) { - const installTemplatePromises = dataStreams.reduce>>( - (acc, dataStream) => { - acc.push( - installTemplateForDataStream({ - pkg: installablePackage, - esClient, - dataStream, - }) - ); - return acc; - }, - [] - ); - - const res = await Promise.all(installTemplatePromises); - const installedTemplates = res.flat(); + await saveInstalledEsRefs( + savedObjectsClient, + installablePackage.name, + installedIndexTemplateRefs + ); - return installedTemplates; - } - return []; + return installedTemplates; }; const installPreBuiltTemplates = async (paths: string[], esClient: ElasticsearchClient) => { @@ -160,7 +152,7 @@ export async function installTemplateForDataStream({ pkg: InstallablePackage; esClient: ElasticsearchClient; dataStream: RegistryDataStream; -}): Promise { +}): Promise { const fields = await loadFieldsFromYaml(pkg, dataStream.path); return installTemplate({ esClient, @@ -171,84 +163,118 @@ export async function installTemplateForDataStream({ }); } +interface TemplateMapEntry { + _meta: { package: { name: string } }; + template: + | { + mappings: NonNullable; + } + | { + settings: NonNullable | object; + }; +} +type TemplateMap = Record; function putComponentTemplate( - body: object | undefined, - name: string, - esClient: ElasticsearchClient -): { clusterPromise: Promise; name: string } | undefined { - if (body) { - const esClientParams = { - name, - body, - }; - - return { - // @ts-expect-error body expected to be ClusterPutComponentTemplateRequest - clusterPromise: esClient.cluster.putComponentTemplate(esClientParams, { ignore: [404] }), - name, - }; + esClient: ElasticsearchClient, + params: { + body: TemplateMapEntry; + name: string; + create?: boolean; } +): { clusterPromise: Promise; name: string } { + const { name, body, create = false } = params; + return { + clusterPromise: esClient.cluster.putComponentTemplate( + // @ts-expect-error body is missing required key `settings`. TemplateMapEntry has settings *or* mappings + { name, body, create }, + { ignore: [404] } + ), + name, + }; } -function buildComponentTemplates(registryElasticsearch: RegistryElasticsearch | undefined) { - let mappingsTemplate; - let settingsTemplate; +const mappingsSuffix = '@mappings'; +const settingsSuffix = '@settings'; +const userSettingsSuffix = '@custom'; +type TemplateBaseName = string; +type UserSettingsTemplateName = `${TemplateBaseName}${typeof userSettingsSuffix}`; + +const isUserSettingsTemplate = (name: string): name is UserSettingsTemplateName => + name.endsWith(userSettingsSuffix); + +function buildComponentTemplates(params: { + templateName: string; + registryElasticsearch: RegistryElasticsearch | undefined; + packageName: string; +}) { + const { templateName, registryElasticsearch, packageName } = params; + const mappingsTemplateName = `${templateName}${mappingsSuffix}`; + const settingsTemplateName = `${templateName}${settingsSuffix}`; + const userSettingsTemplateName = `${templateName}${userSettingsSuffix}`; + + const templatesMap: TemplateMap = {}; + const _meta = { package: { name: packageName } }; if (registryElasticsearch && registryElasticsearch['index_template.mappings']) { - mappingsTemplate = { + templatesMap[mappingsTemplateName] = { template: { - mappings: { - ...registryElasticsearch['index_template.mappings'], - }, + mappings: registryElasticsearch['index_template.mappings'], }, + _meta, }; } if (registryElasticsearch && registryElasticsearch['index_template.settings']) { - settingsTemplate = { + templatesMap[settingsTemplateName] = { template: { settings: registryElasticsearch['index_template.settings'], }, + _meta, }; } - return { settingsTemplate, mappingsTemplate }; -} -async function installDataStreamComponentTemplates( - templateName: string, - registryElasticsearch: RegistryElasticsearch | undefined, - esClient: ElasticsearchClient -) { - const templates: string[] = []; - const componentPromises: Array> = []; + // return empty/stub template + templatesMap[userSettingsTemplateName] = { + template: { + settings: {}, + }, + _meta, + }; - const compTemplates = buildComponentTemplates(registryElasticsearch); + return templatesMap; +} - const mappings = putComponentTemplate( - compTemplates.mappingsTemplate, - `${templateName}-mappings`, - esClient - ); +async function installDataStreamComponentTemplates(params: { + templateName: string; + registryElasticsearch: RegistryElasticsearch | undefined; + esClient: ElasticsearchClient; + packageName: string; +}) { + const { templateName, registryElasticsearch, esClient, packageName } = params; + const templates = buildComponentTemplates({ templateName, registryElasticsearch, packageName }); + const templateNames = Object.keys(templates); + const templateEntries = Object.entries(templates); - const settings = putComponentTemplate( - compTemplates.settingsTemplate, - `${templateName}-settings`, - esClient + // TODO: Check return values for errors + await Promise.all( + templateEntries.map(async ([name, body]) => { + if (isUserSettingsTemplate(name)) { + // look for existing user_settings template + const result = await esClient.cluster.getComponentTemplate({ name }, { ignore: [404] }); + const hasUserSettingsTemplate = result.body.component_templates?.length === 1; + if (!hasUserSettingsTemplate) { + // only add if one isn't already present + const { clusterPromise } = putComponentTemplate(esClient, { body, name, create: true }); + return clusterPromise; + } + } else { + const { clusterPromise } = putComponentTemplate(esClient, { body, name }); + return clusterPromise; + } + }) ); - if (mappings) { - templates.push(mappings.name); - componentPromises.push(mappings.clusterPromise); - } - - if (settings) { - templates.push(settings.name); - componentPromises.push(settings.clusterPromise); - } - - // TODO: Check return values for errors - await Promise.all(componentPromises); - return templates; + return templateNames; } export async function installTemplate({ @@ -263,7 +289,7 @@ export async function installTemplate({ dataStream: RegistryDataStream; packageVersion: string; packageName: string; -}): Promise { +}): Promise { const validFields = processFields(fields); const mappings = generateMappings(validFields); const templateName = generateTemplateName(dataStream); @@ -310,11 +336,12 @@ export async function installTemplate({ await esClient.indices.putIndexTemplate(updateIndexTemplateParams, { ignore: [404] }); } - const composedOfTemplates = await installDataStreamComponentTemplates( + const composedOfTemplates = await installDataStreamComponentTemplates({ templateName, - dataStream.elasticsearch, - esClient - ); + registryElasticsearch: dataStream.elasticsearch, + esClient, + packageName, + }); const template = getTemplate({ type: dataStream.type, @@ -342,3 +369,21 @@ export async function installTemplate({ indexTemplate: template, }; } + +export function getAllTemplateRefs(installedTemplates: IndexTemplateEntry[]) { + return installedTemplates.flatMap((installedTemplate) => { + const indexTemplates = [ + { + id: installedTemplate.templateName, + type: ElasticsearchAssetType.indexTemplate, + }, + ]; + const componentTemplates = installedTemplate.indexTemplate.composed_of.map( + (componentTemplateId) => ({ + id: componentTemplateId, + type: ElasticsearchAssetType.componentTemplate, + }) + ); + return indexTemplates.concat(componentTemplates); + }); +} diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 07d0df021c827b..158996cc574d7a 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -10,7 +10,7 @@ import type { ElasticsearchClient } from 'kibana/server'; import type { Field, Fields } from '../../fields/field'; import type { RegistryDataStream, - TemplateRef, + IndexTemplateEntry, IndexTemplate, IndexTemplateMappings, } from '../../../../types'; @@ -456,7 +456,7 @@ function getBaseTemplate( export const updateCurrentWriteIndices = async ( esClient: ElasticsearchClient, - templates: TemplateRef[] + templates: IndexTemplateEntry[] ): Promise => { if (!templates.length) return; @@ -471,7 +471,7 @@ function isCurrentDataStream(item: CurrentDataStream[] | undefined): item is Cur const queryDataStreamsFromTemplates = async ( esClient: ElasticsearchClient, - templates: TemplateRef[] + templates: IndexTemplateEntry[] ): Promise => { const dataStreamPromises = templates.map((template) => { return getDataStreams(esClient, template); @@ -482,7 +482,7 @@ const queryDataStreamsFromTemplates = async ( const getDataStreams = async ( esClient: ElasticsearchClient, - template: TemplateRef + template: IndexTemplateEntry ): Promise => { const { templateName, indexTemplate } = template; const { body } = await esClient.indices.getDataStream({ name: `${templateName}-*` }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 65d71ac5fdc179..1bbbb1bb9b6a24 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -10,10 +10,10 @@ import type { ElasticsearchClient, SavedObject, SavedObjectsClientContract } fro import { MAX_TIME_COMPLETE_INSTALL, ASSETS_SAVED_OBJECT_TYPE } from '../../../../common'; import type { InstallablePackage, InstallSource, PackageAssetReference } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; -import { ElasticsearchAssetType } from '../../../types'; import type { AssetReference, Installation, InstallType } from '../../../types'; import { installTemplates } from '../elasticsearch/template/install'; import { installPipelines, deletePreviousPipelines } from '../elasticsearch/ingest_pipeline/'; +import { getAllTemplateRefs } from '../elasticsearch/template/install'; import { installILMPolicy } from '../elasticsearch/ilm/install'; import { installKibanaAssets, getKibanaAssets } from '../kibana/assets/install'; import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; @@ -170,10 +170,7 @@ export async function _installPackage({ installedPkg.attributes.install_version ); } - const installedTemplateRefs = installedTemplates.map((template) => ({ - id: template.templateName, - type: ElasticsearchAssetType.indexTemplate, - })); + const installedTemplateRefs = getAllTemplateRefs(installedTemplates); // make sure the assets are installed (or didn't error) if (installKibanaAssetsError) throw installKibanaAssetsError; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index c6fd9a8f763ab4..e00526cbb4ec46 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -257,8 +257,7 @@ async function installPackageFromRegistry({ const { paths, packageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion); // try installing the package, if there was an error, call error handler and rethrow - // TODO: without the ts-ignore, TS complains about the type of the value of the returned InstallResult.status - // @ts-ignore + // @ts-expect-error status is string instead of InstallResult.status 'installed' | 'already_installed' return _installPackage({ savedObjectsClient, esClient, @@ -334,8 +333,7 @@ async function installPackageByUpload({ version: packageInfo.version, packageInfo, }); - // TODO: without the ts-ignore, TS complains about the type of the value of the returned InstallResult.status - // @ts-ignore + // @ts-expect-error status is string instead of InstallResult.status 'installed' | 'already_installed' return _installPackage({ savedObjectsClient, esClient, @@ -484,17 +482,17 @@ export const saveInstalledEsRefs = async ( return installedAssets; }; -export const removeAssetsFromInstalledEsByType = async ( +export const removeAssetTypesFromInstalledEs = async ( savedObjectsClient: SavedObjectsClientContract, pkgName: string, - assetType: AssetType + assetTypes: AssetType[] ) => { const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); const installedAssets = installedPkg?.attributes.installed_es; if (!installedAssets?.length) return; - const installedAssetsToSave = installedAssets?.filter(({ id, type }) => { - return type !== assetType; - }); + const installedAssetsToSave = installedAssets?.filter( + (asset) => !assetTypes.includes(asset.type) + ); return savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { installed_es: installedAssetsToSave, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 706f1bbbaaf35b..70167d1156a667 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -89,13 +89,18 @@ function deleteKibanaAssets( }); } -function deleteESAssets(installedObjects: EsAssetReference[], esClient: ElasticsearchClient) { +function deleteESAssets( + installedObjects: EsAssetReference[], + esClient: ElasticsearchClient +): Array> { return installedObjects.map(async ({ id, type }) => { const assetType = type as AssetType; if (assetType === ElasticsearchAssetType.ingestPipeline) { return deletePipeline(esClient, id); } else if (assetType === ElasticsearchAssetType.indexTemplate) { - return deleteTemplate(esClient, id); + return deleteIndexTemplate(esClient, id); + } else if (assetType === ElasticsearchAssetType.componentTemplate) { + return deleteComponentTemplate(esClient, id); } else if (assetType === ElasticsearchAssetType.transform) { return deleteTransforms(esClient, [id]); } else if (assetType === ElasticsearchAssetType.dataStreamIlmPolicy) { @@ -111,13 +116,30 @@ async function deleteAssets( ) { const logger = appContextService.getLogger(); - const deletePromises: Array> = [ - ...deleteESAssets(installedEs, esClient), - ...deleteKibanaAssets(installedKibana, savedObjectsClient), - ]; + // must delete index templates first, or component templates which reference them cannot be deleted + // separate the assets into Index Templates and other assets + type Tuple = [EsAssetReference[], EsAssetReference[]]; + const [indexTemplates, otherAssets] = installedEs.reduce( + ([indexAssetTypes, otherAssetTypes], asset) => { + if (asset.type === ElasticsearchAssetType.indexTemplate) { + indexAssetTypes.push(asset); + } else { + otherAssetTypes.push(asset); + } + + return [indexAssetTypes, otherAssetTypes]; + }, + [[], []] + ); try { - await Promise.all(deletePromises); + // must delete index templates first + await Promise.all(deleteESAssets(indexTemplates, esClient)); + // then the other asset types + await Promise.all([ + ...deleteESAssets(otherAssets, esClient), + ...deleteKibanaAssets(installedKibana, savedObjectsClient), + ]); } catch (err) { // in the rollback case, partial installs are likely, so missing assets are not an error if (!savedObjectsClient.errors.isNotFoundError(err)) { @@ -126,13 +148,24 @@ async function deleteAssets( } } -async function deleteTemplate(esClient: ElasticsearchClient, name: string): Promise { +async function deleteIndexTemplate(esClient: ElasticsearchClient, name: string): Promise { // '*' shouldn't ever appear here, but it still would delete all templates if (name && name !== '*') { try { await esClient.indices.deleteIndexTemplate({ name }, { ignore: [404] }); } catch { - throw new Error(`error deleting template ${name}`); + throw new Error(`error deleting index template ${name}`); + } + } +} + +async function deleteComponentTemplate(esClient: ElasticsearchClient, name: string): Promise { + // '*' shouldn't ever appear here, but it still would delete all templates + if (name && name !== '*') { + try { + await esClient.cluster.deleteComponentTemplate({ name }, { ignore: [404] }); + } catch (error) { + throw new Error(`error deleting component template ${name}`); } } } diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 8927676976457a..0c08a09e76f4ea 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -63,7 +63,7 @@ export { IndexTemplate, RegistrySearchResults, RegistrySearchResult, - TemplateRef, + IndexTemplateEntry, IndexTemplateMappings, Settings, SettingsSOAttributes, diff --git a/x-pack/plugins/index_management/__jest__/components/index_table.test.js b/x-pack/plugins/index_management/__jest__/components/index_table.test.js index 4ac94319d47119..463d0b30cad08d 100644 --- a/x-pack/plugins/index_management/__jest__/components/index_table.test.js +++ b/x-pack/plugins/index_management/__jest__/components/index_table.test.js @@ -6,9 +6,12 @@ */ import React from 'react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; import axios from 'axios'; +import sinon from 'sinon'; +import { findTestSubject } from '@elastic/eui/lib/test'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; -import { MemoryRouter } from 'react-router-dom'; /** * The below import is required to avoid a console error warn from brace package @@ -18,9 +21,9 @@ import { MemoryRouter } from 'react-router-dom'; */ import { mountWithIntl, stubWebWorker } from '@kbn/test/jest'; // eslint-disable-line no-unused-vars +import { BASE_PATH, API_BASE_PATH } from '../../common/constants'; import { AppWithoutRouter } from '../../public/application/app'; import { AppContextProvider } from '../../public/application/app_context'; -import { Provider } from 'react-redux'; import { loadIndicesSuccess } from '../../public/application/store/actions'; import { breadcrumbService } from '../../public/application/services/breadcrumbs'; import { UiMetricService } from '../../public/application/services/ui_metric'; @@ -29,10 +32,7 @@ import { httpService } from '../../public/application/services/http'; import { setUiMetricService } from '../../public/application/services/api'; import { indexManagementStore } from '../../public/application/store'; import { setExtensionsService } from '../../public/application/store/selectors/extension_service'; -import { BASE_PATH, API_BASE_PATH } from '../../common/constants'; import { ExtensionsService } from '../../public/services'; -import sinon from 'sinon'; -import { findTestSubject } from '@elastic/eui/lib/test'; /* eslint-disable @kbn/eslint/no-restricted-paths */ import { notificationServiceMock } from '../../../../../src/core/public/notifications/notifications_service.mock'; @@ -40,9 +40,9 @@ import { notificationServiceMock } from '../../../../../src/core/public/notifica const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); let server = null; - let store = null; const indices = []; + for (let i = 0; i < 105; i++) { const baseFake = { health: i % 2 === 0 ? 'green' : 'yellow', @@ -63,8 +63,12 @@ for (let i = 0; i < 105; i++) { name: `.admin${i}`, }); } + let component = null; +// Resolve outstanding API requests. See https://www.benmvp.com/blog/asynchronous-testing-with-enzyme-react-jest/ +const runAllPromises = () => new Promise(setImmediate); + const status = (rendered, row = 0) => { rendered.update(); return findTestSubject(rendered, 'indexTableCell-status') @@ -76,39 +80,54 @@ const status = (rendered, row = 0) => { const snapshot = (rendered) => { expect(rendered).toMatchSnapshot(); }; + const openMenuAndClickButton = (rendered, rowIndex, buttonIndex) => { + // Select a row. const checkboxes = findTestSubject(rendered, 'indexTableRowCheckbox'); checkboxes.at(rowIndex).simulate('change', { target: { checked: true } }); rendered.update(); + + // Click the bulk actions button to open the context menu. const actionButton = findTestSubject(rendered, 'indexActionsContextMenuButton'); actionButton.simulate('click'); rendered.update(); + + // Click an action in the context menu. const contextMenuButtons = findTestSubject(rendered, 'indexTableContextMenuButton'); contextMenuButtons.at(buttonIndex).simulate('click'); + rendered.update(); }; -const testEditor = (buttonIndex, rowIndex = 0) => { - const rendered = mountWithIntl(component); + +const testEditor = (rendered, buttonIndex, rowIndex = 0) => { openMenuAndClickButton(rendered, rowIndex, buttonIndex); rendered.update(); snapshot(findTestSubject(rendered, 'detailPanelTabSelected').text()); }; -const testAction = (buttonIndex, done, rowIndex = 0) => { - const rendered = mountWithIntl(component); - let count = 0; + +const testAction = (rendered, buttonIndex, rowIndex = 0) => { + // This is leaking some implementation details about how Redux works. Not sure exactly what's going on + // but it looks like we're aware of how many Redux actions are dispatched in response to user interaction, + // so we "time" our assertion based on how many Redux actions we observe. This is brittle because it + // depends upon how our UI is architected, which will affect how many actions are dispatched. + // Expect this to break when we rearchitect the UI. + let dispatchedActionsCount = 0; store.subscribe(() => { - if (count > 1) { + if (dispatchedActionsCount === 1) { + // Take snapshot of final state. snapshot(status(rendered, rowIndex)); - done(); } - count++; + dispatchedActionsCount++; }); - expect.assertions(2); + openMenuAndClickButton(rendered, rowIndex, buttonIndex); + // take snapshot of initial state. snapshot(status(rendered, rowIndex)); }; + const names = (rendered) => { return findTestSubject(rendered, 'indexTableIndexNameLink'); }; + const namesText = (rendered) => { return names(rendered).map((button) => button.text()); }; @@ -142,23 +161,28 @@ describe('index table', () => { ); + store.dispatch(loadIndicesSuccess({ indices })); server = sinon.fakeServer.create(); + server.respondWith(`${API_BASE_PATH}/indices`, [ 200, { 'Content-Type': 'application/json' }, JSON.stringify(indices), ]); + server.respondWith([ 200, { 'Content-Type': 'application/json' }, JSON.stringify({ acknowledged: true }), ]); + server.respondWith(`${API_BASE_PATH}/indices/reload`, [ 200, { 'Content-Type': 'application/json' }, JSON.stringify(indices), ]); + server.respondImmediately = true; }); afterEach(() => { @@ -168,83 +192,124 @@ describe('index table', () => { server.restore(); }); - test('should change pages when a pagination link is clicked on', () => { + test('should change pages when a pagination link is clicked on', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + snapshot(namesText(rendered)); + const pagingButtons = rendered.find('.euiPaginationButton'); pagingButtons.at(2).simulate('click'); - rendered.update(); snapshot(namesText(rendered)); }); - test('should show more when per page value is increased', () => { + + test('should show more when per page value is increased', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); perPageButton.simulate('click'); rendered.update(); + const fiftyButton = rendered.find('.euiContextMenuItem').at(1); fiftyButton.simulate('click'); rendered.update(); expect(namesText(rendered).length).toBe(50); }); - test('should show the Actions menu button only when at least one row is selected', () => { + + test('should show the Actions menu button only when at least one row is selected', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + let button = findTestSubject(rendered, 'indexTableContextMenuButton'); expect(button.length).toEqual(0); + const checkboxes = findTestSubject(rendered, 'indexTableRowCheckbox'); checkboxes.at(0).simulate('change', { target: { checked: true } }); rendered.update(); button = findTestSubject(rendered, 'indexActionsContextMenuButton'); expect(button.length).toEqual(1); }); - test('should update the Actions menu button text when more than one row is selected', () => { + + test('should update the Actions menu button text when more than one row is selected', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + let button = findTestSubject(rendered, 'indexTableContextMenuButton'); expect(button.length).toEqual(0); + const checkboxes = findTestSubject(rendered, 'indexTableRowCheckbox'); checkboxes.at(0).simulate('change', { target: { checked: true } }); rendered.update(); button = findTestSubject(rendered, 'indexActionsContextMenuButton'); expect(button.text()).toEqual('Manage index'); + checkboxes.at(1).simulate('change', { target: { checked: true } }); rendered.update(); button = findTestSubject(rendered, 'indexActionsContextMenuButton'); expect(button.text()).toEqual('Manage 2 indices'); }); - test('should show system indices only when the switch is turned on', () => { + + test('should show system indices only when the switch is turned on', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + snapshot(rendered.find('.euiPagination li').map((item) => item.text())); const switchControl = rendered.find('.euiSwitch__button'); switchControl.simulate('click'); snapshot(rendered.find('.euiPagination li').map((item) => item.text())); }); - test('should filter based on content of search input', () => { + + test('should filter based on content of search input', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const searchInput = rendered.find('.euiFieldSearch').first(); searchInput.instance().value = 'testy0'; searchInput.simulate('keyup', { key: 'Enter', keyCode: 13, which: 13 }); rendered.update(); snapshot(namesText(rendered)); }); - test('should sort when header is clicked', () => { + + test('should sort when header is clicked', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const nameHeader = findTestSubject(rendered, 'indexTableHeaderCell-name').find('button'); nameHeader.simulate('click'); rendered.update(); snapshot(namesText(rendered)); + nameHeader.simulate('click'); rendered.update(); snapshot(namesText(rendered)); }); - test('should open the index detail slideout when the index name is clicked', () => { + + test('should open the index detail slideout when the index name is clicked', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + expect(findTestSubject(rendered, 'indexDetailFlyout').length).toBe(0); + const indexNameLink = names(rendered).at(0); indexNameLink.simulate('click'); rendered.update(); expect(findTestSubject(rendered, 'indexDetailFlyout').length).toBe(1); }); - test('should show the right context menu options when one index is selected and open', () => { + + test('should show the right context menu options when one index is selected and open', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const checkboxes = findTestSubject(rendered, 'indexTableRowCheckbox'); checkboxes.at(0).simulate('change', { target: { checked: true } }); rendered.update(); @@ -253,8 +318,12 @@ describe('index table', () => { rendered.update(); snapshot(findTestSubject(rendered, 'indexTableContextMenuButton').map((span) => span.text())); }); - test('should show the right context menu options when one index is selected and closed', () => { + + test('should show the right context menu options when one index is selected and closed', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const checkboxes = findTestSubject(rendered, 'indexTableRowCheckbox'); checkboxes.at(1).simulate('change', { target: { checked: true } }); rendered.update(); @@ -263,8 +332,12 @@ describe('index table', () => { rendered.update(); snapshot(findTestSubject(rendered, 'indexTableContextMenuButton').map((span) => span.text())); }); - test('should show the right context menu options when one open and one closed index is selected', () => { + + test('should show the right context menu options when one open and one closed index is selected', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const checkboxes = findTestSubject(rendered, 'indexTableRowCheckbox'); checkboxes.at(0).simulate('change', { target: { checked: true } }); checkboxes.at(1).simulate('change', { target: { checked: true } }); @@ -274,8 +347,12 @@ describe('index table', () => { rendered.update(); snapshot(findTestSubject(rendered, 'indexTableContextMenuButton').map((span) => span.text())); }); - test('should show the right context menu options when more than one open index is selected', () => { + + test('should show the right context menu options when more than one open index is selected', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const checkboxes = findTestSubject(rendered, 'indexTableRowCheckbox'); checkboxes.at(0).simulate('change', { target: { checked: true } }); checkboxes.at(2).simulate('change', { target: { checked: true } }); @@ -285,8 +362,12 @@ describe('index table', () => { rendered.update(); snapshot(findTestSubject(rendered, 'indexTableContextMenuButton').map((span) => span.text())); }); - test('should show the right context menu options when more than one closed index is selected', () => { + + test('should show the right context menu options when more than one closed index is selected', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const checkboxes = findTestSubject(rendered, 'indexTableRowCheckbox'); checkboxes.at(1).simulate('change', { target: { checked: true } }); checkboxes.at(3).simulate('change', { target: { checked: true } }); @@ -296,37 +377,57 @@ describe('index table', () => { rendered.update(); snapshot(findTestSubject(rendered, 'indexTableContextMenuButton').map((span) => span.text())); }); - test('flush button works from context menu', (done) => { - testAction(8, done); + + test('flush button works from context menu', async () => { + const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + testAction(rendered, 8); }); - test('clear cache button works from context menu', (done) => { - testAction(7, done); + + test('clear cache button works from context menu', async () => { + const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + testAction(rendered, 7); }); - test('refresh button works from context menu', (done) => { - testAction(6, done); + + test('refresh button works from context menu', async () => { + const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + testAction(rendered, 6); }); - test('force merge button works from context menu', (done) => { + + test('force merge button works from context menu', async () => { const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const rowIndex = 0; openMenuAndClickButton(rendered, rowIndex, 5); snapshot(status(rendered, rowIndex)); expect(rendered.find('.euiModal').length).toBe(1); + let count = 0; store.subscribe(() => { - if (count > 1) { + if (count === 1) { snapshot(status(rendered, rowIndex)); expect(rendered.find('.euiModal').length).toBe(0); - done(); } count++; }); + const confirmButton = findTestSubject(rendered, 'confirmModalConfirmButton'); confirmButton.simulate('click'); snapshot(status(rendered, rowIndex)); }); - // Commenting the following 2 tests as it works in the browser (status changes to "closed" or "open") but the - // snapshot say the contrary. Need to be investigated. - test('close index button works from context menu', (done) => { + + test('close index button works from context menu', async () => { + const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const modifiedIndices = indices.map((index) => { return { ...index, @@ -339,32 +440,56 @@ describe('index table', () => { { 'Content-Type': 'application/json' }, JSON.stringify(modifiedIndices), ]); - testAction(4, done); + + testAction(rendered, 4); }); - test('open index button works from context menu', (done) => { + + test('open index button works from context menu', async () => { + const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + const modifiedIndices = indices.map((index) => { return { ...index, status: index.name === 'testy1' ? 'open' : index.status, }; }); + server.respondWith(`${API_BASE_PATH}/indices/reload`, [ 200, { 'Content-Type': 'application/json' }, JSON.stringify(modifiedIndices), ]); - testAction(3, done, 1); + + testAction(rendered, 3, 1); }); - test('show settings button works from context menu', () => { - testEditor(0); + + test('show settings button works from context menu', async () => { + const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + testEditor(rendered, 0); }); - test('show mappings button works from context menu', () => { - testEditor(1); + + test('show mappings button works from context menu', async () => { + const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + testEditor(rendered, 1); }); - test('show stats button works from context menu', () => { - testEditor(2); + + test('show stats button works from context menu', async () => { + const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + testEditor(rendered, 2); }); - test('edit index button works from context menu', () => { - testEditor(3); + + test('edit index button works from context menu', async () => { + const rendered = mountWithIntl(component); + await runAllPromises(); + rendered.update(); + testEditor(rendered, 3); }); }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts index 8c8f7e57899254..dee15f2ae3a45d 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts @@ -165,8 +165,10 @@ describe('', () => { const { exists, find } = testBed; expect(exists('componentTemplatesLoadError')).toBe(true); + // The text here looks weird because the child elements' text values (title and description) + // are concatenated when we retrive the error element's text value. expect(find('componentTemplatesLoadError').text()).toContain( - 'Unable to load component templates. Try again.' + 'Error loading component templatesInternal server error' ); }); }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx index 2bb240e6b6ae18..77668f7d550720 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx @@ -13,8 +13,13 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ScopedHistory } from 'kibana/public'; import { EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; -import { attemptToURIDecode } from '../../../../shared_imports'; -import { SectionLoading, ComponentTemplateDeserialized, GlobalFlyout } from '../shared_imports'; +import { + APP_WRAPPER_CLASS, + PageLoading, + PageError, + attemptToURIDecode, +} from '../../../../shared_imports'; +import { ComponentTemplateDeserialized, GlobalFlyout } from '../shared_imports'; import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants'; import { useComponentTemplatesContext } from '../component_templates_context'; import { @@ -24,7 +29,6 @@ import { } from '../component_template_details'; import { EmptyPrompt } from './empty_prompt'; import { ComponentTable } from './table'; -import { LoadError } from './error'; import { ComponentTemplatesDeleteModal } from './delete_modal'; interface Props { @@ -138,18 +142,20 @@ export const ComponentTemplateList: React.FunctionComponent = ({ } }, [componentTemplateName, removeContentFromGlobalFlyout]); - let content: React.ReactNode; - if (isLoading) { - content = ( - + return ( + - + ); - } else if (data?.length) { + } + + let content: React.ReactNode; + + if (data?.length) { content = ( <> @@ -183,11 +189,22 @@ export const ComponentTemplateList: React.FunctionComponent = ({ } else if (data && data.length === 0) { content = ; } else if (error) { - content = ; + content = ( + + } + error={error} + data-test-subj="componentTemplatesLoadError" + /> + ); } return ( -
+
{content} {/* delete modal */} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/error.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/error.tsx deleted file mode 100644 index 9fd0031fe87786..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/error.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FunctionComponent } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiLink, EuiCallOut } from '@elastic/eui'; - -export interface Props { - onReloadClick: () => void; -} - -export const LoadError: FunctionComponent = ({ onReloadClick }) => { - return ( - - - - ), - }} - /> - } - /> - ); -}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/with_privileges.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/with_privileges.tsx index a0f6dc4b59fe7f..eecb56768df9a5 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/with_privileges.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/with_privileges.tsx @@ -9,10 +9,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { FunctionComponent } from 'react'; import { - SectionError, + PageLoading, + PageError, useAuthorizationContext, WithPrivileges, - SectionLoading, NotAuthorizedSection, } from '../shared_imports'; import { APP_CLUSTER_REQUIRED_PRIVILEGES } from '../constants'; @@ -26,7 +26,7 @@ export const ComponentTemplatesWithPrivileges: FunctionComponent = ({ if (apiError) { return ( - { if (isLoading) { return ( - + - + ); } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx index b87b043c924a60..d19c500c3622ae 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx @@ -10,7 +10,7 @@ import { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { SectionLoading, attemptToURIDecode } from '../../shared_imports'; +import { PageLoading, attemptToURIDecode } from '../../shared_imports'; import { useComponentTemplatesContext } from '../../component_templates_context'; import { ComponentTemplateCreate } from '../component_template_create'; @@ -30,7 +30,8 @@ export const ComponentTemplateClone: FunctionComponent { if (error && !isLoading) { - toasts.addError(error, { + // Toasts expects a generic Error object, which is typed as having a required name property. + toasts.addError({ ...error, name: '' } as Error, { title: i18n.translate('xpack.idxMgmt.componentTemplateClone.loadComponentTemplateTitle', { defaultMessage: `Error loading component template '{sourceComponentTemplateName}'.`, values: { sourceComponentTemplateName }, @@ -42,12 +43,12 @@ export const ComponentTemplateClone: FunctionComponent + - + ); } else { // We still show the create form (unpopulated) even if we were not able to load the diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx index 5163c75bdbadda..8fe2c193daa0c1 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx @@ -8,7 +8,7 @@ import React, { useState, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPageContentBody, EuiSpacer, EuiPageHeader } from '@elastic/eui'; import { ComponentTemplateDeserialized } from '../../shared_imports'; import { useComponentTemplatesContext } from '../../component_templates_context'; @@ -59,27 +59,28 @@ export const ComponentTemplateCreate: React.FunctionComponent - - -

+ + -

-
- - - - -
- + + } + bottomBorder + /> + + + + + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx index 809fac980069f4..6ac831b5daccea 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx @@ -8,13 +8,15 @@ import React, { useState, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiTitle, EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { EuiPageContentBody, EuiPageHeader, EuiSpacer } from '@elastic/eui'; import { useComponentTemplatesContext } from '../../component_templates_context'; import { ComponentTemplateDeserialized, - SectionLoading, + PageLoading, + PageError, attemptToURIDecode, + Error, } from '../../shared_imports'; import { ComponentTemplateForm } from '../component_template_form'; @@ -65,64 +67,57 @@ export const ComponentTemplateEdit: React.FunctionComponent + return ( + - - ); - } else if (error) { - content = ( - <> - - } - color="danger" - iconType="alert" - data-test-subj="loadComponentTemplateError" - > -
{error.message}
-
- - +
); - } else if (componentTemplate) { - content = ( - + } + error={error as Error} + data-test-subj="loadComponentTemplateError" /> ); } return ( - - - -

+ + -

-
- - {content} -
-
+ + } + bottomBorder + /> + + + + + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts index 75c68e71996b85..6bf6d204fd9a51 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts @@ -10,7 +10,6 @@ import { ComponentTemplateListItem, ComponentTemplateDeserialized, ComponentTemplateSerialized, - Error, } from '../shared_imports'; import { UIM_COMPONENT_TEMPLATE_DELETE_MANY, @@ -26,7 +25,7 @@ export const getApi = ( trackMetric: (type: UiCounterMetricType, eventName: string) => void ) => { function useLoadComponentTemplates() { - return useRequest({ + return useRequest({ path: `${apiBasePath}/component_templates`, method: 'get', }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts index 64b2e6b47e5d95..a7056e27b5cad4 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts @@ -14,6 +14,7 @@ import { SendRequestResponse, sendRequest as _sendRequest, useRequest as _useRequest, + Error, } from '../shared_imports'; export type UseRequestHook = ( diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts index afc7aed874387e..15528f5b4e8e5b 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts @@ -12,10 +12,12 @@ export { SendRequestResponse, sendRequest, useRequest, - SectionLoading, WithPrivileges, AuthorizationProvider, SectionError, + SectionLoading, + PageLoading, + PageError, Error, useAuthorizationContext, NotAuthorizedSection, diff --git a/x-pack/plugins/index_management/public/application/components/index.ts b/x-pack/plugins/index_management/public/application/components/index.ts index f5c58e5b45ebd2..eeba6e16b543c5 100644 --- a/x-pack/plugins/index_management/public/application/components/index.ts +++ b/x-pack/plugins/index_management/public/application/components/index.ts @@ -6,9 +6,7 @@ */ export { SectionError, Error } from './section_error'; -export { SectionLoading } from './section_loading'; export { NoMatch } from './no_match'; -export { PageErrorForbidden } from './page_error'; export { TemplateDeleteModal } from './template_delete_modal'; export { TemplateForm } from './template_form'; export { DataHealth } from './data_health'; diff --git a/x-pack/plugins/index_management/public/application/components/page_error/page_error_forbidden.tsx b/x-pack/plugins/index_management/public/application/components/page_error/page_error_forbidden.tsx deleted file mode 100644 index e22b180881ed59..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/page_error/page_error_forbidden.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { EuiEmptyPrompt, EuiPageContent } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export function PageErrorForbidden() { - return ( - - - - - } - /> - - ); -} diff --git a/x-pack/plugins/index_management/public/application/components/section_loading.tsx b/x-pack/plugins/index_management/public/application/components/section_loading.tsx deleted file mode 100644 index 3c31744dee398c..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/section_loading.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui'; - -interface Props { - children: React.ReactNode; -} - -export const SectionLoading: React.FunctionComponent = ({ children }) => { - return ( - } - body={{children}} - data-test-subj="sectionLoading" - /> - ); -}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 54160141827d0a..4ccd77d275a94d 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -8,7 +8,7 @@ import React, { useState, useCallback, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer, EuiButton } from '@elastic/eui'; +import { EuiSpacer, EuiButton, EuiPageHeader } from '@elastic/eui'; import { ScopedHistory } from 'kibana/public'; import { TemplateDeserialized } from '../../../../common'; @@ -292,7 +292,7 @@ export const TemplateForm = ({ return ( <> {/* Form header */} - {title} + {title}} bottomBorder /> diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index a9258c6a3b10be..3d5f56c08f8e18 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -24,8 +24,8 @@ import { EuiTitle, } from '@elastic/eui'; -import { reactRouterNavigate } from '../../../../../shared_imports'; -import { SectionLoading, SectionError, Error, DataHealth } from '../../../../components'; +import { SectionLoading, reactRouterNavigate } from '../../../../../shared_imports'; +import { SectionError, Error, DataHealth } from '../../../../components'; import { useLoadDataStream } from '../../../../services/api'; import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal'; import { humanizeTimeStamp } from '../humanize_time_stamp'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx index 131dc2662bc1c7..7bd7c163837d8e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx @@ -16,18 +16,22 @@ import { EuiText, EuiIconTip, EuiSpacer, + EuiPageContent, EuiEmptyPrompt, EuiLink, } from '@elastic/eui'; import { ScopedHistory } from 'kibana/public'; import { + PageLoading, + PageError, + Error, reactRouterNavigate, extractQueryParams, attemptToURIDecode, + APP_WRAPPER_CLASS, } from '../../../../shared_imports'; import { useAppContext } from '../../../app_context'; -import { SectionError, SectionLoading, Error } from '../../../components'; import { useLoadDataStreams } from '../../../services/api'; import { documentationService } from '../../../services/documentation'; import { Section } from '../home'; @@ -166,16 +170,16 @@ export const DataStreamList: React.FunctionComponent + - + ); } else if (error) { content = ( - ); - } else if (Array.isArray(dataStreams) && dataStreams.length > 0) { - activateHiddenFilter(isSelectedDataStreamHidden(dataStreams, decodedDataStreamName)); + } else { + activateHiddenFilter(isSelectedDataStreamHidden(dataStreams!, decodedDataStreamName)); content = ( - <> + {renderHeader()} @@ -270,12 +274,12 @@ export const DataStreamList: React.FunctionComponent - + ); } return ( -
+
{content} {/* diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.tsx index ac46b5dbd256be..fc68ca33e95361 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.tsx @@ -8,12 +8,13 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; +import { APP_WRAPPER_CLASS } from '../../../../shared_imports'; import { DetailPanel } from './detail_panel'; import { IndexTable } from './index_table'; export const IndexList: React.FunctionComponent = ({ history }) => { return ( -
+
diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js index f488290692e7ef..0a407927e34666 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js @@ -19,7 +19,7 @@ import { EuiCheckbox, EuiFlexGroup, EuiFlexItem, - EuiLoadingSpinner, + EuiPageContent, EuiScreenReaderOnly, EuiSpacer, EuiSearchBar, @@ -37,13 +37,18 @@ import { } from '@elastic/eui'; import { UIM_SHOW_DETAILS_CLICK } from '../../../../../../common/constants'; -import { reactRouterNavigate, attemptToURIDecode } from '../../../../../shared_imports'; +import { + PageLoading, + PageError, + reactRouterNavigate, + attemptToURIDecode, +} from '../../../../../shared_imports'; import { REFRESH_RATE_INDEX_LIST } from '../../../../constants'; import { getDataStreamDetailsLink } from '../../../../services/routing'; import { documentationService } from '../../../../services/documentation'; import { AppContextConsumer } from '../../../../app_context'; import { renderBadges } from '../../../../lib/render_badges'; -import { NoMatch, PageErrorForbidden, DataHealth } from '../../../../components'; +import { NoMatch, DataHealth } from '../../../../components'; import { IndexActionsContextMenu } from '../index_actions_context_menu'; const HEADERS = { @@ -332,42 +337,6 @@ export class IndexTable extends Component { }); } - renderError() { - const { indicesError } = this.props; - - const data = indicesError.body ? indicesError.body : indicesError; - - const { error: errorString, cause, message } = data; - - return ( - - - } - color="danger" - iconType="alert" - > -
{message || errorString}
- {cause && ( - - -
    - {cause.map((message, i) => ( -
  • {message}
  • - ))} -
-
- )} -
- -
- ); - } - renderBanners(extensionsService) { const { allIndices = [], filterChanged } = this.props; return extensionsService.banners.map((bannerExtension, i) => { @@ -470,37 +439,71 @@ export class IndexTable extends Component { } = this.props; const { includeHiddenIndices } = this.readURLParams(); + const hasContent = !indicesLoading && !indicesError; - let emptyState; + if (!hasContent) { + const renderNoContent = () => { + if (indicesLoading) { + return ( + + + + ); + } + + if (indicesError) { + if (indicesError.status === 403) { + return ( + + } + /> + ); + } - if (indicesLoading) { - emptyState = ( - - - - - - ); - } + return ( + + } + error={indicesError.body} + /> + ); + } + }; - if (!indicesLoading && !indicesError) { - emptyState = ; + return ( + + {renderNoContent()} + + ); } const { selectedIndicesMap } = this.state; const atLeastOneItemSelected = Object.keys(selectedIndicesMap).length > 0; - if (indicesError && indicesError.status === 403) { - return ; - } - return ( {({ services }) => { const { extensionsService } = services; return ( - + @@ -557,8 +560,6 @@ export class IndexTable extends Component { {this.renderBanners(extensionsService)} - {indicesError && this.renderError()} - {atLeastOneItemSelected ? ( @@ -665,13 +666,13 @@ export class IndexTable extends Component {
) : ( - emptyState + )} {indices.length > 0 ? this.renderPager() : null} - + ); }} diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx index e61362efb8c99a..1a82cb3bfbdd15 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx @@ -33,8 +33,8 @@ import { UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB, } from '../../../../../../common/constants'; -import { UseRequestResponse } from '../../../../../shared_imports'; -import { TemplateDeleteModal, SectionLoading, SectionError, Error } from '../../../../components'; +import { SectionLoading, UseRequestResponse } from '../../../../../shared_imports'; +import { TemplateDeleteModal, SectionError, Error } from '../../../../components'; import { useLoadIndexTemplate } from '../../../../services/api'; import { useServices } from '../../../../app_context'; import { TabAliases, TabMappings, TabSettings } from '../../../../components/shared'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx index b8b5a8e3c7d1a4..57f18134be5d69 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -24,13 +24,14 @@ import { import { UIM_TEMPLATE_LIST_LOAD } from '../../../../../common/constants'; import { TemplateListItem } from '../../../../../common'; -import { attemptToURIDecode } from '../../../../shared_imports'; import { - SectionError, - SectionLoading, - Error, - LegacyIndexTemplatesDeprecation, -} from '../../../components'; + APP_WRAPPER_CLASS, + PageLoading, + PageError, + attemptToURIDecode, + reactRouterNavigate, +} from '../../../../shared_imports'; +import { LegacyIndexTemplatesDeprecation } from '../../../components'; import { useLoadIndexTemplates } from '../../../services/api'; import { documentationService } from '../../../services/documentation'; import { useServices } from '../../../app_context'; @@ -130,7 +131,8 @@ export const TemplateList: React.FunctionComponent ( - + // flex-grow: 0 is needed here because the parent element is a flex column and the header would otherwise expand. + ); - const renderContent = () => { - if (isLoading) { - return ( - + // Track this component mounted. + useEffect(() => { + uiMetricService.trackMetric(METRIC_TYPE.LOADED, UIM_TEMPLATE_LIST_LOAD); + }, [uiMetricService]); + + let content; + + if (isLoading) { + content = ( + + + + ); + } else if (error) { + content = ( + - - ); - } else if (error) { - return ( - + ); + } else if (!hasTemplates) { + content = ( + - } - error={error as Error} - /> - ); - } else if (!hasTemplates) { - return ( - + + } + body={ + <> +

- - } - data-test-subj="emptyPrompt" - /> - ); - } else { - return ( - - {/* Header */} - {renderHeader()} +

+ + } + actions={ + + + + } + data-test-subj="emptyPrompt" + /> + ); + } else { + content = ( + <> + {/* Header */} + {renderHeader()} - {/* Composable index templates table */} - {renderTemplatesTable()} + {/* Composable index templates table */} + {renderTemplatesTable()} - {/* Legacy index templates table. We discourage their adoption if the user isn't already using them. */} - {filteredTemplates.legacyTemplates.length > 0 && renderLegacyTemplatesTable()} - - ); - } - }; + {/* Legacy index templates table. We discourage their adoption if the user isn't already using them. */} + {filteredTemplates.legacyTemplates.length > 0 && renderLegacyTemplatesTable()} - // Track component loaded - useEffect(() => { - uiMetricService.trackMetric(METRIC_TYPE.LOADED, UIM_TEMPLATE_LIST_LOAD); - }, [uiMetricService]); + {isTemplateDetailsVisible && ( + + )} + + ); + } return ( -
- {renderContent()} - - {isTemplateDetailsVisible && ( - - )} +
+ {content}
); }; diff --git a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx index 36bff298e345ba..32c84bc3b15f12 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx @@ -8,11 +8,12 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui'; +import { EuiPageContentBody } from '@elastic/eui'; import { ScopedHistory } from 'kibana/public'; +import { PageLoading, PageError, Error } from '../../../shared_imports'; import { TemplateDeserialized } from '../../../../common'; -import { TemplateForm, SectionLoading, SectionError, Error } from '../../components'; +import { TemplateForm } from '../../components'; import { breadcrumbService } from '../../services/breadcrumbs'; import { getTemplateDetailsLink } from '../../services/routing'; import { saveTemplate, useLoadIndexTemplate } from '../../services/api'; @@ -62,24 +63,22 @@ export const TemplateClone: React.FunctionComponent { breadcrumbService.setBreadcrumbs('templateClone'); }, []); if (isLoading) { - content = ( - + return ( + - + ); } else if (templateToCloneError) { - content = ( - ); - } else if (templateToClone) { - const templateData = { - ...templateToClone, - name: `${decodedTemplateName}-copy`, - } as TemplateDeserialized; + } + + const templateData = { + ...templateToClone, + name: `${decodedTemplateName}-copy`, + } as TemplateDeserialized; - content = ( + return ( + -

- -

- + } defaultValue={templateData} onSave={onSave} @@ -117,12 +114,6 @@ export const TemplateClone: React.FunctionComponent - ); - } - - return ( - - {content} - +
); }; diff --git a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx index 310807aeef38fd..6eba112b11939a 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui'; +import { EuiPageContentBody } from '@elastic/eui'; import { useLocation } from 'react-router-dom'; import { parse } from 'query-string'; import { ScopedHistory } from 'kibana/public'; @@ -52,34 +52,28 @@ export const TemplateCreate: React.FunctionComponent = ({ h }, []); return ( - - - -

- {isLegacy ? ( - - ) : ( - - )} -

- - } - onSave={onSave} - isSaving={isSaving} - saveError={saveError} - clearSaveError={clearSaveError} - isLegacy={isLegacy} - history={history as ScopedHistory} - /> -
-
+ + + ) : ( + + ) + } + onSave={onSave} + isSaving={isSaving} + saveError={saveError} + clearSaveError={clearSaveError} + isLegacy={isLegacy} + history={history as ScopedHistory} + /> + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx index f4ffe97931a240..ff6909d4666f80 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx @@ -7,16 +7,17 @@ import React, { useEffect, useState, Fragment } from 'react'; import { RouteComponentProps } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiTitle, EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { EuiPageContentBody, EuiSpacer, EuiCallOut } from '@elastic/eui'; import { ScopedHistory } from 'kibana/public'; import { TemplateDeserialized } from '../../../../common'; -import { attemptToURIDecode } from '../../../shared_imports'; +import { PageError, PageLoading, attemptToURIDecode, Error } from '../../../shared_imports'; import { breadcrumbService } from '../../services/breadcrumbs'; import { useLoadIndexTemplate, updateTemplate } from '../../services/api'; import { getTemplateDetailsLink } from '../../services/routing'; -import { SectionLoading, SectionError, TemplateForm, Error } from '../../components'; +import { TemplateForm } from '../../components'; import { getIsLegacyFromQueryParams } from '../../lib/index_templates'; interface MatchParams { @@ -62,27 +63,27 @@ export const TemplateEdit: React.FunctionComponent + return ( + - + ); } else if (error) { - content = ( - } - error={error as Error} + error={error} data-test-subj="sectionError" /> ); @@ -91,80 +92,75 @@ export const TemplateEdit: React.FunctionComponent } - color="danger" - iconType="alert" + error={ + { + message: i18n.translate( + 'xpack.idxMgmt.templateEdit.managedTemplateWarningDescription', + { + defaultMessage: 'Managed templates are critical for internal operations.', + } + ), + } as Error + } data-test-subj="systemTemplateEditCallout" - > - - + /> ); - } else { - content = ( + } + } + + return ( + + {isSystemTemplate && ( - {isSystemTemplate && ( - - - } - color="danger" - iconType="alert" - data-test-subj="systemTemplateEditCallout" - > - - - - - )} - -

- -

- + } - defaultValue={template} - onSave={onSave} - isSaving={isSaving} - saveError={saveError} - clearSaveError={clearSaveError} - isEditing={true} - isLegacy={isLegacy} - history={history as ScopedHistory} - /> + color="danger" + iconType="alert" + data-test-subj="systemTemplateEditCallout" + > + + +
- ); - } - } + )} - return ( - - {content} - + + } + defaultValue={template!} + onSave={onSave} + isSaving={isSaving} + saveError={saveError} + clearSaveError={clearSaveError} + isEditing={true} + isLegacy={isLegacy} + history={history as ScopedHistory} + /> +
); }; diff --git a/x-pack/plugins/index_management/public/application/services/use_request.ts b/x-pack/plugins/index_management/public/application/services/use_request.ts index f4d34264395621..3b1d5cf22452df 100644 --- a/x-pack/plugins/index_management/public/application/services/use_request.ts +++ b/x-pack/plugins/index_management/public/application/services/use_request.ts @@ -11,6 +11,7 @@ import { UseRequestConfig, sendRequest as _sendRequest, useRequest as _useRequest, + Error, } from '../../shared_imports'; import { httpService } from './http'; @@ -19,6 +20,6 @@ export const sendRequest = (config: SendRequestConfig): Promise(config: UseRequestConfig) => { - return _useRequest(httpService.httpClient, config); +export const useRequest = (config: UseRequestConfig) => { + return _useRequest(httpService.httpClient, config); }; diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index eddac8e4b8a86a..fa27b22e502fa5 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -5,6 +5,8 @@ * 2.0. */ +export { APP_WRAPPER_CLASS } from '../../../../src/core/public'; + export { SendRequestConfig, SendRequestResponse, @@ -16,6 +18,10 @@ export { extractQueryParams, GlobalFlyout, attemptToURIDecode, + PageLoading, + PageError, + Error, + SectionLoading, } from '../../../../src/plugins/es_ui_shared/public'; export { diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts index bd000186d91c40..231a2764d27106 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts @@ -17,28 +17,40 @@ import { getCloudManagedTemplatePrefix } from '../../../lib/get_managed_template import { RouteDependencies } from '../../../types'; import { addBasePath } from '../index'; -export function registerGetAllRoute({ router }: RouteDependencies) { +export function registerGetAllRoute({ router, lib: { isEsError } }: RouteDependencies) { router.get({ path: addBasePath('/index_templates'), validate: false }, async (ctx, req, res) => { const { callAsCurrentUser } = ctx.dataManagement!.client; - const cloudManagedTemplatePrefix = await getCloudManagedTemplatePrefix(callAsCurrentUser); - const legacyTemplatesEs = await callAsCurrentUser('indices.getTemplate'); - const { index_templates: templatesEs } = await callAsCurrentUser( - 'dataManagement.getComposableIndexTemplates' - ); - - const legacyTemplates = deserializeLegacyTemplateList( - legacyTemplatesEs, - cloudManagedTemplatePrefix - ); - const templates = deserializeTemplateList(templatesEs, cloudManagedTemplatePrefix); - - const body = { - templates, - legacyTemplates, - }; - - return res.ok({ body }); + try { + const cloudManagedTemplatePrefix = await getCloudManagedTemplatePrefix(callAsCurrentUser); + + const legacyTemplatesEs = await callAsCurrentUser('indices.getTemplate'); + const { index_templates: templatesEs } = await callAsCurrentUser( + 'dataManagement.getComposableIndexTemplates' + ); + + const legacyTemplates = deserializeLegacyTemplateList( + legacyTemplatesEs, + cloudManagedTemplatePrefix + ); + const templates = deserializeTemplateList(templatesEs, cloudManagedTemplatePrefix); + + const body = { + templates, + legacyTemplates, + }; + + return res.ok({ body }); + } catch (error) { + if (isEsError(error)) { + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + // Case: default + throw error; + } }); } diff --git a/x-pack/plugins/ingest_pipelines/public/index.ts b/x-pack/plugins/ingest_pipelines/public/index.ts index 8948a3e8d56bef..d120f60ef8a2d1 100644 --- a/x-pack/plugins/ingest_pipelines/public/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/index.ts @@ -10,10 +10,3 @@ import { IngestPipelinesPlugin } from './plugin'; export function plugin() { return new IngestPipelinesPlugin(); } - -export { - INGEST_PIPELINES_APP_ULR_GENERATOR, - IngestPipelinesUrlGenerator, - IngestPipelinesUrlGeneratorState, - INGEST_PIPELINES_PAGES, -} from './url_generator'; diff --git a/x-pack/plugins/ingest_pipelines/public/locator.test.ts b/x-pack/plugins/ingest_pipelines/public/locator.test.ts new file mode 100644 index 00000000000000..0b1246b2bed59f --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/locator.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ManagementAppLocatorDefinition } from 'src/plugins/management/common/locator'; +import { IngestPipelinesLocatorDefinition, INGEST_PIPELINES_PAGES } from './locator'; + +describe('Ingest pipeline locator', () => { + const setup = () => { + const managementDefinition = new ManagementAppLocatorDefinition(); + const definition = new IngestPipelinesLocatorDefinition({ + managementAppLocator: { + getLocation: (params) => managementDefinition.getLocation(params), + getUrl: async () => { + throw new Error('not implemented'); + }, + navigate: async () => { + throw new Error('not implemented'); + }, + useUrl: () => '', + }, + }); + return { definition }; + }; + + describe('Pipelines List', () => { + it('generates relative url for list without pipelineId', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.LIST, + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines', + }); + }); + + it('generates relative url for list with a pipelineId', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.LIST, + pipelineId: 'pipeline_name', + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines/?pipeline=pipeline_name', + }); + }); + }); + + describe('Pipeline Edit', () => { + it('generates relative url for pipeline edit', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.EDIT, + pipelineId: 'pipeline_name', + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines/edit/pipeline_name', + }); + }); + }); + + describe('Pipeline Clone', () => { + it('generates relative url for pipeline clone', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.CLONE, + pipelineId: 'pipeline_name', + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines/create/pipeline_name', + }); + }); + }); + + describe('Pipeline Create', () => { + it('generates relative url for pipeline create', async () => { + const { definition } = setup(); + const location = await definition.getLocation({ + page: INGEST_PIPELINES_PAGES.CREATE, + pipelineId: 'pipeline_name', + }); + + expect(location).toMatchObject({ + app: 'management', + path: '/ingest/ingest_pipelines/create', + }); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/locator.ts b/x-pack/plugins/ingest_pipelines/public/locator.ts new file mode 100644 index 00000000000000..d819011f14f470 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/locator.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SerializableState } from 'src/plugins/kibana_utils/common'; +import { ManagementAppLocator } from 'src/plugins/management/common'; +import { + LocatorPublic, + LocatorDefinition, + KibanaLocation, +} from '../../../../src/plugins/share/public'; +import { + getClonePath, + getCreatePath, + getEditPath, + getListPath, +} from './application/services/navigation'; +import { PLUGIN_ID } from '../common/constants'; + +export enum INGEST_PIPELINES_PAGES { + LIST = 'pipelines_list', + EDIT = 'pipeline_edit', + CREATE = 'pipeline_create', + CLONE = 'pipeline_clone', +} + +interface IngestPipelinesBaseParams extends SerializableState { + pipelineId: string; +} +export interface IngestPipelinesListParams extends Partial { + page: INGEST_PIPELINES_PAGES.LIST; +} + +export interface IngestPipelinesEditParams extends IngestPipelinesBaseParams { + page: INGEST_PIPELINES_PAGES.EDIT; +} + +export interface IngestPipelinesCloneParams extends IngestPipelinesBaseParams { + page: INGEST_PIPELINES_PAGES.CLONE; +} + +export interface IngestPipelinesCreateParams extends IngestPipelinesBaseParams { + page: INGEST_PIPELINES_PAGES.CREATE; +} + +export type IngestPipelinesParams = + | IngestPipelinesListParams + | IngestPipelinesEditParams + | IngestPipelinesCloneParams + | IngestPipelinesCreateParams; + +export type IngestPipelinesLocator = LocatorPublic; + +export const INGEST_PIPELINES_APP_LOCATOR = 'INGEST_PIPELINES_APP_LOCATOR'; + +export interface IngestPipelinesLocatorDependencies { + managementAppLocator: ManagementAppLocator; +} + +export class IngestPipelinesLocatorDefinition implements LocatorDefinition { + public readonly id = INGEST_PIPELINES_APP_LOCATOR; + + constructor(protected readonly deps: IngestPipelinesLocatorDependencies) {} + + public readonly getLocation = async (params: IngestPipelinesParams): Promise => { + const location = await this.deps.managementAppLocator.getLocation({ + sectionId: 'ingest', + appId: PLUGIN_ID, + }); + + let path: string = ''; + + switch (params.page) { + case INGEST_PIPELINES_PAGES.EDIT: + path = getEditPath({ + pipelineName: params.pipelineId, + }); + break; + case INGEST_PIPELINES_PAGES.CREATE: + path = getCreatePath(); + break; + case INGEST_PIPELINES_PAGES.LIST: + path = getListPath({ + inspectedPipelineName: params.pipelineId, + }); + break; + case INGEST_PIPELINES_PAGES.CLONE: + path = getClonePath({ + clonedPipelineName: params.pipelineId, + }); + break; + } + + return { + ...location, + path: path === '/' ? location.path : location.path + path, + }; + }; +} diff --git a/x-pack/plugins/ingest_pipelines/public/plugin.ts b/x-pack/plugins/ingest_pipelines/public/plugin.ts index 4a138a12d6819f..b4eb33162a1f4c 100644 --- a/x-pack/plugins/ingest_pipelines/public/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/public/plugin.ts @@ -11,7 +11,7 @@ import { CoreSetup, Plugin } from 'src/core/public'; import { PLUGIN_ID } from '../common/constants'; import { uiMetricService, apiService } from './application/services'; import { SetupDependencies, StartDependencies } from './types'; -import { registerUrlGenerator } from './url_generator'; +import { IngestPipelinesLocatorDefinition } from './locator'; export class IngestPipelinesPlugin implements Plugin { @@ -50,7 +50,11 @@ export class IngestPipelinesPlugin }, }); - registerUrlGenerator(coreSetup, management, share); + share.url.locators.create( + new IngestPipelinesLocatorDefinition({ + managementAppLocator: management.locator, + }) + ); } public start() {} diff --git a/x-pack/plugins/ingest_pipelines/public/url_generator.test.ts b/x-pack/plugins/ingest_pipelines/public/url_generator.test.ts deleted file mode 100644 index dc45f9bc39088e..00000000000000 --- a/x-pack/plugins/ingest_pipelines/public/url_generator.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { IngestPipelinesUrlGenerator, INGEST_PIPELINES_PAGES } from './url_generator'; - -describe('IngestPipelinesUrlGenerator', () => { - const getAppBasePath = (absolute: boolean = false) => { - if (absolute) { - return Promise.resolve('http://localhost/app/test_app'); - } - return Promise.resolve('/app/test_app'); - }; - const urlGenerator = new IngestPipelinesUrlGenerator(getAppBasePath); - - describe('Pipelines List', () => { - it('generates relative url for list without pipelineId', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.LIST, - }); - expect(url).toBe('/app/test_app/'); - }); - - it('generates absolute url for list without pipelineId', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.LIST, - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/'); - }); - it('generates relative url for list with a pipelineId', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.LIST, - pipelineId: 'pipeline_name', - }); - expect(url).toBe('/app/test_app/?pipeline=pipeline_name'); - }); - - it('generates absolute url for list with a pipelineId', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.LIST, - pipelineId: 'pipeline_name', - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/?pipeline=pipeline_name'); - }); - }); - - describe('Pipeline Edit', () => { - it('generates relative url for pipeline edit', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.EDIT, - pipelineId: 'pipeline_name', - }); - expect(url).toBe('/app/test_app/edit/pipeline_name'); - }); - - it('generates absolute url for pipeline edit', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.EDIT, - pipelineId: 'pipeline_name', - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/edit/pipeline_name'); - }); - }); - - describe('Pipeline Clone', () => { - it('generates relative url for pipeline clone', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.CLONE, - pipelineId: 'pipeline_name', - }); - expect(url).toBe('/app/test_app/create/pipeline_name'); - }); - - it('generates absolute url for pipeline clone', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.CLONE, - pipelineId: 'pipeline_name', - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/create/pipeline_name'); - }); - }); - - describe('Pipeline Create', () => { - it('generates relative url for pipeline create', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.CREATE, - pipelineId: 'pipeline_name', - }); - expect(url).toBe('/app/test_app/create'); - }); - - it('generates absolute url for pipeline create', async () => { - const url = await urlGenerator.createUrl({ - page: INGEST_PIPELINES_PAGES.CREATE, - pipelineId: 'pipeline_name', - absolute: true, - }); - expect(url).toBe('http://localhost/app/test_app/create'); - }); - }); -}); diff --git a/x-pack/plugins/ingest_pipelines/public/url_generator.ts b/x-pack/plugins/ingest_pipelines/public/url_generator.ts deleted file mode 100644 index d9a77addcd5fd8..00000000000000 --- a/x-pack/plugins/ingest_pipelines/public/url_generator.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreSetup } from 'src/core/public'; -import { MANAGEMENT_APP_ID } from '../../../../src/plugins/management/public'; -import { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public'; -import { - getClonePath, - getCreatePath, - getEditPath, - getListPath, -} from './application/services/navigation'; -import { SetupDependencies } from './types'; -import { PLUGIN_ID } from '../common/constants'; - -export const INGEST_PIPELINES_APP_ULR_GENERATOR = 'INGEST_PIPELINES_APP_URL_GENERATOR'; - -export enum INGEST_PIPELINES_PAGES { - LIST = 'pipelines_list', - EDIT = 'pipeline_edit', - CREATE = 'pipeline_create', - CLONE = 'pipeline_clone', -} - -interface UrlGeneratorState { - pipelineId: string; - absolute?: boolean; -} -export interface PipelinesListUrlGeneratorState extends Partial { - page: INGEST_PIPELINES_PAGES.LIST; -} - -export interface PipelineEditUrlGeneratorState extends UrlGeneratorState { - page: INGEST_PIPELINES_PAGES.EDIT; -} - -export interface PipelineCloneUrlGeneratorState extends UrlGeneratorState { - page: INGEST_PIPELINES_PAGES.CLONE; -} - -export interface PipelineCreateUrlGeneratorState extends UrlGeneratorState { - page: INGEST_PIPELINES_PAGES.CREATE; -} - -export type IngestPipelinesUrlGeneratorState = - | PipelinesListUrlGeneratorState - | PipelineEditUrlGeneratorState - | PipelineCloneUrlGeneratorState - | PipelineCreateUrlGeneratorState; - -export class IngestPipelinesUrlGenerator - implements UrlGeneratorsDefinition { - constructor(private readonly getAppBasePath: (absolute: boolean) => Promise) {} - - public readonly id = INGEST_PIPELINES_APP_ULR_GENERATOR; - - public readonly createUrl = async (state: IngestPipelinesUrlGeneratorState): Promise => { - switch (state.page) { - case INGEST_PIPELINES_PAGES.EDIT: { - return `${await this.getAppBasePath(!!state.absolute)}${getEditPath({ - pipelineName: state.pipelineId, - })}`; - } - case INGEST_PIPELINES_PAGES.CREATE: { - return `${await this.getAppBasePath(!!state.absolute)}${getCreatePath()}`; - } - case INGEST_PIPELINES_PAGES.LIST: { - return `${await this.getAppBasePath(!!state.absolute)}${getListPath({ - inspectedPipelineName: state.pipelineId, - })}`; - } - case INGEST_PIPELINES_PAGES.CLONE: { - return `${await this.getAppBasePath(!!state.absolute)}${getClonePath({ - clonedPipelineName: state.pipelineId, - })}`; - } - } - }; -} - -export const registerUrlGenerator = ( - coreSetup: CoreSetup, - management: SetupDependencies['management'], - share: SetupDependencies['share'] -) => { - const getAppBasePath = async (absolute = false) => { - const [coreStart] = await coreSetup.getStartServices(); - return coreStart.application.getUrlForApp(MANAGEMENT_APP_ID, { - path: management.sections.section.ingest.getApp(PLUGIN_ID)!.basePath, - absolute: !!absolute, - }); - }; - - share.urlGenerators.registerUrlGenerator(new IngestPipelinesUrlGenerator(getAppBasePath)); -}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 52488cb32ae837..0e2ba5ce8ad59f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -1370,6 +1370,57 @@ describe('editor_frame', () => { }) ); }); + + it('should avoid completely to compute suggestion when in fullscreen mode', async () => { + const props = { + ...getDefaultProps(), + initialContext: { + indexPatternId: '1', + fieldName: 'test', + }, + visualizationMap: { + testVis: mockVisualization, + }, + datasourceMap: { + testDatasource: mockDatasource, + testDatasource2: mockDatasource2, + }, + + ExpressionRenderer: expressionRendererMock, + }; + + const { instance: el } = await mountWithProvider( + , + props.plugins.data + ); + instance = el; + + expect( + instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement + ).not.toBeUndefined(); + + await act(async () => { + (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ + type: 'TOGGLE_FULLSCREEN', + }); + }); + + instance.update(); + + expect(instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement).toBe(false); + + await act(async () => { + (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ + type: 'TOGGLE_FULLSCREEN', + }); + }); + + instance.update(); + + expect( + instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement + ).not.toBeUndefined(); + }); }); describe('passing state back to the caller', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index cc65bb126d2d9e..bd96682f427fa6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -452,7 +452,8 @@ export function EditorFrame(props: EditorFrameProps) { ) } suggestionsPanel={ - allLoaded && ( + allLoaded && + !state.isFullscreenDatasource && ( - {i18n.translate('xpack.lens.indexPattern.terms.orderDirection', { - defaultMessage: 'Rank direction', - })}{' '} - - - } + label={i18n.translate('xpack.lens.indexPattern.terms.orderDirection', { + defaultMessage: 'Rank direction', + })} display="columnCompressed" fullWidth > diff --git a/x-pack/plugins/ml/common/types/results.ts b/x-pack/plugins/ml/common/types/results.ts index fa40cefcaed48d..74d32864385889 100644 --- a/x-pack/plugins/ml/common/types/results.ts +++ b/x-pack/plugins/ml/common/types/results.ts @@ -6,6 +6,7 @@ */ import { estypes } from '@elastic/elasticsearch'; +import { LineAnnotationDatum, RectAnnotationDatum } from '@elastic/charts'; export interface GetStoppedPartitionResult { jobs: string[] | Record; @@ -13,6 +14,9 @@ export interface GetStoppedPartitionResult { export interface GetDatafeedResultsChartDataResult { bucketResults: number[][]; datafeedResults: number[][]; + annotationResultsRect: RectAnnotationDatum[]; + annotationResultsLine: LineAnnotationDatum[]; + modelSnapshotResultsLine: LineAnnotationDatum[]; } export interface DatafeedResultsChartDataParams { diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index afed7e79ff757f..b68e64a5d9f6ae 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -494,13 +494,13 @@ class AnnotationsTableUI extends Component { render: (annotation) => { const viewDataFeedText = ( ); const viewDataFeedTooltipAriaLabelText = i18n.translate( - 'xpack.ml.annotationsTable.viewDatafeedTooltipAriaLabel', - { defaultMessage: 'View datafeed' } + 'xpack.ml.annotationsTable.datafeedChartTooltipAriaLabel', + { defaultMessage: 'Datafeed chart' } ); return ( ) : null} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx index 88ffaa0da7fdcd..93be45bbdaf978 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx @@ -114,10 +114,7 @@ export const ExpandedRow: FC = ({ item }) => { } const { - services: { - share, - application: { navigateToUrl }, - }, + services: { share }, } = useMlKibana(); const tabs = [ @@ -402,17 +399,16 @@ export const ExpandedRow: FC = ({ item }) => { { - const ingestPipelinesAppUrlGenerator = share.urlGenerators.getUrlGenerator( - 'INGEST_PIPELINES_APP_URL_GENERATOR' - ); - await navigateToUrl( - await ingestPipelinesAppUrlGenerator.createUrl({ - page: 'pipeline_edit', - pipelineId: pipelineName, - absolute: true, - }) + onClick={() => { + const locator = share.url.locators.get( + 'INGEST_PIPELINES_APP_LOCATOR' ); + if (!locator) return; + locator.navigate({ + page: 'pipeline_edit', + pipelineId: pipelineName, + absolute: true, + }); }} > void; + onClose: () => void; +} + +function setLineAnnotationHeader(lineDatum: LineAnnotationDatum) { + lineDatum.header = dateFormatter(lineDatum.dataValue); + return lineDatum; } export const DatafeedModal: FC = ({ jobId, end, onClose }) => { @@ -68,11 +83,17 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = isInitialized: boolean; }>({ datafeedConfig: undefined, bucketSpan: undefined, isInitialized: false }); const [endDate, setEndDate] = useState(moment(end)); - const [interval, setInterval] = useState(); const [selectedTabId, setSelectedTabId] = useState(TAB_IDS.CHART); const [isLoadingChartData, setIsLoadingChartData] = useState(false); const [bucketData, setBucketData] = useState([]); + const [annotationData, setAnnotationData] = useState<{ + rect: RectAnnotationDatum[]; + line: LineAnnotationDatum[]; + }>({ rect: [], line: [] }); + const [modelSnapshotData, setModelSnapshotData] = useState([]); const [sourceData, setSourceData] = useState([]); + const [showAnnotations, setShowAnnotations] = useState(true); + const [showModelSnapshots, setShowModelSnapshots] = useState(true); const { results: { getDatafeedResultChartData }, @@ -102,25 +123,30 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = const handleChange = (date: moment.Moment) => setEndDate(date); const handleEndDateChange = (direction: ChartDirectionType) => { - if (interval === undefined) return; + if (data.bucketSpan === undefined) return; const newEndDate = endDate.clone(); - const [count, type] = interval.split(' '); + const unitMatch = data.bucketSpan.match(/[d | h| m | s]/g)!; + const unit = unitMatch[0]; + const count = Number(data.bucketSpan.replace(/[^0-9]/g, '')); if (direction === CHART_DIRECTION.FORWARD) { - newEndDate.add(Number(count), type); + newEndDate.add(MAX_CHART_POINTS * count, unit); } else { - newEndDate.subtract(Number(count), type); + newEndDate.subtract(MAX_CHART_POINTS * count, unit); } setEndDate(newEndDate); }; const getChartData = useCallback(async () => { - if (interval === undefined) return; + if (data.bucketSpan === undefined) return; const endTimestamp = moment(endDate).valueOf(); - const [count, type] = interval.split(' '); - const startMoment = endDate.clone().subtract(Number(count), type); + const unitMatch = data.bucketSpan.match(/[d | h| m | s]/g)!; + const unit = unitMatch[0]; + const count = Number(data.bucketSpan.replace(/[^0-9]/g, '')); + // STARTTIME = ENDTIME - (BucketSpan * MAX_CHART_POINTS) + const startMoment = endDate.clone().subtract(MAX_CHART_POINTS * count, unit); const startTimestamp = moment(startMoment).valueOf(); try { @@ -128,6 +154,11 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = setSourceData(chartData.datafeedResults); setBucketData(chartData.bucketResults); + setAnnotationData({ + rect: chartData.annotationResultsRect, + line: chartData.annotationResultsLine.map(setLineAnnotationHeader), + }); + setModelSnapshotData(chartData.modelSnapshotResultsLine.map(setLineAnnotationHeader)); } catch (error) { const title = i18n.translate('xpack.ml.jobsList.datafeedModal.errorToastTitle', { defaultMessage: 'Error fetching data', @@ -135,7 +166,7 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = displayErrorToast(error, title); } setIsLoadingChartData(false); - }, [endDate, interval]); + }, [endDate, data.bucketSpan]); const getJobData = async () => { try { @@ -145,11 +176,6 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = bucketSpan: job.analysis_config.bucket_span, isInitialized: true, }); - const intervalOptions = getIntervalOptions(job.analysis_config.bucket_span); - const initialInterval = intervalOptions.length - ? intervalOptions[intervalOptions.length - 1] - : undefined; - setInterval(initialInterval?.value || '72 hours'); } catch (error) { displayErrorToast(error); } @@ -161,20 +187,17 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = useEffect( function loadChartData() { - if (interval !== undefined) { + if (data.bucketSpan !== undefined) { setIsLoadingChartData(true); getChartData(); } }, - [endDate, interval] + [endDate, data.bucketSpan] ); const { datafeedConfig, bucketSpan, isInitialized } = data; - - const intervalOptions = useMemo(() => { - if (bucketSpan === undefined) return []; - return getIntervalOptions(bucketSpan); - }, [bucketSpan]); + const checkboxIdAnnotation = useMemo(() => htmlIdGenerator()(), []); + const checkboxIdModelSnapshot = useMemo(() => htmlIdGenerator()(), []); return ( = ({ jobId, end, onClose }) = - + + + + } + /> + + + +

+ +

+
+
+
= ({ jobId, end, onClose }) = - - setInterval(e.target.value)} - aria-label={i18n.translate( - 'xpack.ml.jobsList.datafeedModal.intervalSelection', - { - defaultMessage: 'Datafeed modal chart interval selection', - } - )} - /> - = ({ jobId, end, onClose }) = isEnabled={datafeedConfig.state === DATAFEED_STATE.STOPPED} /> + + + + + + + } + checked={showAnnotations} + onChange={() => setShowAnnotations(!showAnnotations)} + /> + + + + + + } + checked={showModelSnapshots} + onChange={() => setShowModelSnapshots(!showModelSnapshots)} + /> + + + @@ -298,7 +362,65 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = })} position={Position.Left} /> + {showModelSnapshots ? ( + } + markerPosition={Position.Top} + style={{ + line: { + strokeWidth: 3, + stroke: euiTheme.euiColorVis1, + opacity: 0.5, + }, + }} + /> + ) : null} + {showAnnotations ? ( + <> + } + markerPosition={Position.Top} + style={{ + line: { + strokeWidth: 3, + stroke: euiTheme.euiColorDangerText, + opacity: 0.5, + }, + }} + /> + + + ) : null} = ({ jobId, end, onClose }) = curve={CurveType.LINEAR} /> { - const unitMatch = bucketSpan.match(/[d | h| m | s]/g)!; - const unit = unitMatch[0]; - const count = Number(bucketSpan.replace(/[^0-9]/g, '')); - - const intervalOptions = []; - - if (['s', 'ms', 'micros', 'nanos'].includes(unit)) { - intervalOptions.push( - { - value: '1 hour', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.1hourOption', { - defaultMessage: '{count} hour', - values: { count: 1 }, - }), - }, - { - value: '2 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.2hourOption', { - defaultMessage: '{count} hours', - values: { count: 2 }, - }), - } - ); - } - - if ((unit === 'm' && count <= 4) || unit === 'h') { - intervalOptions.push( - { - value: '3 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.3hourOption', { - defaultMessage: '{count} hours', - values: { count: 3 }, - }), - }, - { - value: '8 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.8hourOption', { - defaultMessage: '{count} hours', - values: { count: 8 }, - }), - }, - { - value: '12 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.12hourOption', { - defaultMessage: '{count} hours', - values: { count: 12 }, - }), - }, - { - value: '24 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.24hourOption', { - defaultMessage: '{count} hours', - values: { count: 24 }, - }), - } - ); - } - - if ((unit === 'm' && count >= 5 && count <= 15) || unit === 'h') { - intervalOptions.push( - { - value: '48 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.48hourOption', { - defaultMessage: '{count} hours', - values: { count: 48 }, - }), - }, - { - value: '72 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.72hourOption', { - defaultMessage: '{count} hours', - values: { count: 72 }, - }), - } - ); - } - - if ((unit === 'm' && count >= 10 && count <= 15) || unit === 'h' || unit === 'd') { - intervalOptions.push( - { - value: '5 days', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.5daysOption', { - defaultMessage: '{count} days', - values: { count: 5 }, - }), - }, - { - value: '7 days', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.7daysOption', { - defaultMessage: '{count} days', - values: { count: 7 }, - }), - } - ); - } - - if (unit === 'h' || unit === 'd') { - intervalOptions.push({ - value: '14 days', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.14DaysOption', { - defaultMessage: '{count} days', - values: { count: 14 }, - }), - }); - } - - return intervalOptions; -}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js index b514c8433daf48..d3856e6afa3982 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js @@ -7,26 +7,29 @@ import PropTypes from 'prop-types'; import React, { Component, Fragment } from 'react'; - -import { EuiTabbedContent, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonIcon, EuiTabbedContent, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import { extractJobDetails } from './extract_job_details'; import { JsonPane } from './json_tab'; import { DatafeedPreviewPane } from './datafeed_preview_tab'; import { AnnotationsTable } from '../../../../components/annotations/annotations_table'; +import { DatafeedModal } from '../datafeed_modal'; import { AnnotationFlyout } from '../../../../components/annotations/annotation_flyout'; import { ModelSnapshotTable } from '../../../../components/model_snapshots'; import { ForecastsTable } from './forecasts_table'; import { JobDetailsPane } from './job_details_pane'; import { JobMessagesPane } from './job_messages_pane'; -import { i18n } from '@kbn/i18n'; import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; export class JobDetailsUI extends Component { constructor(props) { super(props); - this.state = {}; + this.state = { + datafeedModalVisible: false, + }; if (this.props.addYourself) { this.props.addYourself(props.jobId, (j) => this.updateJob(j)); } @@ -77,6 +80,30 @@ export class JobDetailsUI extends Component { alertRules, } = extractJobDetails(job, basePath, refreshJobList); + datafeed.titleAction = ( + + } + > + + this.setState({ + datafeedModalVisible: true, + }) + } + /> + + ); + const tabs = [ { id: 'job-settings', @@ -105,6 +132,32 @@ export class JobDetailsUI extends Component { /> ), }, + { + id: 'datafeed', + 'data-test-subj': 'mlJobListTab-datafeed', + name: i18n.translate('xpack.ml.jobsList.jobDetails.tabs.datafeedLabel', { + defaultMessage: 'Datafeed', + }), + content: ( + <> + + {this.props.jobId && this.state.datafeedModalVisible ? ( + { + this.setState({ + datafeedModalVisible: false, + }); + }} + end={job.data_counts.latest_bucket_timestamp} + jobId={this.props.jobId} + /> + ) : null} + + ), + }, { id: 'counts', 'data-test-subj': 'mlJobListTab-counts', @@ -137,21 +190,6 @@ export class JobDetailsUI extends Component { ]; if (showFullDetails && datafeed.items.length) { - // Datafeed should be at index 2 in tabs array for full details - tabs.splice(2, 0, { - id: 'datafeed', - 'data-test-subj': 'mlJobListTab-datafeed', - name: i18n.translate('xpack.ml.jobsList.jobDetails.tabs.datafeedLabel', { - defaultMessage: 'Datafeed', - }), - content: ( - - ), - }); - tabs.push( { id: 'datafeed-preview', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details_pane.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details_pane.js index 49d9bcde490520..4046f4d5d80712 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details_pane.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details_pane.js @@ -9,6 +9,8 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { + EuiFlexGroup, + EuiFlexItem, EuiTitle, EuiTable, EuiTableBody, @@ -42,9 +44,14 @@ function Section({ section }) { return ( - -

{section.title}

-
+ + + +

{section.title}

+
+
+ {section.titleAction} +
diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts index 19ba5aa304bf04..25ef36782207f1 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts @@ -6,7 +6,10 @@ */ // Service for obtaining data for the ML Results dashboards. -import { GetStoppedPartitionResult } from '../../../../common/types/results'; +import { + GetStoppedPartitionResult, + GetDatafeedResultsChartDataResult, +} from '../../../../common/types/results'; import { HttpService } from '../http_service'; import { basePath } from './index'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; @@ -148,7 +151,7 @@ export const resultsApiProvider = (httpService: HttpService) => ({ start, end, }); - return httpService.http({ + return httpService.http({ path: `${basePath()}/results/datafeed_results_chart`, method: 'POST', body, 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 9413ee00184d20..81ee394b997044 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 @@ -27,6 +27,7 @@ import { import { MlJobsResponse } from '../../../common/types/job_service'; import type { MlClient } from '../../lib/ml_client'; import { datafeedsProvider } from '../job_service/datafeeds'; +import { annotationServiceProvider } from '../annotation_service'; // Service for carrying out Elasticsearch queries to obtain data for the // ML Results dashboards. @@ -620,13 +621,19 @@ export function resultsServiceProvider(mlClient: MlClient, client?: IScopedClust const finalResults: GetDatafeedResultsChartDataResult = { bucketResults: [], datafeedResults: [], + annotationResultsRect: [], + annotationResultsLine: [], + modelSnapshotResultsLine: [], }; const { getDatafeedByJobId } = datafeedsProvider(client!, mlClient); - const datafeedConfig = await getDatafeedByJobId(jobId); - const { body: jobsResponse } = await mlClient.getJobs({ job_id: jobId }); - if (jobsResponse.count === 0 || jobsResponse.jobs === undefined) { + const [datafeedConfig, { body: jobsResponse }] = await Promise.all([ + getDatafeedByJobId(jobId), + mlClient.getJobs({ job_id: jobId }), + ]); + + if (jobsResponse && (jobsResponse.count === 0 || jobsResponse.jobs === undefined)) { throw Boom.notFound(`Job with the id "${jobId}" not found`); } @@ -696,10 +703,25 @@ export function resultsServiceProvider(mlClient: MlClient, client?: IScopedClust ]) || []; } - const bucketResp = await mlClient.getBuckets({ - job_id: jobId, - body: { desc: true, start: String(start), end: String(end), page: { from: 0, size: 1000 } }, - }); + const { getAnnotations } = annotationServiceProvider(client!); + + const [bucketResp, annotationResp, { body: modelSnapshotsResp }] = await Promise.all([ + mlClient.getBuckets({ + job_id: jobId, + body: { desc: true, start: String(start), end: String(end), page: { from: 0, size: 1000 } }, + }), + getAnnotations({ + jobIds: [jobId], + earliestMs: start, + latestMs: end, + maxAnnotations: 1000, + }), + mlClient.getModelSnapshots({ + job_id: jobId, + start: String(start), + end: String(end), + }), + ]); const bucketResults = bucketResp?.body?.buckets ?? []; bucketResults.forEach((dataForTime) => { @@ -708,6 +730,36 @@ export function resultsServiceProvider(mlClient: MlClient, client?: IScopedClust finalResults.bucketResults.push([timestamp, eventCount]); }); + const annotationResults = annotationResp.annotations[jobId] || []; + annotationResults.forEach((annotation) => { + const timestamp = Number(annotation?.timestamp); + const endTimestamp = Number(annotation?.end_timestamp); + if (timestamp === endTimestamp) { + finalResults.annotationResultsLine.push({ + dataValue: timestamp, + details: annotation.annotation, + }); + } else { + finalResults.annotationResultsRect.push({ + coordinates: { + x0: timestamp, + x1: endTimestamp, + }, + details: annotation.annotation, + }); + } + }); + + const modelSnapshots = modelSnapshotsResp?.model_snapshots ?? []; + modelSnapshots.forEach((modelSnapshot) => { + const timestamp = Number(modelSnapshot?.timestamp); + + finalResults.modelSnapshotResultsLine.push({ + dataValue: timestamp, + details: modelSnapshot.description, + }); + }); + return finalResults; } diff --git a/x-pack/plugins/osquery/server/usage/fetchers.ts b/x-pack/plugins/osquery/server/usage/fetchers.ts index 6a4236b5adccd3..3d5f3592101fd2 100644 --- a/x-pack/plugins/osquery/server/usage/fetchers.ts +++ b/x-pack/plugins/osquery/server/usage/fetchers.ts @@ -56,6 +56,7 @@ export async function getPolicyLevelUsage( }, }, index: '.fleet-agents', + ignore_unavailable: true, }); const policied = agentResponse.body.aggregations?.policied as AggregationsSingleBucketAggregate; if (policied && typeof policied.doc_count === 'number') { @@ -118,6 +119,7 @@ export async function getLiveQueryUsage( }, }, index: '.fleet-actions', + ignore_unavailable: true, }); const result: LiveQueryUsage = { session: await getRouteMetric(soClient, 'live_query'), @@ -226,6 +228,7 @@ export async function getBeatUsage(esClient: ElasticsearchClient) { }, }, index: METRICS_INDICES, + ignore_unavailable: true, }); return extractBeatUsageMetrics(metricResponse); diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 99753242e76279..dfaad68e295ebd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -58,7 +58,6 @@ export interface ActivityLogActionResponse { } export type ActivityLogEntry = ActivityLogAction | ActivityLogActionResponse; export interface ActivityLog { - total: number; page: number; pageSize: number; data: ActivityLogEntry[]; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index b20b1501eecc57..a9a81aa285af7c 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -15,6 +15,7 @@ const allowedExperimentalValues = Object.freeze({ trustedAppsByPolicyEnabled: false, metricsEntitiesEnabled: false, ruleRegistryEnabled: false, + tGridEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/common/index.ts b/x-pack/plugins/security_solution/common/index.ts index 1fec1c76430ebd..e6d7bcc9bd506c 100644 --- a/x-pack/plugins/security_solution/common/index.ts +++ b/x-pack/plugins/security_solution/common/index.ts @@ -4,3 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + +export * from './types'; +export * from './search_strategy'; +export * from './utility_types'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts index 4fcfbdac3c1b4c..095ba4ca20afca 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts @@ -4,52 +4,27 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { estypes } from '@elastic/elasticsearch'; import { IEsSearchResponse } from '../../../../../../src/plugins/data/common'; +export type { + Inspect, + SortField, + TimerangeInput, + PaginationInputPaginated, + DocValueFields, + CursorType, + TotalValue, +} from '../../../../timelines/common'; +export { Direction } from '../../../../timelines/common'; export type Maybe = T | null; export type SearchHit = IEsSearchResponse['rawResponse']['hits']['hits'][0]; -export interface TotalValue { - value: number; - relation: string; -} - -export interface Inspect { - dsl: string[]; -} - export interface PageInfoPaginated { activePage: number; fakeTotalCount: number; showMorePagesIndicator: boolean; } - -export interface CursorType { - value?: Maybe; - tiebreaker?: Maybe; -} - -export enum Direction { - asc = 'asc', - desc = 'desc', -} - -export interface SortField { - field: Field; - direction: Direction; -} - -export interface TimerangeInput { - /** The interval string to use for last bucket. The format is '{value}{unit}'. For example '5m' would return the metrics for the last 5 minutes of the timespan. */ - interval: string; - /** The end of the timerange */ - to: string; - /** The beginning of the timerange */ - from: string; -} - export interface PaginationInput { /** The limit parameter allows you to configure the maximum amount of items to be returned */ limit: number; @@ -59,19 +34,6 @@ export interface PaginationInput { tiebreaker?: Maybe; } -export interface PaginationInputPaginated { - /** The activePage parameter defines the page of results you want to fetch */ - activePage: number; - /** The cursorStart parameter defines the start of the results to be displayed */ - cursorStart: number; - /** The fakePossibleCount parameter determines the total count in order to show 5 additional pages */ - fakePossibleCount: number; - /** The querySize parameter is the number of items to be returned */ - querySize: number; -} - -export type DocValueFields = estypes.SearchDocValueField; - export interface Explanation { value: number; description: string; @@ -111,13 +73,3 @@ export interface GenericBuckets { } export type StringOrNumber = string | number; - -export interface TimerangeFilter { - range: { - [timestamp: string]: { - gte: string; - lte: string; - format: string; - }; - }; -} diff --git a/x-pack/plugins/security_solution/common/search_strategy/index.ts b/x-pack/plugins/security_solution/common/search_strategy/index.ts index 575256b991d163..e3d6736878063b 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/index.ts @@ -8,3 +8,4 @@ export * from './common'; export * from './security_solution'; export * from './timeline'; +export * from './index_fields'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts index d747758640fab2..4e5f8af41a2ef0 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts @@ -5,37 +5,10 @@ * 2.0. */ -import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; -import { Ecs } from '../../../../ecs'; -import { CursorType, Inspect, Maybe, PaginationInputPaginated } from '../../../common'; -import { TimelineRequestOptionsPaginated } from '../..'; - -export interface TimelineEdges { - node: TimelineItem; - cursor: CursorType; -} - -export interface TimelineItem { - _id: string; - _index?: Maybe; - data: TimelineNonEcsData[]; - ecs: Ecs; -} - -export interface TimelineNonEcsData { - field: string; - value?: Maybe; -} - -export interface TimelineEventsAllStrategyResponse extends IEsSearchResponse { - edges: TimelineEdges[]; - totalCount: number; - pageInfo: Pick; - inspect?: Maybe; -} - -export interface TimelineEventsAllRequestOptions extends TimelineRequestOptionsPaginated { - fields: string[] | Array<{ field: string; include_unmapped: boolean }>; - fieldRequested: string[]; - language: 'eql' | 'kuery' | 'lucene'; -} +export type { + TimelineEdges, + TimelineItem, + TimelineNonEcsData, + TimelineEventsAllStrategyResponse, + TimelineEventsAllRequestOptions, +} from '../../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts index 4a5bd2c99a0eb6..e4d2ea52ffdff5 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts @@ -5,22 +5,8 @@ * 2.0. */ -import { Ecs } from '../../../../ecs'; -import { CursorType, Maybe } from '../../../common'; - -export interface TimelineEdges { - node: TimelineItem; - cursor: CursorType; -} - -export interface TimelineItem { - _id: string; - _index?: Maybe; - data: TimelineNonEcsData[]; - ecs: Ecs; -} - -export interface TimelineNonEcsData { - field: string; - value?: Maybe; -} +export type { + TimelineEdges, + TimelineItem, + TimelineNonEcsData, +} from '../../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts index 1f9820f8e5c2b0..3fd13e56cc7e7e 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts @@ -5,27 +5,8 @@ * 2.0. */ -import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; -import { Inspect, Maybe } from '../../../common'; -import { TimelineRequestOptionsPaginated } from '../..'; - -export interface TimelineEventsDetailsItem { - ariaRowindex?: Maybe; - category?: string; - field: string; - values?: Maybe; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - originalValue?: Maybe; - isObjectArray: boolean; -} - -export interface TimelineEventsDetailsStrategyResponse extends IEsSearchResponse { - data?: Maybe; - inspect?: Maybe; -} - -export interface TimelineEventsDetailsRequestOptions - extends Partial { - indexName: string; - eventId: string; -} +export type { + TimelineEventsDetailsItem, + TimelineEventsDetailsStrategyResponse, + TimelineEventsDetailsRequestOptions, +} from '../../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts index c508876032fca2..10e9bbd7670cd4 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts @@ -5,43 +5,10 @@ * 2.0. */ -import { EuiComboBoxOptionOption } from '@elastic/eui'; -import { - EqlSearchStrategyRequest, - EqlSearchStrategyResponse, -} from '../../../../../../../../src/plugins/data/common'; -import { Inspect, Maybe, PaginationInputPaginated } from '../../..'; -import { TimelineEdges, TimelineEventsAllRequestOptions } from '../..'; -import { EqlSearchResponse } from '../../../../detection_engine/types'; - -export interface TimelineEqlRequestOptions - extends EqlSearchStrategyRequest, - Omit { - eventCategoryField?: string; - tiebreakerField?: string; - timestampField?: string; - size?: number; -} - -export interface TimelineEqlResponse extends EqlSearchStrategyResponse> { - edges: TimelineEdges[]; - totalCount: number; - pageInfo: Pick; - inspect: Maybe; -} - -export interface EqlOptionsData { - keywordFields: EuiComboBoxOptionOption[]; - dateFields: EuiComboBoxOptionOption[]; - nonDateFields: EuiComboBoxOptionOption[]; -} - -export interface EqlOptionsSelected { - eventCategoryField?: string; - tiebreakerField?: string; - timestampField?: string; - query?: string; - size?: number; -} - -export type FieldsEqlOptions = keyof EqlOptionsSelected; +export type { + TimelineEqlRequestOptions, + TimelineEqlResponse, + EqlOptionsData, + EqlOptionsSelected, + FieldsEqlOptions, +} from '../../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts index f29dc4a3c74509..39f23a63c8afea 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts @@ -5,38 +5,11 @@ * 2.0. */ -import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; -import { Inspect, Maybe } from '../../../common'; -import { TimelineRequestBasicOptions } from '../..'; - -export enum LastEventIndexKey { - hostDetails = 'hostDetails', - hosts = 'hosts', - ipDetails = 'ipDetails', - network = 'network', -} - -export interface LastTimeDetails { - hostName?: Maybe; - ip?: Maybe; -} - -export interface TimelineEventsLastEventTimeStrategyResponse extends IEsSearchResponse { - lastSeen: Maybe; - inspect?: Maybe; -} - -export interface TimelineKpiStrategyResponse extends IEsSearchResponse { - destinationIpCount: number; - inspect?: Maybe; - hostCount: number; - processCount: number; - sourceIpCount: number; - userCount: number; -} - -export interface TimelineEventsLastEventTimeRequestOptions - extends Omit { - indexKey: LastEventIndexKey; - details: LastTimeDetails; -} +export { LastEventIndexKey } from '../../../../../../timelines/common'; + +export type { + LastTimeDetails, + TimelineEventsLastEventTimeStrategyResponse, + TimelineKpiStrategyResponse, + TimelineEventsLastEventTimeRequestOptions, +} from '../../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts index 9c2c23eb334a3d..7064ef033fc5a0 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts @@ -24,7 +24,12 @@ import { SortField, Maybe, } from '../common'; -import { DataProviderType, TimelineType, TimelineStatus } from '../../types/timeline'; +import { + DataProviderType, + TimelineType, + TimelineStatus, + RowRendererId, +} from '../../types/timeline'; export * from './events'; @@ -165,25 +170,6 @@ export interface SortTimelineInput { sortDirection?: Maybe; } -export enum RowRendererId { - alerts = 'alerts', - auditd = 'auditd', - auditd_file = 'auditd_file', - library = 'library', - netflow = 'netflow', - plain = 'plain', - registry = 'registry', - suricata = 'suricata', - system = 'system', - system_dns = 'system_dns', - system_endgame_process = 'system_endgame_process', - system_file = 'system_file', - system_fim = 'system_fim', - system_security_event = 'system_security_event', - system_socket = 'system_socket', - zeek = 'zeek', -} - export interface TimelineInput { columns?: Maybe; dataProviders?: Maybe; diff --git a/x-pack/plugins/index_management/public/application/components/page_error/index.ts b/x-pack/plugins/security_solution/common/types/index.ts similarity index 80% rename from x-pack/plugins/index_management/public/application/components/page_error/index.ts rename to x-pack/plugins/security_solution/common/types/index.ts index 040edfa362c636..9464a33082a495 100644 --- a/x-pack/plugins/index_management/public/application/components/page_error/index.ts +++ b/x-pack/plugins/security_solution/common/types/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { PageErrorForbidden } from './page_error_forbidden'; +export * from './timeline'; diff --git a/x-pack/plugins/security_solution/common/types/timeline/actions/index.ts b/x-pack/plugins/security_solution/common/types/timeline/actions/index.ts new file mode 100644 index 00000000000000..782af107417c2a --- /dev/null +++ b/x-pack/plugins/security_solution/common/types/timeline/actions/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export type { + ActionProps, + HeaderActionProps, + GenericActionRowCellRenderProps, + HeaderCellRender, + RowCellRender, + ControlColumnProps, +} from '../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts b/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts new file mode 100644 index 00000000000000..83b0ced332a62c --- /dev/null +++ b/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { CellValueElementProps } from '../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/types/timeline/columns/index.ts b/x-pack/plugins/security_solution/common/types/timeline/columns/index.ts new file mode 100644 index 00000000000000..ee4d621e35d6cd --- /dev/null +++ b/x-pack/plugins/security_solution/common/types/timeline/columns/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + ColumnHeaderType, + ColumnId, + ColumnHeaderOptions, + ColumnRenderer, +} from '../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/types/timeline/data_provider/index.ts b/x-pack/plugins/security_solution/common/types/timeline/data_provider/index.ts new file mode 100644 index 00000000000000..f363176ac0a88c --- /dev/null +++ b/x-pack/plugins/security_solution/common/types/timeline/data_provider/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export { IS_OPERATOR, EXISTS_OPERATOR } from '../../../../../timelines/common'; + +export type { + QueryOperator, + DataProviderType, + QueryMatch, + DataProvider, + DataProvidersAnd, +} from '../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 7ae52a3990ff7d..05cf99195774b9 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -23,6 +23,13 @@ import { FlowTarget } from '../../search_strategy/security_solution/network'; import { errorSchema } from '../../detection_engine/schemas/response/error_schema'; import { Direction, Maybe } from '../../search_strategy'; +export * from './actions'; +export * from './cells'; +export * from './columns'; +export * from './data_provider'; +export * from './rows'; +export * from './store'; + /* * ColumnHeader Types */ @@ -492,6 +499,11 @@ export type TimelineExpandedDetail = { [tab in TimelineTabs]?: TimelineExpandedDetailType; }; +export type ToggleDetailPanel = TimelineExpandedDetailType & { + tabType?: TimelineTabs; + timelineId: string; +}; + export const pageInfoTimeline = runtimeTypes.type({ pageIndex: runtimeTypes.number, pageSize: runtimeTypes.number, diff --git a/x-pack/plugins/security_solution/common/types/timeline/rows/index.ts b/x-pack/plugins/security_solution/common/types/timeline/rows/index.ts new file mode 100644 index 00000000000000..ae2d19a5e2ca8c --- /dev/null +++ b/x-pack/plugins/security_solution/common/types/timeline/rows/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export type { RowRenderer } from '../../../../../timelines/common'; diff --git a/x-pack/plugins/security_solution/common/types/timeline/store.ts b/x-pack/plugins/security_solution/common/types/timeline/store.ts new file mode 100644 index 00000000000000..01fc9db7c8e1da --- /dev/null +++ b/x-pack/plugins/security_solution/common/types/timeline/store.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ColumnHeaderOptions, + ColumnId, + RowRendererId, + TimelineExpandedDetail, + TimelineTypeLiteral, +} from '.'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Filter } from '../../../../../../src/plugins/data/public'; + +import { Direction } from '../../search_strategy'; +import { DataProvider } from './data_provider'; + +export type KueryFilterQueryKind = 'kuery' | 'lucene' | 'eql'; + +export interface KueryFilterQuery { + kind: KueryFilterQueryKind; + expression: string; +} + +export interface SerializedFilterQuery { + kuery: KueryFilterQuery | null; + serializedQuery: string; +} + +export type SortDirection = 'none' | 'asc' | 'desc' | Direction; +export interface SortColumnTimeline { + columnId: string; + columnType: string; + sortDirection: SortDirection; +} + +export interface TimelinePersistInput { + id: string; + dataProviders?: DataProvider[]; + dateRange?: { + start: string; + end: string; + }; + excludedRowRendererIds?: RowRendererId[]; + expandedDetail?: TimelineExpandedDetail; + filters?: Filter[]; + columns: ColumnHeaderOptions[]; + itemsPerPage?: number; + indexNames: string[]; + kqlQuery?: { + filterQuery: SerializedFilterQuery | null; + }; + show?: boolean; + sort?: SortColumnTimeline[]; + showCheckboxes?: boolean; + timelineType?: TimelineTypeLiteral; + templateTimelineId?: string | null; + templateTimelineVersion?: number | null; +} + +/** Invoked when a column is sorted */ +export type OnColumnSorted = (sorted: { columnId: ColumnId; sortDirection: SortDirection }) => void; + +export type OnColumnsSorted = ( + sorted: Array<{ columnId: ColumnId; sortDirection: SortDirection }> +) => void; + +export type OnColumnRemoved = (columnId: ColumnId) => void; + +export type OnColumnResized = ({ columnId, delta }: { columnId: ColumnId; delta: number }) => void; + +/** Invoked when a user clicks to load more item */ +export type OnChangePage = (nextPage: number) => void; + +/** Invoked when a user checks/un-checks a row */ +export type OnRowSelected = ({ + eventIds, + isSelected, +}: { + eventIds: string[]; + isSelected: boolean; +}) => void; + +/** Invoked when a user checks/un-checks the select all checkbox */ +export type OnSelectAll = ({ isSelected }: { isSelected: boolean }) => void; + +/** Invoked when columns are updated */ +export type OnUpdateColumns = (columns: ColumnHeaderOptions[]) => void; + +/** Invoked when a user pins an event */ +export type OnPinEvent = (eventId: string) => void; + +/** Invoked when a user unpins an event */ +export type OnUnPinEvent = (eventId: string) => void; diff --git a/x-pack/plugins/security_solution/common/utils/field_formatters.test.ts b/x-pack/plugins/security_solution/common/utils/field_formatters.test.ts index b724c0f672b506..64d4f2986903a1 100644 --- a/x-pack/plugins/security_solution/common/utils/field_formatters.test.ts +++ b/x-pack/plugins/security_solution/common/utils/field_formatters.test.ts @@ -7,7 +7,7 @@ import { EventHit, EventSource } from '../search_strategy'; import { getDataFromFieldsHits, getDataFromSourceHits, getDataSafety } from './field_formatters'; -import { eventDetailsFormattedFields, eventHit } from './mock_event_details'; +import { eventDetailsFormattedFields, eventHit } from '@kbn/securitysolution-t-grid'; describe('Events Details Helpers', () => { const fields: EventHit['fields'] = eventHit.fields; diff --git a/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts index 78ee3fdcdcdd50..3ff036fa0107fe 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts @@ -45,7 +45,7 @@ describe('Overview Page', () => { describe('with no data', () => { it('Splash screen should be here', () => { - cy.stubSearchStrategyApi(emptyInstance, undefined, 'securitySolutionIndexFields'); + cy.stubSearchStrategyApi(emptyInstance, undefined, 'indexFields'); loginAndWaitForPage(OVERVIEW_URL); cy.get(OVERVIEW_EMPTY_PAGE).should('be.visible'); }); diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index 90eb9a38d7509c..e74d06cd621fb2 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -35,7 +35,7 @@ Cypress.Commands.add( 'stubSearchStrategyApi', function (stubObject, factoryQueryType, searchStrategyName = 'securitySolutionSearchStrategy') { cy.intercept('POST', '/internal/bsearch', (req) => { - if (searchStrategyName === 'securitySolutionIndexFields') { + if (searchStrategyName === 'indexFields') { req.reply(stubObject.rawResponse); } else if (factoryQueryType === 'overviewHost') { req.reply(stubObject.overviewHost); diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 02dbc56bd33976..e26f0d9b65bfae 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -17,6 +17,7 @@ "inspector", "licensing", "maps", + "timelines", "triggersActionsUi", "uiActions" ], diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index d5f7a25549cf24..c223570c77201c 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -21,7 +21,6 @@ import { GlobalToaster, ManageGlobalToaster } from '../common/components/toaster import { KibanaContextProvider, useKibana, useUiSetting$ } from '../common/lib/kibana'; import { State } from '../common/store'; -import { ManageGlobalTimeline } from '../timelines/components/manage_timeline'; import { StartServices } from '../types'; import { PageRouter } from './routes'; import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; @@ -49,27 +48,25 @@ const StartAppComponent: FC = ({ - - - - - - - - {children} - - - - - - - - - + + + + + + + {children} + + + + + + + + diff --git a/x-pack/plugins/security_solution/public/common/components/accessibility/index.ts b/x-pack/plugins/security_solution/public/common/components/accessibility/index.ts new file mode 100644 index 00000000000000..f05644c85e5364 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/accessibility/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './tooltip_with_keyboard_shortcut'; diff --git a/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/index.tsx b/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/index.tsx index 97922ecdc5b614..2d66b4e93e4dc1 100644 --- a/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/index.tsx @@ -10,7 +10,7 @@ import React from 'react'; import * as i18n from './translations'; -interface Props { +export interface TooltipWithKeyboardShortcutProps { additionalScreenReaderOnlyContext?: string; content: React.ReactNode; shortcut: string; @@ -22,7 +22,7 @@ const TooltipWithKeyboardShortcutComponent = ({ content, shortcut, showShortcut, -}: Props) => ( +}: TooltipWithKeyboardShortcutProps) => ( <>
{content}
{additionalScreenReaderOnlyContext !== '' && ( 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 43d5c66655808b..58cca7bcbd1213 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 @@ -6,12 +6,12 @@ */ import React, { useEffect, useMemo } from 'react'; - +import { useDispatch } from 'react-redux'; +import { timelineActions } from '../../../timelines/store/timeline'; 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 { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import * as i18n from './translations'; @@ -70,22 +70,24 @@ const AlertsTableComponent: React.FC = ({ startDate, pageFilters = [], }) => { + const dispatch = useDispatch(); const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]); const { filterManager } = useKibana().services.data.query; - const { initializeTimeline } = useManageTimeline(); useEffect(() => { - initializeTimeline({ - id: timelineId, - documentType: i18n.ALERTS_DOCUMENT_TYPE, - filterManager, - defaultModel: alertsDefaultModel, - footerText: i18n.TOTAL_COUNT_OF_ALERTS, - title: i18n.ALERTS_TABLE_TITLE, - unit: i18n.UNIT, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + dispatch( + timelineActions.initializeTGridSettings({ + id: timelineId, + documentType: i18n.ALERTS_DOCUMENT_TYPE, + filterManager, + defaultColumns: alertsDefaultModel.columns, + excludedRowRendererIds: alertsDefaultModel.excludedRowRendererIds, + footerText: i18n.TOTAL_COUNT_OF_ALERTS, + title: i18n.ALERTS_TABLE_TITLE, + // TODO: avoid passing this through the store + }) + ); + }, [dispatch, filterManager, timelineId]); return ( { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx index 4958f6bae4a307..175239fcaebe74 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx @@ -14,6 +14,8 @@ import { TestProviders } from '../../mock'; import { DraggableLegendItem, LegendItem } from './draggable_legend_item'; +jest.mock('../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.test.tsx index dc0e24fcba8f5a..bc3b9c3eaa1c62 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.test.tsx @@ -13,6 +13,8 @@ import { TestProviders } from '../../mock'; import { DragDropContextWrapper } from './drag_drop_context_wrapper'; +jest.mock('../../lib/kibana'); + describe('DragDropContextWrapper', () => { describe('rendering', () => { test('it renders against the snapshot', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx index 2a7b870a593bb4..1ab19c44e29b2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -11,6 +11,7 @@ import { DropResult, DragDropContext } from 'react-beautiful-dnd'; import { useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import deepEqual from 'fast-deep-equal'; +import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; import { BeforeCapture } from './drag_drop_context'; import { BrowserFields } from '../../containers/source'; @@ -23,21 +24,24 @@ import { ADDED_TO_TIMELINE_MESSAGE, ADDED_TO_TIMELINE_TEMPLATE_MESSAGE, } from '../../hooks/translations'; -import { useAddToTimelineSensor } from '../../hooks/use_add_to_timeline'; import { displaySuccessToast, useStateToaster } from '../toasters'; import { TimelineId, TimelineType } from '../../../../common/types/timeline'; import { - addFieldToTimelineColumns, addProviderToTimeline, fieldWasDroppedOnTimelineColumns, - getTimelineIdFromColumnDroppableId, + IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME, providerWasDroppedOnTimeline, draggableIsField, userIsReArrangingProviders, } from './helpers'; -import { IS_DRAGGING_CLASS_NAME, IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from './drag_classnames'; import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { useKibana } from '../../lib/kibana'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; +import { + addFieldToTimelineColumns, + getTimelineIdFromColumnDroppableId, +} from '../../../../../timelines/public'; +import { alertsHeaders } from '../alerts_viewer/default_headers'; // @ts-expect-error window['__react-beautiful-dnd-disable-dev-warnings'] = true; @@ -84,6 +88,7 @@ const onDragEndHandler = ({ } else if (fieldWasDroppedOnTimelineColumns(result)) { addFieldToTimelineColumns({ browserFields, + defaultsHeader: alertsHeaders, dispatch, result, timelineId: getTimelineIdFromColumnDroppableId(result.destination?.droppableId ?? ''), @@ -91,8 +96,6 @@ const onDragEndHandler = ({ } }; -const sensors = [useAddToTimelineSensor]; - /** * DragDropContextWrapperComponent handles all drag end events */ @@ -100,7 +103,8 @@ export const DragDropContextWrapperComponent: React.FC = ({ browserFields const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const getDataProviders = useMemo(() => dragAndDropSelectors.getDataProvidersSelector(), []); - + const { timelines } = useKibana().services; + const sensors = [timelines.getUseAddToTimelineSensor()]; const { dataProviders: activeTimelineDataProviders, timelineType, diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx index 0d8011ee8b65db..bdc5545880e1c2 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx @@ -17,6 +17,8 @@ import { DragDropContextWrapper } from './drag_drop_context_wrapper'; import { ConditionalPortal, DraggableWrapper, getStyle } from './draggable_wrapper'; import { useMountAppended } from '../../utils/use_mount_appended'; +jest.mock('../../lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx index 0cb030862a3899..9db5b3899d8bc1 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx @@ -6,6 +6,7 @@ */ import { EuiScreenReaderOnly } from '@elastic/eui'; +import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-grid'; import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Draggable, @@ -24,12 +25,12 @@ import { ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID } from '../../../timelines/com import { TruncatableText } from '../truncatable_text'; import { WithHoverActions } from '../with_hover_actions'; -import { useDraggableKeyboardWrapper } from './draggable_keyboard_wrapper_hook'; import { DraggableWrapperHoverContent, useGetTimelineId } from './draggable_wrapper_hover_content'; -import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, getDraggableId, getDroppableId } from './helpers'; +import { getDraggableId, getDroppableId } from './helpers'; import { ProviderContainer } from './provider_container'; import * as i18n from './translations'; +import { useKibana } from '../../lib/kibana'; // As right now, we do not know what we want there, we will keep it as a placeholder export const DragEffects = styled.div``; @@ -142,6 +143,7 @@ const DraggableWrapperComponent: React.FC = ({ const isDisabled = dataProvider.id.includes(`-${ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID}-`); const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState(false); const dispatch = useDispatch(); + const { timelines } = useKibana().services; const handleClosePopOverTrigger = useCallback(() => { setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger); @@ -297,7 +299,7 @@ const DraggableWrapperComponent: React.FC = ({ setHoverActionsOwnFocus(true); }, []); - const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({ + const { onBlur, onKeyDown } = timelines.getUseDraggableKeyboardWrapper()({ closePopover: handleClosePopOverTrigger, draggableId: getDraggableId(dataProvider.id), fieldName: dataProvider.queryMatch.field, diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx index 0d688bd805999f..400b178c167f68 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -17,14 +17,10 @@ import { TestProviders } from '../../mock'; import { FilterManager } from '../../../../../../../src/plugins/data/public'; import { useSourcererScope } from '../../containers/sourcerer'; import { DraggableWrapperHoverContent } from './draggable_wrapper_hover_content'; -import { - ManageGlobalTimeline, - getTimelineDefaults, -} from '../../../timelines/components/manage_timeline'; import { TimelineId } from '../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; jest.mock('../link_to'); - jest.mock('../../lib/kibana'); jest.mock('../../containers/sourcerer', () => { const original = jest.requireActual('../../containers/sourcerer'); @@ -42,29 +38,18 @@ jest.mock('uuid', () => { }; }); const mockStartDragToTimeline = jest.fn(); -jest.mock('../../hooks/use_add_to_timeline', () => { - const original = jest.requireActual('../../hooks/use_add_to_timeline'); +jest.mock('../../../../../timelines/public/hooks/use_add_to_timeline', () => { + const original = jest.requireActual('../../../../../timelines/public/hooks/use_add_to_timeline'); return { ...original, useAddToTimeline: () => ({ startDragToTimeline: mockStartDragToTimeline }), }; }); const mockAddFilters = jest.fn(); -const mockGetTimelineFilterManager = jest.fn().mockReturnValue({ - addFilters: mockAddFilters, -}); -jest.mock('../../../timelines/components/manage_timeline', () => { - const original = jest.requireActual('../../../timelines/components/manage_timeline'); - - return { - ...original, - useManageTimeline: () => ({ - getManageTimelineById: jest.fn().mockReturnValue({ indexToAdd: [] }), - getTimelineFilterManager: mockGetTimelineFilterManager, - isManagedTimeline: jest.fn().mockReturnValue(false), - }), - }; -}); +jest.mock('../../../common/hooks/use_selector', () => ({ + useShallowEqualSelector: jest.fn(), + useDeepEqualSelector: jest.fn(), +})); const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; const timelineId = TimelineId.active; @@ -85,6 +70,9 @@ const defaultProps = { describe('DraggableWrapperHoverContent', () => { beforeAll(() => { mockStartDragToTimeline.mockReset(); + (useDeepEqualSelector as jest.Mock).mockReturnValue({ + filterManager: { addFilters: mockAddFilters }, + }); (useSourcererScope as jest.Mock).mockReturnValue({ browserFields: mockBrowserFields, selectedPatterns: [], @@ -144,15 +132,10 @@ describe('DraggableWrapperHoverContent', () => { beforeEach(() => { onFilterAdded = jest.fn(); - const manageTimelineForTesting = { - [timelineId]: getTimelineDefaults(timelineId), - }; wrapper = mount( - - - + ); }); @@ -237,18 +220,9 @@ describe('DraggableWrapperHoverContent', () => { filterManager.addFilters = jest.fn(); onFilterAdded = jest.fn(); - const manageTimelineForTesting = { - [timelineId]: { - ...getTimelineDefaults(timelineId), - filterManager, - }, - }; - wrapper = mount( - - - + ); }); @@ -586,39 +560,4 @@ describe('DraggableWrapperHoverContent', () => { expect(wrapper.find(`[data-test-subj="copy-to-clipboard"]`).first().exists()).toBe(false); }); }); - - describe('Filter Manager', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - test('filter manager, not active timeline', () => { - mount( - - - - ); - - expect(mockGetTimelineFilterManager).not.toBeCalled(); - }); - test('filter manager, active timeline', () => { - mount( - - - - ); - - expect(mockGetTimelineFilterManager).toBeCalled(); - }); - test('filter manager, active timeline in draggableId', () => { - mount( - - - - ); - - expect(mockGetTimelineFilterManager).toBeCalled(); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx index 880f0b4e18acab..71c3114015a03f 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx @@ -12,14 +12,12 @@ import { EuiScreenReaderOnly, EuiToolTip, } from '@elastic/eui'; + import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { DraggableId } from 'react-beautiful-dnd'; import styled from 'styled-components'; -import { stopPropagationAndPreventDefault } from '../accessibility/helpers'; -import { TooltipWithKeyboardShortcut } from '../accessibility/tooltip_with_keyboard_shortcut'; import { getAllFieldsByName } from '../../containers/source'; -import { useAddToTimeline } from '../../hooks/use_add_to_timeline'; import { COPY_TO_CLIPBOARD_BUTTON_CLASS_NAME } from '../../lib/clipboard/clipboard'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; import { useKibana } from '../../lib/kibana'; @@ -28,11 +26,14 @@ import { StatefulTopN } from '../top_n'; import { allowTopN } from './helpers'; import * as i18n from './translations'; -import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; import { TimelineId } from '../../../../common/types/timeline'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/components/timeline/styles'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; +import { timelineSelectors } from '../../../timelines/store/timeline'; +import { stopPropagationAndPreventDefault } from '../../../../../timelines/public'; +import { TooltipWithKeyboardShortcut } from '../accessibility'; export const AdditionalContent = styled.div` padding: 2px; @@ -102,21 +103,25 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ toggleTopN, value, }) => { - const { startDragToTimeline } = useAddToTimeline({ draggableId, fieldName: field }); const kibana = useKibana(); + const { timelines } = kibana.services; + const { startDragToTimeline } = timelines.getUseAddToTimeline()({ + draggableId, + fieldName: field, + }); const filterManagerBackup = useMemo(() => kibana.services.data.query.filterManager, [ kibana.services.data.query.filterManager, ]); - const { getTimelineFilterManager } = useManageTimeline(); + const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); + const { filterManager: activeFilterMananager } = useDeepEqualSelector((state) => + getManageTimeline(state, timelineId ?? '') + ); const defaultFocusedButtonRef = useRef(null); const panelRef = useRef(null); const filterManager = useMemo( - () => - timelineId === TimelineId.active - ? getTimelineFilterManager(TimelineId.active) - : filterManagerBackup, - [timelineId, getTimelineFilterManager, filterManagerBackup] + () => (timelineId === TimelineId.active ? activeFilterMananager : filterManagerBackup), + [timelineId, activeFilterMananager, filterManagerBackup] ); // Regarding data from useManageTimeline: diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/droppable_wrapper.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/droppable_wrapper.test.tsx index 42f70e9d296b30..73a732b5d64583 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/droppable_wrapper.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/droppable_wrapper.test.tsx @@ -15,6 +15,8 @@ import { DragDropContextWrapper } from './drag_drop_context_wrapper'; import { DroppableWrapper } from './droppable_wrapper'; import { useMountAppended } from '../../utils/use_mount_appended'; +jest.mock('../../lib/kibana'); + describe('DroppableWrapper', () => { const mount = useMountAppended(); diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts index 58d2e0e7dc70f4..a14a44cd9a68bd 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts @@ -7,6 +7,7 @@ import { omit } from 'lodash/fp'; import { DropResult } from 'react-beautiful-dnd'; +import { getTimelineIdFromColumnDroppableId } from '../../../../../timelines/public'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; @@ -33,7 +34,6 @@ import { getDroppableId, getFieldIdFromDraggable, getProviderIdFromDraggable, - getTimelineIdFromColumnDroppableId, getTimelineProviderDraggableId, getTimelineProviderDroppableId, providerWasDroppedOnTimeline, diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts index 1d6454bba0699a..9717e1e1eda911 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts @@ -4,138 +4,53 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { isString } from 'lodash/fp'; -import { DropResult, FluidDragActions, Position } from 'react-beautiful-dnd'; +import { DropResult } from 'react-beautiful-dnd'; import { Dispatch } from 'redux'; import { ActionCreator } from 'typescript-fsa'; +import { getProviderIdFromDraggable } from '@kbn/securitysolution-t-grid'; -import { stopPropagationAndPreventDefault } from '../accessibility/helpers'; -import { alertsHeaders } from '../alerts_viewer/default_headers'; -import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source'; +import { BrowserField } from '../../containers/source'; import { dragAndDropActions } from '../../store/actions'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; -import { timelineActions } from '../../../timelines/store/timeline'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import { addContentToTimeline } from '../../../timelines/components/timeline/data_providers/helpers'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; -import { TimelineId } from '../../../../common/types/timeline'; - -export const draggableIdPrefix = 'draggableId'; - -export const droppableIdPrefix = 'droppableId'; - -export const draggableContentPrefix = `${draggableIdPrefix}.content.`; - -export const draggableTimelineProvidersPrefix = `${draggableIdPrefix}.timelineProviders.`; - -export const draggableFieldPrefix = `${draggableIdPrefix}.field.`; - -export const droppableContentPrefix = `${droppableIdPrefix}.content.`; - -export const droppableFieldPrefix = `${droppableIdPrefix}.field.`; - -export const droppableTimelineProvidersPrefix = `${droppableIdPrefix}.timelineProviders.`; - -export const droppableTimelineColumnsPrefix = `${droppableIdPrefix}.timelineColumns.`; - -export const droppableTimelineFlyoutBottomBarPrefix = `${droppableIdPrefix}.flyoutButton.`; - -export const getDraggableId = (dataProviderId: string): string => - `${draggableContentPrefix}${dataProviderId}`; - -export const getDraggableFieldId = ({ - contextId, - fieldId, -}: { - contextId: string; - fieldId: string; -}): string => `${draggableFieldPrefix}${escapeContextId(contextId)}.${escapeFieldId(fieldId)}`; - -export const getTimelineProviderDroppableId = ({ - groupIndex, - timelineId, -}: { - groupIndex: number; - timelineId: string; -}): string => `${droppableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}`; - -export const getTimelineProviderDraggableId = ({ - dataProviderId, - groupIndex, - timelineId, -}: { - dataProviderId: string; - groupIndex: number; - timelineId: string; -}): string => - `${draggableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}.${dataProviderId}`; - -export const getDroppableId = (visualizationPlaceholderId: string): string => - `${droppableContentPrefix}${visualizationPlaceholderId}`; - -export const sourceIsContent = (result: DropResult): boolean => - result.source.droppableId.startsWith(droppableContentPrefix); - -export const sourceAndDestinationAreSameTimelineProviders = (result: DropResult): boolean => { - const regex = /^droppableId\.timelineProviders\.(\S+)\./; - const sourceMatches = result.source.droppableId.match(regex) ?? []; - const destinationMatches = result.destination?.droppableId.match(regex) ?? []; - - return ( - sourceMatches.length >= 2 && - destinationMatches.length >= 2 && - sourceMatches[1] === destinationMatches[1] - ); -}; - -export const draggableIsContent = (result: DropResult | { draggableId: string }): boolean => - result.draggableId.startsWith(draggableContentPrefix); - -export const draggableIsField = (result: DropResult | { draggableId: string }): boolean => - result.draggableId.startsWith(draggableFieldPrefix); - -export const reasonIsDrop = (result: DropResult): boolean => result.reason === 'DROP'; - -export const destinationIsTimelineProviders = (result: DropResult): boolean => - result.destination != null && - result.destination.droppableId.startsWith(droppableTimelineProvidersPrefix); - -export const destinationIsTimelineColumns = (result: DropResult): boolean => - result.destination != null && - result.destination.droppableId.startsWith(droppableTimelineColumnsPrefix); - -export const destinationIsTimelineButton = (result: DropResult): boolean => - result.destination != null && - result.destination.droppableId.startsWith(droppableTimelineFlyoutBottomBarPrefix); - -export const getProviderIdFromDraggable = (result: DropResult): string => - result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1); - -export const getFieldIdFromDraggable = (result: DropResult): string => - unEscapeFieldId(result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1)); - -export const escapeDataProviderId = (path: string) => path.replace(/\./g, '_'); - -export const escapeContextId = (path: string) => path.replace(/\./g, '_'); - -export const escapeFieldId = (path: string) => path.replace(/\./g, '!!!DOT!!!'); - -export const unEscapeFieldId = (path: string) => path.replace(/!!!DOT!!!/g, '.'); - -export const providerWasDroppedOnTimeline = (result: DropResult): boolean => - reasonIsDrop(result) && - draggableIsContent(result) && - sourceIsContent(result) && - destinationIsTimelineProviders(result); - -export const userIsReArrangingProviders = (result: DropResult): boolean => - reasonIsDrop(result) && sourceAndDestinationAreSameTimelineProviders(result); - -export const fieldWasDroppedOnTimelineColumns = (result: DropResult): boolean => - reasonIsDrop(result) && draggableIsField(result) && destinationIsTimelineColumns(result); +export { + draggableIdPrefix, + droppableIdPrefix, + draggableContentPrefix, + draggableTimelineProvidersPrefix, + draggableFieldPrefix, + draggableIsField, + droppableContentPrefix, + droppableFieldPrefix, + droppableTimelineProvidersPrefix, + droppableTimelineColumnsPrefix, + droppableTimelineFlyoutBottomBarPrefix, + getDraggableId, + getDraggableFieldId, + getTimelineProviderDroppableId, + getTimelineProviderDraggableId, + getDroppableId, + sourceIsContent, + sourceAndDestinationAreSameTimelineProviders, + draggableIsContent, + reasonIsDrop, + destinationIsTimelineProviders, + destinationIsTimelineColumns, + destinationIsTimelineButton, + getProviderIdFromDraggable, + getFieldIdFromDraggable, + escapeDataProviderId, + escapeContextId, + escapeFieldId, + unEscapeFieldId, + providerWasDroppedOnTimeline, + userIsReArrangingProviders, + fieldWasDroppedOnTimelineColumns, + DRAG_TYPE_FIELD, + IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME, +} from '@kbn/securitysolution-t-grid'; interface AddProviderToTimelineParams { activeTimelineDataProviders: DataProvider[]; dataProviders: IdToDataProvider; @@ -148,18 +63,6 @@ interface AddProviderToTimelineParams { timelineId: string; } -interface AddFieldToTimelineColumnsParams { - upsertColumn?: ActionCreator<{ - column: ColumnHeaderOptions; - id: string; - index: number; - }>; - browserFields: BrowserFields; - dispatch: Dispatch; - result: DropResult; - timelineId: string; -} - export const addProviderToTimeline = ({ activeTimelineDataProviders, dataProviders, @@ -186,67 +89,6 @@ export const addProviderToTimeline = ({ } }; -const linkFields: Record = { - 'signal.rule.name': 'signal.rule.id', - 'event.module': 'rule.reference', -}; - -export const addFieldToTimelineColumns = ({ - upsertColumn = timelineActions.upsertColumn, - browserFields, - dispatch, - result, - timelineId, -}: AddFieldToTimelineColumnsParams): void => { - const fieldId = getFieldIdFromDraggable(result); - const allColumns = getAllFieldsByName(browserFields); - const column = allColumns[fieldId]; - const initColumnHeader = - timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage - ? alertsHeaders.find((c) => c.id === fieldId) ?? {} - : {}; - - if (column != null) { - dispatch( - upsertColumn({ - column: { - category: column.category, - columnHeaderType: 'not-filtered', - description: isString(column.description) ? column.description : undefined, - example: isString(column.example) ? column.example : undefined, - id: fieldId, - linkField: linkFields[fieldId] ?? undefined, - type: column.type, - aggregatable: column.aggregatable, - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, - ...initColumnHeader, - }, - id: timelineId, - index: result.destination != null ? result.destination.index : 0, - }) - ); - } else { - // create a column definition, because it doesn't exist in the browserFields: - dispatch( - upsertColumn({ - column: { - columnHeaderType: 'not-filtered', - id: fieldId, - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, - }, - id: timelineId, - index: result.destination != null ? result.destination.index : 0, - }) - ); - } -}; - -/** - * Prevents fields from being dragged or dropped to any area other than column - * header drop zone in the timeline - */ -export const DRAG_TYPE_FIELD = 'drag-type-field'; - export const allowTopN = ({ browserField, fieldName, @@ -341,125 +183,3 @@ export const allowTopN = ({ return isAllowlistedNonBrowserField || (isAggregatable && isAllowedType); }; - -export const getTimelineIdFromColumnDroppableId = (droppableId: string) => - droppableId.slice(droppableId.lastIndexOf('.') + 1); - -/** The draggable will move this many pixes via the keyboard when the arrow key is pressed */ -export const KEYBOARD_DRAG_OFFSET = 20; - -export const DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME = 'draggable-keyboard-wrapper'; - -/** - * Temporarily disables tab focus on child links of the draggable to work - * around an issue where tab focus becomes stuck on the interactive children - * - * NOTE: This function is (intentionally) only effective when used in a key - * event handler, because it automatically restores focus capabilities on - * the next tick. - */ -export const temporarilyDisableInteractiveChildTabIndexes = (draggableElement: HTMLDivElement) => { - const interactiveChildren = draggableElement.querySelectorAll('a, button'); - interactiveChildren.forEach((interactiveChild) => { - interactiveChild.setAttribute('tabindex', '-1'); // DOM mutation - }); - - // restore the default tabindexs on the next tick: - setTimeout(() => { - interactiveChildren.forEach((interactiveChild) => { - interactiveChild.setAttribute('tabindex', '0'); // DOM mutation - }); - }, 0); -}; - -export const draggableKeyDownHandler = ({ - beginDrag, - cancelDragActions, - closePopover, - draggableElement, - dragActions, - dragToLocation, - endDrag, - keyboardEvent, - openPopover, - setDragActions, -}: { - beginDrag: () => FluidDragActions | null; - cancelDragActions: () => void; - closePopover?: () => void; - draggableElement: HTMLDivElement; - dragActions: FluidDragActions | null; - dragToLocation: ({ - // eslint-disable-next-line @typescript-eslint/no-shadow - dragActions, - position, - }: { - dragActions: FluidDragActions | null; - position: Position; - }) => void; - keyboardEvent: React.KeyboardEvent; - endDrag: (dragActions: FluidDragActions | null) => void; - openPopover?: () => void; - setDragActions: (value: React.SetStateAction) => void; -}) => { - let currentPosition: DOMRect | null = null; - - switch (keyboardEvent.key) { - case ' ': - if (!dragActions) { - // start dragging, because space was pressed - if (closePopover != null) { - closePopover(); - } - setDragActions(beginDrag()); - } else { - // end dragging, because space was pressed - endDrag(dragActions); - setDragActions(null); - } - break; - case 'Escape': - cancelDragActions(); - break; - case 'Tab': - // IMPORTANT: we do NOT want to stop propagation and prevent default when Tab is pressed - temporarilyDisableInteractiveChildTabIndexes(draggableElement); - break; - case 'ArrowUp': - currentPosition = draggableElement.getBoundingClientRect(); - dragToLocation({ - dragActions, - position: { x: currentPosition.x, y: currentPosition.y - KEYBOARD_DRAG_OFFSET }, - }); - break; - case 'ArrowDown': - currentPosition = draggableElement.getBoundingClientRect(); - dragToLocation({ - dragActions, - position: { x: currentPosition.x, y: currentPosition.y + KEYBOARD_DRAG_OFFSET }, - }); - break; - case 'ArrowLeft': - currentPosition = draggableElement.getBoundingClientRect(); - dragToLocation({ - dragActions, - position: { x: currentPosition.x - KEYBOARD_DRAG_OFFSET, y: currentPosition.y }, - }); - break; - case 'ArrowRight': - currentPosition = draggableElement.getBoundingClientRect(); - dragToLocation({ - dragActions, - position: { x: currentPosition.x + KEYBOARD_DRAG_OFFSET, y: currentPosition.y }, - }); - break; - case 'Enter': - stopPropagationAndPreventDefault(keyboardEvent); // prevents the first item in the popover from getting an errant ENTER - if (!dragActions && openPopover != null) { - openPopover(); - } - break; - default: - break; - } -}; diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx index 9c6b8c485986e4..f77bf0f347f794 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx @@ -21,6 +21,8 @@ import { tooltipContentIsExplicitlyNull, } from '.'; +jest.mock('../../lib/kibana'); + describe('draggables', () => { const mount = useMountAppended(); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx index a66d1d05025cbd..2998b96fcf6eed 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx @@ -11,10 +11,10 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, - EuiSpacer, + EuiForm, + EuiFormRow, EuiText, EuiTextArea, - EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CANCEL, COMMENT, COMMENT_PLACEHOLDER, CONFIRM } from './translations'; @@ -41,56 +41,62 @@ export const EndpointIsolateForm = memo( ); return ( - <> - -

- {hostName} }} - />{' '} - {messageAppend} -

-
+ + + +

+ {hostName} }} + /> +
+

+

+ {' '} + {messageAppend} +

+
+
- + + + - -

{COMMENT}

-
- - - - - - - - {CANCEL} - - - - - {CONFIRM} - - - - + + + + + {CANCEL} + + + + + {CONFIRM} + + + + +
); } ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index b8f29996d603bb..c782804b0592b1 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -17,6 +17,8 @@ import { TestProviders } from '../../mock'; import { mockBrowserFields } from '../../containers/source/mock'; import { useMountAppended } from '../../utils/use_mount_appended'; +jest.mock('../../lib/kibana'); + jest.mock('../../../detections/containers/detection_engine/rules/use_rule_async', () => { return { useRuleAsync: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index 204b8c088304b5..1be05cc5605529 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -21,9 +21,8 @@ import { get, isEmpty } from 'lodash'; import memoizeOne from 'memoize-one'; import React from 'react'; import styled from 'styled-components'; -import { onFocusReFocusDraggable } from '../accessibility/helpers'; +import { onFocusReFocusDraggable } from '../../../../../timelines/public'; import { BrowserFields } from '../../containers/source'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { DragEffects } from '../drag_and_drop/draggable_wrapper'; import { DroppableWrapper } from '../drag_and_drop/droppable_wrapper'; import { DRAG_TYPE_FIELD, getDroppableId } from '../drag_and_drop/helpers'; @@ -38,6 +37,7 @@ import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; import { getIconFromType, getExampleText } from './helpers'; import * as i18n from './translations'; import { EventFieldsData } from './types'; +import { ColumnHeaderOptions } from '../../../../common'; const HoverActionsContainer = styled(EuiPanel)` align-items: center; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index 0c7515fe75d862..6aff259d8220e2 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -20,6 +20,8 @@ import { mockAlertDetailsData } from './__mocks__'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { TimelineTabs } from '../../../../common/types/timeline'; +jest.mock('../../../common/lib/kibana'); + jest.mock('../link_to'); describe('EventDetails', () => { const mount = useMountAppended(); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx index f0865e1b8e0835..555b67da953d6c 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx @@ -16,6 +16,8 @@ import { mockBrowserFields } from '../../containers/source/mock'; import { useMountAppended } from '../../utils/use_mount_appended'; import { TimelineTabs } from '../../../../common/types/timeline'; +jest.mock('../../lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx index 93d0e6ccfbe3c2..3ad7e9aef19dcd 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx @@ -11,26 +11,24 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { rgba } from 'polished'; import styled from 'styled-components'; - import { arrayIndexToAriaIndex, DATA_COLINDEX_ATTRIBUTE, DATA_ROWINDEX_ATTRIBUTE, isTab, onKeyDownFocusHandler, -} from '../accessibility/helpers'; +} from '../../../../../timelines/public'; + import { ADD_TIMELINE_BUTTON_CLASS_NAME } from '../../../timelines/components/flyout/add_timeline_button'; import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { BrowserFields, getAllFieldsByName } from '../../containers/source'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { getColumnHeaders } from '../../../timelines/components/timeline/body/column_headers/helpers'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; - import { getColumns } from './columns'; import { EVENT_FIELDS_TABLE_CLASS_NAME, onEventDetailsTabKeyPressed, search } from './helpers'; import { useDeepEqualSelector } from '../../hooks/use_selector'; -import { TimelineTabs } from '../../../../common/types/timeline'; +import { ColumnHeaderOptions, TimelineTabs } from '../../../../common/types/timeline'; interface Props { browserFields: BrowserFields; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx index 1f12c2de5e24fd..8392be420a2c53 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx @@ -15,15 +15,15 @@ import { getTableSkipFocus, handleSkipFocus, stopPropagationAndPreventDefault, -} from '../accessibility/helpers'; +} from '../../../../../timelines/public'; import { BrowserField, BrowserFields } from '../../containers/source'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { DEFAULT_DATE_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_MIN_WIDTH, } from '../../../timelines/components/timeline/body/constants'; import * as i18n from './translations'; +import { ColumnHeaderOptions } from '../../../../common'; /** * Defines the behavior of the search input that appears above the table of data diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx index 7c84a325cb667a..5051b39fe60933 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions } from '../../../../common'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; import { DEFAULT_COLUMN_MIN_WIDTH, 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 36986f5f8d3534..90a4e67d76b99d 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 @@ -21,9 +21,8 @@ import { mockBrowserFields, mockDocValueFields } from '../../containers/source/m import { eventsDefaultModel } from './default_model'; import { useMountAppended } from '../../utils/use_mount_appended'; import { inputsModel } from '../../store/inputs'; -import { TimelineId } from '../../../../common/types/timeline'; +import { TimelineId, SortDirection } from '../../../../common/types/timeline'; import { KqlMode } from '../../../timelines/store/timeline/model'; -import { SortDirection } from '../../../timelines/components/timeline/body/sort'; import { AlertsTableFilterGroup } from '../../../detections/components/alerts_table/alerts_filter_group'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; @@ -31,6 +30,8 @@ import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell import { useTimelineEvents } from '../../../timelines/containers'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; +jest.mock('../../lib/kibana'); + jest.mock('../../hooks/use_experimental_features'); const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; @@ -144,18 +145,18 @@ describe('EventsViewer', () => { mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponseWithEvents]); }); - test('call the right reduce action to show event details', async () => { + test('call the right reduce action to show event details', () => { const wrapper = mount( ); - await act(async () => { + act(() => { wrapper.find(`[data-test-subj="expand-event"]`).first().simulate('click'); }); - await waitFor(() => { + waitFor(() => { expect(mockDispatch).toBeCalledTimes(2); expect(mockDispatch.mock.calls[1][0]).toEqual({ payload: { @@ -197,7 +198,7 @@ describe('EventsViewer', () => { ); expect(wrapper.find(`[data-test-subj="show-field-browser"]`).first().exists()).toBe(true); }); - // TO DO sourcerer @X + test('it renders the footer containing the pagination', () => { const wrapper = mount( 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 cf2557f9508332..5dadd740ae3bca 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 @@ -10,11 +10,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; import { Direction } from '../../../../common/search_strategy'; import { BrowserFields, DocValueFields } from '../../containers/source'; import { useTimelineEvents } from '../../../timelines/containers'; import { useKibana } from '../../lib/kibana'; -import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model'; +import { KqlMode } from '../../../timelines/store/timeline/model'; import { HeaderSection } from '../header_section'; import { defaultHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers'; import { Sort } from '../../../timelines/components/timeline/body/sort'; @@ -36,18 +37,21 @@ import { Query, } from '../../../../../../../src/plugins/data/public'; import { inputsModel } from '../../store'; -import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { ExitFullScreen } from '../exit_full_screen'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; -import { TimelineId, TimelineTabs } from '../../../../common/types/timeline'; -import { RowRenderer } from '../../../timelines/components/timeline/body/renderers/row_renderer'; +import { + ColumnHeaderOptions, + ControlColumnProps, + RowRenderer, + TimelineId, + TimelineTabs, +} from '../../../../common/types/timeline'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/components/timeline/styles'; -import { - defaultControlColumn, - ControlColumnProps, -} from '../../../timelines/components/timeline/body/control_columns'; +import { defaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; +import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px const UTILITY_BAR_HEIGHT = 19; // px @@ -162,21 +166,19 @@ const EventsViewerComponent: React.FC = ({ utilityBar, graphEventId, }) => { + const dispatch = useDispatch(); const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); const [isQueryLoading, setIsQueryLoading] = useState(false); - const { getManageTimelineById, setIsTimelineLoading } = useManageTimeline(); - useEffect(() => { - setIsTimelineLoading({ id, isLoading: isQueryLoading }); - }, [id, isQueryLoading, setIsTimelineLoading]); + dispatch(timelineActions.updateIsLoading({ id, isLoading: isQueryLoading })); + }, [dispatch, id, isQueryLoading]); - const { queryFields, title, unit } = useMemo(() => getManageTimelineById(id), [ - getManageTimelineById, - id, - ]); + const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); + const unit = useMemo(() => (n: number) => i18n.UNIT(n), []); + const { queryFields, title } = useDeepEqualSelector((state) => getManageTimeline(state, id)); const justTitle = useMemo(() => {title}, [title]); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx index cd27177643b449..571e04a106cf0b 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx @@ -22,6 +22,8 @@ import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell import { useTimelineEvents } from '../../../timelines/containers'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; +jest.mock('../../../common/lib/kibana'); + jest.mock('../../../timelines/containers', () => ({ useTimelineEvents: jest.fn(), })); @@ -60,7 +62,9 @@ describe('StatefulEventsViewer', () => { await waitFor(() => { wrapper.update(); - expect(wrapper.find('[data-test-subj="events-viewer-panel"]').first().exists()).toBe(true); + expect(wrapper.text()).toMatchInlineSnapshot( + `"Showing: 12 events1 fields sorted@timestamp1event.severityevent.categoryevent.actionhost.namesource.ipdestination.ipdestination.bytesuser.name_idmessage0 of 12 events123"` + ); }); }); 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 7e603ec6bd0725..32aa716d4bce3e 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 @@ -12,18 +12,20 @@ import styled from 'styled-components'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; -import { TimelineId } from '../../../../common/types/timeline'; +import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline'; import { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/timeline/model'; import { Filter } from '../../../../../../../src/plugins/data/public'; -import { EventsViewer } from './events_viewer'; import { InspectButtonContainer } from '../inspect'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; import { DetailsPanel } from '../../../timelines/components/side_panel'; -import { RowRenderer } from '../../../timelines/components/timeline/body/renderers/row_renderer'; import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; +import { useKibana } from '../../lib/kibana'; +import { defaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; +import { EventsViewer } from './events_viewer'; const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>` height: ${({ $isFullScreen }) => ($isFullScreen ? '100%' : undefined)}; @@ -81,6 +83,7 @@ const StatefulEventsViewerComponent: React.FC = ({ // If truthy, the graph viewer (Resolver) is showing graphEventId, }) => { + const { timelines: timelinesUi } = useKibana().services; const { browserFields, docValueFields, @@ -88,8 +91,9 @@ const StatefulEventsViewerComponent: React.FC = ({ selectedPatterns, loading: isLoadingIndexPattern, } = useSourcererScope(scopeId); - const { globalFullScreen } = useGlobalFullScreen(); - + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); + // TODO: Once we are past experimental phase this code should be removed + const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); useEffect(() => { if (createTimeline != null) { createTimeline({ @@ -109,37 +113,73 @@ const StatefulEventsViewerComponent: React.FC = ({ }, []); const globalFilters = useMemo(() => [...filters, ...(pageFilters ?? [])], [filters, pageFilters]); + const leadingControlColumns: ControlColumnProps[] = [defaultControlColumn]; + const trailingControlColumns: ControlColumnProps[] = []; return ( <> - + {tGridEnabled ? ( + timelinesUi.getTGrid<'embedded'>({ + type: 'embedded', + browserFields, + columns, + dataProviders: dataProviders!, + deletedEventIds, + docValueFields, + end, + filters: globalFilters, + globalFullScreen, + headerFilterGroup, + id, + indexNames: selectedPatterns, + indexPattern, + isLive, + isLoadingIndexPattern, + itemsPerPage, + itemsPerPageOptions: itemsPerPageOptions!, + kqlMode, + query, + onRuleChange, + renderCellValue, + rowRenderers, + setGlobalFullScreen, + start, + sort, + utilityBar, + graphEventId, + leadingControlColumns, + trailingControlColumns, + }) + ) : ( + + )} i18n.translate('xpack.securitySolution.eventsViewer.unit', { values: { totalCount }, diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/title.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/title.test.tsx index 7ad9de29431c96..d21adbd00cc202 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/title.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/title.test.tsx @@ -13,6 +13,8 @@ import { TestProviders } from '../../mock'; import { Title } from './title'; import { useMountAppended } from '../../utils/use_mount_appended'; +jest.mock('../../lib/kibana'); + describe('Title', () => { const mount = useMountAppended(); diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx index ddbcf710aff305..a0e2ff266ad288 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx @@ -131,7 +131,7 @@ const InspectButtonComponent: React.FC = ({ color="text" iconSide="left" iconType="inspect" - isDisabled={loading || isDisabled} + isDisabled={loading || isDisabled || false} isLoading={loading} onClick={handleClick} > @@ -145,7 +145,7 @@ const InspectButtonComponent: React.FC = ({ data-test-subj="inspect-icon-button" iconSize="m" iconType="inspect" - isDisabled={loading || isDisabled} + isDisabled={loading || isDisabled || false} title={i18n.INSPECT} onClick={handleClick} /> diff --git a/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.test.tsx index 115fb65dc70114..f08edb114b9a98 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.test.tsx @@ -13,6 +13,8 @@ import { EntityDraggableComponent } from './entity_draggable'; import { TestProviders } from '../../mock/test_providers'; import { useMountAppended } from '../../utils/use_mount_appended'; +jest.mock('../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_score.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_score.test.tsx index 6ad2bd30283d23..0d9b4001c17aaf 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_score.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_score.test.tsx @@ -17,6 +17,8 @@ import { useMountAppended } from '../../../utils/use_mount_appended'; import { Anomalies } from '../types'; import { waitFor } from '@testing-library/dom'; +jest.mock('../../../lib/kibana'); + const startDate: string = '2020-07-07T08:20:18.966Z'; const endDate: string = '3000-01-01T00:00:00.000Z'; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_scores.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_scores.test.tsx index 6b569a67cfebf7..5eb0751404872e 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_scores.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_scores.test.tsx @@ -18,6 +18,8 @@ import { Anomalies } from '../types'; import { useMountAppended } from '../../../utils/use_mount_appended'; import { waitFor } from '@testing-library/dom'; +jest.mock('../../../lib/kibana'); + const startDate: string = '2020-07-07T08:20:18.966Z'; const endDate: string = '3000-01-01T00:00:00.000Z'; const narrowDateRange = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.test.tsx index ae6ef4e680ffaa..2ecda8482e3400 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.test.tsx @@ -16,6 +16,8 @@ import { Columns } from '../../paginated_table'; import { TestProviders } from '../../../mock'; import { useMountAppended } from '../../../utils/use_mount_appended'; +jest.mock('../../../lib/kibana'); + const startDate = new Date(2001).toISOString(); const endDate = new Date(3000).toISOString(); const interval = 'days'; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.test.tsx index b8a8ab88a74fd6..48c2ec3ee38d81 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.test.tsx @@ -15,6 +15,8 @@ import React from 'react'; import { TestProviders } from '../../../mock'; import { useMountAppended } from '../../../utils/use_mount_appended'; +jest.mock('../../../../common/lib/kibana'); + const startDate = new Date(2001).toISOString(); const endDate = new Date(3000).toISOString(); describe('get_anomalies_network_table_columns', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/tables/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/tables/helpers.test.tsx index 8c2b97a4b8b38e..c122138f9547a5 100644 --- a/x-pack/plugins/security_solution/public/common/components/tables/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/tables/helpers.test.tsx @@ -18,6 +18,9 @@ import { import { TestProviders } from '../../mock'; import { getEmptyValue } from '../empty_value'; import { useMountAppended } from '../../utils/use_mount_appended'; + +jest.mock('../../lib/kibana'); + describe('Table Helpers', () => { const items = ['item1', 'item2', 'item3']; const mount = useMountAppended(); diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/utils.ts b/x-pack/plugins/security_solution/public/common/components/toasters/utils.ts index 70e095c88576f9..04ceafde7ef74f 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/utils.ts +++ b/x-pack/plugins/security_solution/public/common/components/toasters/utils.ts @@ -8,10 +8,10 @@ import type React from 'react'; import uuid from 'uuid'; import { isError } from 'lodash/fp'; +import { isAppError } from '@kbn/securitysolution-t-grid'; import { AppToast, ActionToaster } from './'; import { isToasterError } from './errors'; -import { isAppError } from '../../utils/api'; /** * Displays an error toast for the provided title and message diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index 005602738f376e..4f6834e84d83a8 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -18,17 +18,11 @@ import { createSecuritySolutionStorageMock, mockIndexPattern, } from '../../mock'; -import { FilterManager } from '../../../../../../../src/plugins/data/public'; import { createStore, State } from '../../store'; import { Props } from './top_n'; import { StatefulTopN } from '.'; -import { - ManageGlobalTimeline, - getTimelineDefaults, -} from '../../../timelines/components/manage_timeline'; import { TimelineId } from '../../../../common/types/timeline'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -45,8 +39,6 @@ jest.mock('../link_to'); jest.mock('../../lib/kibana'); jest.mock('../../../timelines/store/timeline/actions'); -const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; - const field = 'process.name'; const value = 'nice'; @@ -175,9 +167,7 @@ describe('StatefulTopN', () => { beforeEach(() => { wrapper = mount( - - - + ); }); @@ -244,26 +234,16 @@ describe('StatefulTopN', () => { }); describe('rendering in a timeline context', () => { - let filterManager: FilterManager; let wrapper: ReactWrapper; beforeEach(() => { - filterManager = new FilterManager(mockUiSettingsForFilterManager); - const manageTimelineForTesting = { - [TimelineId.active]: { - ...getTimelineDefaults(TimelineId.active), - filterManager, - }, - }; testProps = { ...testProps, timelineId: TimelineId.active, }; wrapper = mount( - - - + ); }); @@ -320,25 +300,13 @@ describe('StatefulTopN', () => { }); describe('rendering in a NON-active timeline context', () => { test(`defaults to the 'Alert events' option when rendering in a NON-active timeline context (e.g. the Alerts table on the Detections page) when 'documentType' from 'useTimelineTypeContext()' is 'alerts'`, async () => { - const filterManager = new FilterManager(mockUiSettingsForFilterManager); - - const manageTimelineForTesting = { - [TimelineId.active]: { - ...getTimelineDefaults(TimelineId.active), - filterManager, - documentType: 'alerts', - }, - }; - testProps = { ...testProps, timelineId: TimelineId.detectionsPage, }; const wrapper = mount( - - - + ); await waitFor(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx index ea271a777456cd..c867862e690bde 100644 --- a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx @@ -6,13 +6,13 @@ */ import { EuiPopover } from '@elastic/eui'; +import { + HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME, + IS_DRAGGING_CLASS_NAME, +} from '@kbn/securitysolution-t-grid'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { IS_DRAGGING_CLASS_NAME } from '../drag_and_drop/drag_classnames'; - -export const HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME = 'hover-actions-always-show'; - /** * To avoid expensive changes to the DOM, delay showing the popover menu */ diff --git a/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts index 3e690e50b04b14..4f558412576b4c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts @@ -83,7 +83,7 @@ export const useTimelineLastEventTime = ({ TimelineEventsLastEventTimeRequestOptions, TimelineEventsLastEventTimeStrategyResponse >(request, { - strategy: 'securitySolutionTimelineSearchStrategy', + strategy: 'timelineSearchStrategy', abortSignal: abortCtrl.current.signal, }) .subscribe({ diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 1c17f95bb6ba04..3bc92dafd351fd 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -151,7 +151,7 @@ export const useFetchIndex = ( { indices: iNames, onlyCheckIfIndicesExist }, { abortSignal: abortCtrl.current.signal, - strategy: 'securitySolutionIndexFields', + strategy: 'indexFields', } ) .subscribe({ @@ -235,7 +235,7 @@ export const useIndexFields = (sourcererScopeName: SourcererScopeName) => { { indices: indicesName, onlyCheckIfIndicesExist: false }, { abortSignal: abortCtrl.current.signal, - strategy: 'securitySolutionIndexFields', + strategy: 'indexFields', } ) .subscribe({ diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts index da6b41080c1c72..6c5caa25a1f961 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts @@ -7,9 +7,10 @@ import { renderHook } from '@testing-library/react-hooks'; import { IEsError } from 'src/plugins/data/public'; +import { KibanaError, SecurityAppError } from '@kbn/securitysolution-t-grid'; import { useToasts } from '../lib/kibana'; -import { KibanaError, SecurityAppError } from '../utils/api'; + import { appErrorToErrorStack, convertErrorToEnumerable, diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts index 61b20e137f8707..0c2721e6ad4164 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts @@ -7,11 +7,17 @@ import { useCallback, useRef } from 'react'; import { isString } from 'lodash/fp'; +import { + AppError, + isAppError, + isKibanaError, + isSecurityAppError, +} from '@kbn/securitysolution-t-grid'; + import { IEsError, isEsError } from '../../../../../../src/plugins/data/public'; import { ErrorToastOptions, ToastsStart, Toast } from '../../../../../../src/core/public'; import { useToasts } from '../lib/kibana'; -import { AppError, isAppError, isKibanaError, isSecurityAppError } from '../utils/api'; export type UseAppToasts = Pick & { api: ToastsStart; diff --git a/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx b/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx index 1baa57166de3fb..2f5afc8a44489a 100644 --- a/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx @@ -6,9 +6,10 @@ */ import { EuiToolTip } from '@elastic/eui'; + import React from 'react'; -import { TooltipWithKeyboardShortcut } from '../../components/accessibility/tooltip_with_keyboard_shortcut'; +import { TooltipWithKeyboardShortcut } from '../../components/accessibility'; import * as i18n from '../../components/drag_and_drop/translations'; import { Clipboard } from './clipboard'; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts index eb0ae1ae1dee9e..09c3d2537e2726 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts @@ -6,6 +6,10 @@ */ import { notificationServiceMock } from '../../../../../../../../src/core/public/mocks'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { createTGridMocks } from '../../../../../../timelines/public/mock'; + import { createKibanaContextProviderMock, createUseUiSettingMock, @@ -30,14 +34,24 @@ export const useKibana = jest.fn().mockReturnValue({ })), })), }, + query: { + ...mockStartServicesMock.data.query, + filterManager: { + addFilters: jest.fn(), + getFilters: jest.fn(), + getUpdates$: jest.fn().mockReturnValue({ subscribe: jest.fn() }), + setAppFilters: jest.fn(), + }, + }, }, + timelines: createTGridMocks(), }, }); export const useUiSetting = jest.fn(createUseUiSettingMock()); export const useUiSetting$ = jest.fn(createUseUiSetting$Mock()); export const useHttp = jest.fn().mockReturnValue(createStartServicesMock().http); export const useTimeZone = jest.fn(); -export const useDateFormat = jest.fn(); +export const useDateFormat = jest.fn().mockReturnValue('MMM D, YYYY @ HH:mm:ss.SSS'); export const useBasePath = jest.fn(() => '/test/base/path'); export const useToasts = jest .fn() diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 557c04e4e8a475..316f8b6214d1e7 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -43,6 +43,7 @@ export const mockGlobalState: State = { trustedAppsByPolicyEnabled: false, metricsEntitiesEnabled: false, ruleRegistryEnabled: false, + tGridEnabled: false, }, }, hosts: { diff --git a/x-pack/plugins/security_solution/public/common/mock/header.ts b/x-pack/plugins/security_solution/public/common/mock/header.ts index ae7d3c9e576a83..029ddb00d18325 100644 --- a/x-pack/plugins/security_solution/public/common/mock/header.ts +++ b/x-pack/plugins/security_solution/public/common/mock/header.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ColumnHeaderOptions } from '../../timelines/store/timeline/model'; +import { ColumnHeaderOptions } from '../../../common'; import { defaultColumnHeaderType } from '../../timelines/components/timeline/body/column_headers/default_headers'; import { DEFAULT_COLUMN_MIN_WIDTH, diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_timeline_control_columns.tsx b/x-pack/plugins/security_solution/public/common/mock/mock_timeline_control_columns.tsx index 7604732f902034..7dae3e671d2711 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_timeline_control_columns.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/mock_timeline_control_columns.tsx @@ -15,7 +15,7 @@ import { EuiPopoverTitle, EuiSpacer, } from '@elastic/eui'; -import { ControlColumnProps } from '../../timelines/components/timeline/body/control_columns'; +import { ControlColumnProps } from '../../../common/types/timeline'; const SelectionHeaderCell = () => { return ( diff --git a/x-pack/plugins/security_solution/public/common/mock/utils.ts b/x-pack/plugins/security_solution/public/common/mock/utils.ts index 30951b81611dbf..e0f8e651a58210 100644 --- a/x-pack/plugins/security_solution/public/common/mock/utils.ts +++ b/x-pack/plugins/security_solution/public/common/mock/utils.ts @@ -5,12 +5,20 @@ * 2.0. */ +import { AnyAction, Reducer } from 'redux'; +import reduceReducers from 'reduce-reducers'; + +import { tGridReducer } from '../../../../timelines/public'; + import { hostsReducer } from '../../hosts/store'; import { networkReducer } from '../../network/store'; import { timelineReducer } from '../../timelines/store/timeline/reducer'; import { managementReducer } from '../../management/store/reducer'; import { ManagementPluginReducer } from '../../management'; import { SubPluginsInitReducer } from '../store'; +import { mockGlobalState } from './global_state'; +import { TimelineState } from '../../timelines/store/timeline/types'; +import { defaultHeaders } from '../../timelines/components/timeline/body/column_headers/default_headers'; interface Global extends NodeJS.Global { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -19,10 +27,32 @@ interface Global extends NodeJS.Global { export const globalNode: Global = global; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const combineTimelineReducer = reduceReducers( + { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + test: { + ...mockGlobalState.timeline.timelineById.test, + defaultColumns: defaultHeaders, + loadingText: 'events', + footerText: 'events', + documentType: '', + selectAll: false, + queryFields: [], + unit: (n: number) => n, + }, + }, + }, + tGridReducer, + timelineReducer +) as Reducer; + export const SUB_PLUGINS_REDUCER: SubPluginsInitReducer = { hosts: hostsReducer, network: networkReducer, - timeline: timelineReducer, + timeline: combineTimelineReducer, /** * These state's are wrapped in `Immutable`, but for compatibility with the overall app architecture, * they are cast to mutable versions here. diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/model.ts b/x-pack/plugins/security_solution/public/common/store/inputs/model.ts index e784f6cebae17a..5791a4940cbedd 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/model.ts @@ -60,6 +60,7 @@ export interface GlobalGenericQuery { isInspected: boolean; loading: boolean; selectedInspectIndex: number; + invalidKqlQuery?: Error; } export interface GlobalGraphqlQuery extends GlobalGenericQuery { diff --git a/x-pack/plugins/security_solution/public/common/store/types.ts b/x-pack/plugins/security_solution/public/common/store/types.ts index fbf4caad9793dc..21e833abe1f9ba 100644 --- a/x-pack/plugins/security_solution/public/common/store/types.ts +++ b/x-pack/plugins/security_solution/public/common/store/types.ts @@ -37,18 +37,6 @@ export type StoreState = HostsPluginState & */ export type State = CombinedState; -export type KueryFilterQueryKind = 'kuery' | 'lucene' | 'eql'; - -export interface KueryFilterQuery { - kind: KueryFilterQueryKind; - expression: string; -} - -export interface SerializedFilterQuery { - kuery: KueryFilterQuery | null; - serializedQuery: string; -} - /** * like redux's `MiddlewareAPI` but `getState` returns an `Immutable` version of * state and `dispatch` accepts `Immutable` versions of actions. 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 e5cefca66d0fdd..601e0509009cea 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 @@ -14,6 +14,7 @@ import { i18n } from '@kbn/i18n'; import type { Filter } from '../../../../../../../src/plugins/data/common/es_query/filters'; import { + KueryFilterQueryKind, TimelineId, TimelineResult, TimelineStatus, @@ -44,7 +45,6 @@ import { replaceTemplateFieldFromMatchFilters, replaceTemplateFieldFromDataProviders, } from './helpers'; -import { KueryFilterQueryKind } from '../../../common/store'; import { DataProvider, QueryOperator, @@ -399,7 +399,7 @@ export const sendAlertToTimelineAction = async ({ factoryQueryType: TimelineEventsQueries.details, }, { - strategy: 'securitySolutionTimelineSearchStrategy', + strategy: 'timelineSearchStrategy', } ) .toPromise(), diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.test.tsx index 4ca2980dc74e5c..a3d3bf48343760 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.test.tsx @@ -11,6 +11,7 @@ import { shallow, mount } from 'enzyme'; import { AlertsUtilityBar, AlertsUtilityBarProps } from './index'; import { TestProviders } from '../../../../common/mock/test_providers'; +jest.useFakeTimers(); jest.mock('../../../../common/lib/kibana'); describe('AlertsUtilityBar', () => { 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 02a815bc59f3bd..9a142f6cba2470 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 @@ -6,11 +6,11 @@ */ import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { RowRendererId } from '../../../../common/types/timeline'; +import { ColumnHeaderOptions, RowRendererId } from '../../../../common/types/timeline'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Filter } from '../../../../../../../src/plugins/data/common/es_query'; -import { ColumnHeaderOptions, SubsetTimelineModel } from '../../../timelines/store/timeline/model'; +import { SubsetTimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { columns } from '../../configurations/security_solution_detections/columns'; 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 5a7f017435ede6..7980160fea76cd 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 @@ -8,11 +8,11 @@ import { EuiPanel, EuiLoadingContent } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Filter, esQuery } from '../../../../../../../src/plugins/data/public'; -import { TimelineIdLiteral } from '../../../../common/types/timeline'; +import { RowRendererId, TimelineIdLiteral } from '../../../../common/types/timeline'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; import { StatefulEventsViewer } from '../../../common/components/events_viewer'; import { HeaderSection } from '../../../common/components/header_section'; @@ -23,8 +23,6 @@ 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 } from '../../../timelines/components/manage_timeline'; - import { updateAlertStatusAction } from './actions'; import { requiredFieldsForActions, @@ -95,6 +93,7 @@ export const AlertsTableComponent: React.FC = ({ timelineId, to, }) => { + const dispatch = useDispatch(); const [showClearSelectionAction, setShowClearSelectionAction] = useState(false); const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); const { @@ -106,7 +105,6 @@ export const AlertsTableComponent: React.FC = ({ const kibana = useKibana(); const [, dispatchToaster] = useStateToaster(); const { addWarning } = useAppToasts(); - const { initializeTimeline, setSelectAll } = useManageTimeline(); // TODO: Once we are past experimental phase this code should be removed const ruleRegistryEnabled = useIsExperimentalFeatureEnabled('ruleRegistryEnabled'); @@ -195,14 +193,16 @@ export const AlertsTableComponent: React.FC = ({ // Catches state change isSelectAllChecked->false upon user selection change to reset utility bar useEffect(() => { if (isSelectAllChecked) { - setSelectAll({ - id: timelineId, - selectAll: false, - }); + dispatch( + timelineActions.setTGridSelectAll({ + id: timelineId, + selectAll: false, + }) + ); } else { setShowClearSelectionAction(false); } - }, [isSelectAllChecked, setSelectAll, timelineId]); + }, [dispatch, isSelectAllChecked, timelineId]); // Callback for when open/closed filter changes const onFilterGroupChangedCallback = useCallback( @@ -218,23 +218,27 @@ export const AlertsTableComponent: React.FC = ({ // Callback for clearing entire selection from utility bar const clearSelectionCallback = useCallback(() => { clearSelected!({ id: timelineId }); - setSelectAll({ - id: timelineId, - selectAll: false, - }); + dispatch( + timelineActions.setTGridSelectAll({ + id: timelineId, + selectAll: false, + }) + ); setShowClearSelectionAction(false); - }, [clearSelected, setSelectAll, setShowClearSelectionAction, timelineId]); + }, [clearSelected, dispatch, timelineId]); // Callback for selecting all events on all pages from utility bar // Dispatches to stateful_body's selectAll via TimelineTypeContext props // as scope of response data required to actually set selectedEvents const selectAllOnAllPagesCallback = useCallback(() => { - setSelectAll({ - id: timelineId, - selectAll: true, - }); + dispatch( + timelineActions.setTGridSelectAll({ + id: timelineId, + selectAll: true, + }) + ); setShowClearSelectionAction(true); - }, [setSelectAll, setShowClearSelectionAction, timelineId]); + }, [dispatch, timelineId]); const updateAlertsStatusCallback: UpdateAlertsStatusCallback = useCallback( async ( @@ -330,22 +334,22 @@ export const AlertsTableComponent: React.FC = ({ : alertsDefaultModel; useEffect(() => { - initializeTimeline({ - defaultModel: { - ...defaultTimelineModel, - columns, - }, - documentType: i18n.ALERTS_DOCUMENT_TYPE, - filterManager, - footerText: i18n.TOTAL_COUNT_OF_ALERTS, - id: timelineId, - loadingText: i18n.LOADING_ALERTS, - selectAll: false, - queryFields: requiredFieldsForActions, - title: '', - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + dispatch( + timelineActions.initializeTGridSettings({ + defaultColumns: columns, + documentType: i18n.ALERTS_DOCUMENT_TYPE, + excludedRowRendererIds: defaultTimelineModel.excludedRowRendererIds as RowRendererId[], + filterManager, + footerText: i18n.TOTAL_COUNT_OF_ALERTS, + id: timelineId, + loadingText: i18n.LOADING_ALERTS, + selectAll: false, + queryFields: requiredFieldsForActions, + title: '', + showCheckboxes: true, + }) + ); + }, [dispatch, defaultTimelineModel, filterManager, timelineId]); const headerFilterGroup = useMemo( () => , diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts index 8cbb532501a2cd..70d2237a535ebb 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts @@ -6,10 +6,9 @@ */ import { EuiDataGridColumn } from '@elastic/eui'; - +import { ColumnHeaderOptions } from '../../../../../common'; import { defaultColumnHeaderType } from '../../../../timelines/components/timeline/body/column_headers/default_headers'; import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../../../timelines/components/timeline/body/constants'; -import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; import * as i18n from '../../../components/alerts_table/translations'; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx index 9c2114a4ef085c..7db75d3a73d907 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx @@ -15,10 +15,12 @@ import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../com import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; -import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions } from '../../../../../common'; import { RenderCellValue } from '.'; +jest.mock('../../../../common/lib/kibana/'); + describe('RenderCellValue', () => { const columnId = '@timestamp'; const eventId = '_id-123'; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts index 96d2d870b12702..3365ce5432940f 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts @@ -6,10 +6,9 @@ */ import { EuiDataGridColumn } from '@elastic/eui'; - +import { ColumnHeaderOptions } from '../../../../../common'; import { defaultColumnHeaderType } from '../../../../timelines/components/timeline/body/column_headers/default_headers'; import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../../../timelines/components/timeline/body/constants'; -import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; import * as i18n from '../../../components/alerts_table/translations'; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx index aa4eb543a3d9b5..a8f295df2540d8 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx @@ -15,9 +15,11 @@ import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../com import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; -import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; import { RenderCellValue } from '.'; +import { ColumnHeaderOptions } from '../../../../../common'; + +jest.mock('../../../../common/lib/kibana/'); describe('RenderCellValue', () => { const columnId = '@timestamp'; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts index 23a0740294e847..7f46c839ffe629 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -6,13 +6,13 @@ */ import { EuiDataGridColumn } from '@elastic/eui'; +import { ColumnHeaderOptions } from '../../../../common'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH, } from '../../../timelines/components/timeline/body/constants'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import * as i18n from '../../components/alerts_table/translations'; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx index 18350c102c049b..965ee913a1daa0 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx @@ -9,16 +9,18 @@ import { mount } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; +import { ColumnHeaderOptions } from '../../../../common'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { DragDropContextWrapper } from '../../../common/components/drag_and_drop/drag_drop_context_wrapper'; import { defaultHeaders, mockTimelineData, TestProviders } from '../../../common/mock'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { RenderCellValue } from '.'; +jest.mock('../../../common/lib/kibana'); + describe('RenderCellValue', () => { const columnId = '@timestamp'; const eventId = '_id-123'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx index 84eaf8e3aa93c3..6f8d938dd987e3 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx @@ -6,13 +6,13 @@ */ import { useEffect, useState } from 'react'; +import { isSecurityAppError } from '@kbn/securitysolution-t-grid'; import { DEFAULT_ALERTS_INDEX } from '../../../../../common/constants'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { createSignalIndex, getSignalIndex } from './api'; import * as i18n from './translations'; -import { isSecurityAppError } from '../../../../common/utils/api'; import { useAlertsPrivileges } from './use_alerts_privileges'; type Func = () => Promise; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx index 8e231f0d1fdbba..d55d171708963f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx @@ -6,10 +6,9 @@ */ import { useEffect, useState, useCallback } from 'react'; - +import { isSecurityAppError } from '@kbn/securitysolution-t-grid'; import { useReadListIndex, useCreateListIndex } from '@kbn/securitysolution-list-hooks'; import { useHttp, useKibana } from '../../../../common/lib/kibana'; -import { isSecurityAppError } from '../../../../common/utils/api'; import * as i18n from './translations'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useListsPrivileges } from './use_lists_privileges'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx index f848b71cf7bd36..4f524886935cd2 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx @@ -6,8 +6,8 @@ */ import { useEffect, useRef, useState } from 'react'; +import { isNotFoundError } from '@kbn/securitysolution-t-grid'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { isNotFoundError } from '../../../../common/utils/api'; import { RuleStatusRowItemType } from '../../../pages/detection_engine/rules/all/columns'; import { getRuleStatusById, getRulesStatusByIds } from './api'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx index 4a39e486b6fd5a..abd5a2781c8a77 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx @@ -6,11 +6,11 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; +import { SecurityAppError } from '@kbn/securitysolution-t-grid'; import { useRuleWithFallback } from './use_rule_with_fallback'; import * as api from './api'; import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { SecurityAppError } from '../../../../common/utils/api'; jest.mock('./api'); jest.mock('../alerts/api'); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.tsx index 11c30547848c38..da56275280f654 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.tsx @@ -6,9 +6,9 @@ */ import { useCallback, useEffect, useMemo } from 'react'; +import { isNotFoundError } from '@kbn/securitysolution-t-grid'; import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { isNotFoundError } from '../../../../common/utils/api'; import { useQueryAlerts } from '../alerts/use_query'; import { fetchRuleById } from './api'; import { transformInput } from './transforms'; 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 0c09c4fb7953a3..0c12d8256d66d2 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 @@ -11,13 +11,13 @@ import { noop } from 'lodash/fp'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { isTab } from '../../../../../timelines/public'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { SecurityPageName } from '../../../app/types'; import { TimelineId } from '../../../../common/types/timeline'; import { useGlobalTime } from '../../../common/containers/use_global_time'; -import { isTab } from '../../../common/components/accessibility/helpers'; import { UpdateDateRange } from '../../../common/components/charts/common'; import { FiltersGlobal } from '../../../common/components/filters_global'; import { getRulesUrl } from '../../../common/components/link_to/redirect_to_detection_engine'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx index dd3549ea20d365..8cc3113a5706a3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx @@ -42,6 +42,9 @@ describe('ExceptionListsTable', () => { addError: jest.fn(), }, }, + timelines: { + getLastUpdated: () => null, + }, }, }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 7f734b10fd0200..35404f4486bc3e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -26,7 +26,6 @@ import { Loader } from '../../../../../../common/components/loader'; import { Panel } from '../../../../../../common/components/panel'; import * as i18n from './translations'; import { AllRulesUtilityBar } from '../utility_bar'; -import { LastUpdatedAt } from '../../../../../../common/components/last_updated'; import { AllExceptionListsColumns, getAllExceptionListsColumns } from './columns'; import { useAllExceptionLists } from './use_all_exception_lists'; import { ReferenceErrorModal } from '../../../../../components/value_lists_management_modal/reference_error_modal'; @@ -62,7 +61,7 @@ const exceptionReferenceModalInitialState: ReferenceModalState = { export const ExceptionListsTable = React.memo( ({ formatUrl, history, hasPermissions, loading }) => { const { - services: { http, notifications }, + services: { http, notifications, timelines }, } = useKibana(); const { exportExceptionList, deleteExceptionList } = useApi(http); @@ -344,7 +343,7 @@ export const ExceptionListsTable = React.memo( } + subtitle={timelines.getLastUpdated({ showUpdating: loading, updatedAt: lastUpdated })} > {!initLoading && } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 8fd82a495e52f8..2ec34aaece60b4 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -47,7 +47,6 @@ import { hasMlAdminPermissions } from '../../../../../../common/machine_learning import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license'; import { isBoolean } from '../../../../../common/utils/privileges'; import { AllRulesUtilityBar } from './utility_bar'; -import { LastUpdatedAt } from '../../../../../common/components/last_updated'; import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; import { AllRulesTabs } from '.'; import { useValueChanged } from '../../../../../common/hooks/use_value_changed'; @@ -104,6 +103,7 @@ export const RulesTables = React.memo( application: { capabilities: { actions }, }, + timelines, }, } = useKibana(); @@ -473,12 +473,10 @@ export const RulesTables = React.memo( split growLeftSplit={false} title={i18n.ALL_RULES} - subtitle={ - - } + subtitle={timelines.getLastUpdated({ + showUpdating: loading || isLoadingRules || isLoadingRulesStatuses, + updatedAt: lastUpdated, + })} > {shouldShowRulesTable && ( ({ diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx index 751a2bf5a20558..2cd4ed1f57f84a 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx @@ -20,6 +20,8 @@ import { mockData } from './mock'; import { HostsType } from '../../store/model'; import * as i18n from './translations'; +jest.mock('../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx index 2333d5e9b127c3..b51e20b801f408 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx @@ -19,6 +19,8 @@ import { type } from './utils'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { getHostDetailsPageFilters } from './helpers'; +jest.mock('../../../common/lib/kibana'); + jest.mock('../../../common/components/url_state/normalize_time_range.ts'); jest.mock('../../../common/containers/source', () => ({ diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index b86d7ad66e21e8..7d31d291e75f17 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -11,6 +11,7 @@ import { noop } from 'lodash/fp'; import React, { useCallback, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { isTab } from '../../../../timelines/public'; import { SecurityPageName } from '../../app/types'; import { UpdateDateRange } from '../../common/components/charts/common'; @@ -42,7 +43,6 @@ import * as i18n from './translations'; import { filterHostData } from './navigation'; import { hostsModel } from '../store'; import { HostsTableType } from '../store/model'; -import { isTab } from '../../common/components/accessibility/helpers'; import { onTimelineTabKeyPressed, resetKeyboardFocus, 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 f88709e6e95ac8..973dbc41925da0 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 @@ -10,6 +10,7 @@ import { useDispatch } from 'react-redux'; import { TimelineId } from '../../../../common/types/timeline'; import { StatefulEventsViewer } from '../../../common/components/events_viewer'; +import { timelineActions } from '../../../timelines/store/timeline'; import { HostsComponentsQueryProps } from './types'; import { eventsDefaultModel } from '../../../common/components/events_viewer/default_model'; import { @@ -20,7 +21,6 @@ import { MatrixHistogram } from '../../../common/components/matrix_histogram'; import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import * as i18n from '../translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; -import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; @@ -64,14 +64,15 @@ const EventsQueryTabBodyComponent: React.FC = ({ startDate, }) => { const dispatch = useDispatch(); - const { initializeTimeline } = useManageTimeline(); const { globalFullScreen } = useGlobalFullScreen(); useEffect(() => { - initializeTimeline({ - id: TimelineId.hostsPageEvents, - defaultModel: eventsDefaultModel, - }); - }, [dispatch, initializeTimeline]); + dispatch( + timelineActions.initializeTGridSettings({ + id: TimelineId.hostsPageEvents, + defaultColumns: eventsDefaultModel.columns, + }) + ); + }, [dispatch]); useEffect(() => { return () => { diff --git a/x-pack/plugins/security_solution/public/index.ts b/x-pack/plugins/security_solution/public/index.ts index 55262fe039b4e3..3d2412b326b549 100644 --- a/x-pack/plugins/security_solution/public/index.ts +++ b/x-pack/plugins/security_solution/public/index.ts @@ -8,6 +8,7 @@ import { PluginInitializerContext } from '../../../../src/core/public'; import { Plugin } from './plugin'; import { PluginSetup } from './types'; +export type { TimelineModel } from './timelines/store/timeline/model'; export const plugin = (context: PluginInitializerContext): Plugin => new Plugin(context); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 5b5bac3a0a6e19..949feb29643173 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -16,7 +16,7 @@ import { import { ServerApiError } from '../../../../common/types'; import { GetPolicyListResponse } from '../../policy/types'; import { GetPackagesResponse } from '../../../../../../fleet/common'; -import { EndpointState } from '../types'; +import { EndpointIndexUIQueryParams, EndpointState } from '../types'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/public'; export interface ServerReturnedEndpointList { @@ -163,12 +163,29 @@ export type EndpointPendingActionsStateChanged = Action<'endpointPendingActionsS payload: EndpointState['endpointPendingActions']; }; +export interface EndpointDetailsActivityLogUpdatePaging { + type: 'endpointDetailsActivityLogUpdatePaging'; + payload: { + // disable paging when no more data after paging + disabled: boolean; + page: number; + pageSize: number; + }; +} + +export interface EndpointDetailsFlyoutTabChanged { + type: 'endpointDetailsFlyoutTabChanged'; + payload: { flyoutView: EndpointIndexUIQueryParams['show'] }; +} + export type EndpointAction = | ServerReturnedEndpointList | ServerFailedToReturnEndpointList | ServerReturnedEndpointDetails | ServerFailedToReturnEndpointDetails | AppRequestedEndpointActivityLog + | EndpointDetailsActivityLogUpdatePaging + | EndpointDetailsFlyoutTabChanged | EndpointDetailsActivityLogChanged | ServerReturnedEndpointPolicyResponse | ServerFailedToReturnEndpointPolicyResponse diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts index d43f361a0e6bb8..317b735e1169e5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts @@ -19,9 +19,13 @@ export const initialEndpointPageState = (): Immutable => { loading: false, error: undefined, endpointDetails: { + flyoutView: undefined, activityLog: { - page: 1, - pageSize: 50, + paging: { + disabled: false, + page: 1, + pageSize: 50, + }, logData: createUninitialisedResourceState(), }, hostDetails: { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 7f7c5f84f8bffd..68dd47362bc383 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -42,9 +42,13 @@ describe('EndpointList store concerns', () => { loading: false, error: undefined, endpointDetails: { + flyoutView: undefined, activityLog: { - page: 1, - pageSize: 50, + paging: { + disabled: false, + page: 1, + pageSize: 50, + }, logData: { type: 'UninitialisedResourceState' }, }, hostDetails: { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 52da30fabf95a1..6cf5e989fb645d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -44,6 +44,7 @@ import { } from '../../../../common/lib/endpoint_isolation/mocks'; import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator'; import { endpointPageHttpMock } from '../mocks'; +import { EndpointDetailsTabsTypes } from '../view/details/components/endpoint_details_tabs'; jest.mock('../../policy/store/services/ingest', () => ({ sendGetAgentConfigList: () => Promise.resolve({ items: [] }), @@ -226,8 +227,16 @@ describe('endpoint list middleware', () => { const dispatchUserChangedUrl = () => { dispatchUserChangedUrlToEndpointList({ search: `?${search.split('?').pop()}` }); }; + const dispatchFlyoutViewChange = () => { + dispatch({ + type: 'endpointDetailsFlyoutTabChanged', + payload: { + flyoutView: EndpointDetailsTabsTypes.activityLog, + }, + }); + }; - const fleetActionGenerator = new FleetActionGenerator(Math.random().toString()); + const fleetActionGenerator = new FleetActionGenerator('seed'); const actionData = fleetActionGenerator.generate({ agents: [endpointList.hosts[0].metadata.agent.id], }); @@ -265,6 +274,7 @@ describe('endpoint list middleware', () => { it('should set ActivityLog state to loading', async () => { dispatchUserChangedUrl(); + dispatchFlyoutViewChange(); const loadingDispatched = waitForAction('endpointDetailsActivityLogChanged', { validate(action) { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 4f96223e8b7897..53b30aeb02bd53 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -35,6 +35,7 @@ import { getActivityLogDataPaging, getLastLoadedActivityLogData, detailsData, + getEndpointDetailsFlyoutView, } from './selectors'; import { AgentIdsPendingActions, EndpointState, PolicyIds } from '../types'; import { @@ -48,6 +49,7 @@ import { ENDPOINT_ACTION_LOG_ROUTE, HOST_METADATA_GET_ROUTE, HOST_METADATA_LIST_ROUTE, + BASE_POLICY_RESPONSE_ROUTE, metadataCurrentIndexPattern, } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; @@ -61,6 +63,7 @@ import { AppAction } from '../../../../common/store/actions'; import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; import { ServerReturnedEndpointPackageInfo } from './action'; import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint_pending_actions'; +import { EndpointDetailsTabsTypes } from '../view/details/components/endpoint_details_tabs'; type EndpointPageStore = ImmutableMiddlewareAPI; @@ -339,6 +342,28 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(error.body ?? error), }); } - - // call the policy response api - try { - const policyResponse = await coreStart.http.get(`/api/endpoint/policy_response`, { - query: { agentId: selectedEndpoint }, - }); - dispatch({ - type: 'serverReturnedEndpointPolicyResponse', - payload: policyResponse, - }); - } catch (error) { - dispatch({ - type: 'serverFailedToReturnEndpointPolicyResponse', - payload: error, - }); - } } // page activity log API @@ -408,17 +417,24 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(updatedLogData), }); - // TODO dispatch 'noNewLogData' if !activityLog.length - // resets paging to previous state + if (!activityLog.data.length) { + dispatch({ + type: 'endpointDetailsActivityLogUpdatePaging', + payload: { + disabled: true, + page: activityLog.page - 1, + pageSize: activityLog.pageSize, + }, + }); + } } else { dispatch({ type: 'endpointDetailsActivityLogChanged', diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index 9460c27dfe705d..44c63edd8e95c5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -29,12 +29,23 @@ const handleEndpointDetailsActivityLogChanged: CaseReducer { + const pagingOptions = + action.payload.type === 'LoadedResourceState' + ? { + ...state.endpointDetails.activityLog, + paging: { + ...state.endpointDetails.activityLog.paging, + page: action.payload.data.page, + pageSize: action.payload.data.pageSize, + }, + } + : { ...state.endpointDetails.activityLog }; return { ...state!, endpointDetails: { ...state.endpointDetails!, activityLog: { - ...state.endpointDetails.activityLog, + ...pagingOptions, logData: action.payload, }, }, @@ -138,7 +149,8 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta }, }; } else if (action.type === 'appRequestedEndpointActivityLog') { - const pageData = { + const paging = { + disabled: state.endpointDetails.activityLog.paging.disabled, page: action.payload.page, pageSize: action.payload.pageSize, }; @@ -148,10 +160,32 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta ...state.endpointDetails!, activityLog: { ...state.endpointDetails.activityLog, - ...pageData, + paging, }, }, }; + } else if (action.type === 'endpointDetailsActivityLogUpdatePaging') { + const paging = { + ...action.payload, + }; + return { + ...state, + endpointDetails: { + ...state.endpointDetails!, + activityLog: { + ...state.endpointDetails.activityLog, + paging, + }, + }, + }; + } else if (action.type === 'endpointDetailsFlyoutTabChanged') { + return { + ...state, + endpointDetails: { + ...state.endpointDetails!, + flyoutView: action.payload.flyoutView, + }, + }; } else if (action.type === 'endpointDetailsActivityLogChanged') { return handleEndpointDetailsActivityLogChanged(state, action); } else if (action.type === 'endpointPendingActionsStateChanged') { @@ -255,8 +289,11 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta const activityLog = { logData: createUninitialisedResourceState(), - page: 1, - pageSize: 50, + paging: { + disabled: false, + page: 1, + pageSize: 50, + }, }; // Reset `isolationRequestState` if needed diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index d9be85377c81d7..eeb54379e8e7df 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -364,13 +364,14 @@ export const getIsolationRequestError: ( } }); +export const getEndpointDetailsFlyoutView = ( + state: Immutable +): EndpointIndexUIQueryParams['show'] => state.endpointDetails.flyoutView; + export const getActivityLogDataPaging = ( state: Immutable -): Immutable> => { - return { - page: state.endpointDetails.activityLog.page, - pageSize: state.endpointDetails.activityLog.pageSize, - }; +): Immutable => { + return state.endpointDetails.activityLog.paging; }; export const getActivityLogData = ( diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index 59aa2bd15dd74a..c985259588cb05 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -37,9 +37,13 @@ export interface EndpointState { /** api error from retrieving host list */ error?: ServerApiError; endpointDetails: { + flyoutView: EndpointIndexUIQueryParams['show']; activityLog: { - page: number; - pageSize: number; + paging: { + disabled: boolean; + page: number; + pageSize: number; + }; logData: AsyncResourceState; }; hostDetails: { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx index 3e228be4565b1c..aa1f56529657ec 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx @@ -5,10 +5,15 @@ * 2.0. */ +import { useDispatch } from 'react-redux'; import React, { memo, useCallback, useMemo, useState } from 'react'; -import styled from 'styled-components'; -import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; +import { EuiTab, EuiTabs, EuiFlyoutBody, EuiTabbedContentTab, EuiSpacer } from '@elastic/eui'; import { EndpointIndexUIQueryParams } from '../../../types'; +import { EndpointAction } from '../../../store/action'; +import { useEndpointSelector } from '../../hooks'; +import { getActivityLogDataPaging } from '../../../store/selectors'; +import { EndpointDetailsFlyoutHeader } from './flyout_header'; + export enum EndpointDetailsTabsTypes { overview = 'overview', activityLog = 'activity_log', @@ -24,29 +29,18 @@ interface EndpointDetailsTabs { content: JSX.Element; } -const StyledEuiTabbedContent = styled(EuiTabbedContent)` - overflow: hidden; - padding-bottom: ${(props) => props.theme.eui.paddingSizes.xl}; - - > [role='tabpanel'] { - height: 100%; - padding-right: 12px; - overflow: hidden; - overflow-y: auto; - ::-webkit-scrollbar { - -webkit-appearance: none; - width: 4px; - } - ::-webkit-scrollbar-thumb { - border-radius: 2px; - background-color: rgba(0, 0, 0, 0.5); - -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); - } - } -`; - export const EndpointDetailsFlyoutTabs = memo( - ({ show, tabs }: { show: EndpointIndexUIQueryParams['show']; tabs: EndpointDetailsTabs[] }) => { + ({ + hostname, + show, + tabs, + }: { + hostname?: string; + show: EndpointIndexUIQueryParams['show']; + tabs: EndpointDetailsTabs[]; + }) => { + const dispatch = useDispatch<(action: EndpointAction) => void>(); + const { pageSize } = useEndpointSelector(getActivityLogDataPaging); const [selectedTabId, setSelectedTabId] = useState(() => { return show === 'details' ? EndpointDetailsTabsTypes.overview @@ -54,8 +48,33 @@ export const EndpointDetailsFlyoutTabs = memo( }); const handleTabClick = useCallback( - (tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EndpointDetailsTabsId), - [setSelectedTabId] + (tab: EuiTabbedContentTab) => { + dispatch({ + type: 'endpointDetailsFlyoutTabChanged', + payload: { + flyoutView: tab.id as EndpointIndexUIQueryParams['show'], + }, + }); + if (tab.id === EndpointDetailsTabsTypes.activityLog) { + const paging = { + page: 1, + pageSize, + }; + dispatch({ + type: 'appRequestedEndpointActivityLog', + payload: paging, + }); + dispatch({ + type: 'endpointDetailsActivityLogUpdatePaging', + payload: { + disabled: false, + ...paging, + }, + }); + } + return setSelectedTabId(tab.id as EndpointDetailsTabsId); + }, + [dispatch, pageSize, setSelectedTabId] ); const selectedTab = useMemo(() => tabs.find((tab) => tab.id === selectedTabId), [ @@ -63,14 +82,27 @@ export const EndpointDetailsFlyoutTabs = memo( selectedTabId, ]); + const renderTabs = tabs.map((tab) => ( + handleTabClick(tab)} + isSelected={tab.id === selectedTabId} + key={tab.id} + data-test-subj={tab.id} + > + {tab.name} + + )); + return ( - + <> + + + {renderTabs} + + + {selectedTab?.content} + + ); } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/flyout_header.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/flyout_header.tsx new file mode 100644 index 00000000000000..f791c0d6adf179 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/flyout_header.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiFlyoutHeader, EuiLoadingContent, EuiToolTip, EuiTitle } from '@elastic/eui'; +import { useEndpointSelector } from '../../hooks'; +import { detailsLoading } from '../../../store/selectors'; + +export const EndpointDetailsFlyoutHeader = memo( + ({ + hasBorder = false, + hostname, + children, + }: { + hasBorder?: boolean; + hostname?: string; + children?: React.ReactNode | React.ReactNodeArray; + }) => { + const hostDetailsLoading = useEndpointSelector(detailsLoading); + + return ( + + {hostDetailsLoading ? ( + + ) : ( + + +

+ {hostname} +

+
+
+ )} + {children} +
+ ); + } +); + +EndpointDetailsFlyoutHeader.displayName = 'EndpointDetailsFlyoutHeader'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx index c431cd682d25ba..4fe70039d12512 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -78,7 +78,7 @@ const useLogEntryUIProps = ( if (isSuccessful) { return i18.ACTIVITY_LOG.LogEntry.response.isolationSuccessful; } else { - return i18.ACTIVITY_LOG.LogEntry.response.isolationSuccessful; + return i18.ACTIVITY_LOG.LogEntry.response.isolationFailed; } } else { if (isSuccessful) { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx index 55479845bce0a3..f1701054c4d5f4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx @@ -5,11 +5,19 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useEffect, useRef } from 'react'; +import styled from 'styled-components'; -import { EuiButton, EuiEmptyPrompt, EuiLoadingContent, EuiSpacer } from '@elastic/eui'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, + EuiEmptyPrompt, +} from '@elastic/eui'; import { useDispatch } from 'react-redux'; import { LogEntry } from './components/log_entry'; +import * as i18 from '../translations'; import { Immutable, ActivityLog } from '../../../../../../common/endpoint/types'; import { AsyncResourceState } from '../../../../state'; import { useEndpointSelector } from '../hooks'; @@ -19,54 +27,95 @@ import { getActivityLogError, getActivityLogIterableData, getActivityLogRequestLoaded, + getLastLoadedActivityLogData, getActivityLogRequestLoading, } from '../../store/selectors'; +const LoadMoreTrigger = styled.div` + height: 6px; + width: 100%; +`; + export const EndpointActivityLog = memo( ({ activityLog }: { activityLog: AsyncResourceState> }) => { const activityLogLoading = useEndpointSelector(getActivityLogRequestLoading); const activityLogLoaded = useEndpointSelector(getActivityLogRequestLoaded); + const activityLastLogData = useEndpointSelector(getLastLoadedActivityLogData); const activityLogData = useEndpointSelector(getActivityLogIterableData); + const activityLogSize = activityLogData.length; const activityLogError = useEndpointSelector(getActivityLogError); - const dispatch = useDispatch<(a: EndpointAction) => void>(); - const { page, pageSize } = useEndpointSelector(getActivityLogDataPaging); + const dispatch = useDispatch<(action: EndpointAction) => void>(); + const { page, pageSize, disabled: isPagingDisabled } = useEndpointSelector( + getActivityLogDataPaging + ); + + const loadMoreTrigger = useRef(null); + const getActivityLog = useCallback( + (entries: IntersectionObserverEntry[]) => { + const isTargetIntersecting = entries.some((entry) => entry.isIntersecting); + if (isTargetIntersecting && activityLogLoaded && !isPagingDisabled) { + dispatch({ + type: 'appRequestedEndpointActivityLog', + payload: { + page: page + 1, + pageSize, + }, + }); + } + }, + [activityLogLoaded, dispatch, isPagingDisabled, page, pageSize] + ); - const getActivityLog = useCallback(() => { - dispatch({ - type: 'appRequestedEndpointActivityLog', - payload: { - page: page + 1, - pageSize, - }, - }); - }, [dispatch, page, pageSize]); + useEffect(() => { + const observer = new IntersectionObserver(getActivityLog); + const element = loadMoreTrigger.current; + if (element) { + observer.observe(element); + } + return () => { + observer.disconnect(); + }; + }, [getActivityLog]); return ( <> - - {activityLogLoading || activityLogError ? ( - {'No logged actions'}} - body={

{'No actions have been logged for this endpoint.'}

} - /> - ) : ( - <> - - {activityLogLoading ? ( - - ) : ( - activityLogLoaded && - activityLogData.map((logEntry) => ( - - )) - )} - - {'show more'} - - - )} + + {(activityLogLoaded && !activityLogSize) || activityLogError ? ( + + {i18.ACTIVITY_LOG.LogEntry.emptyState.title}} + body={

{i18.ACTIVITY_LOG.LogEntry.emptyState.body}

} + data-test-subj="activityLogEmpty" + /> +
+ ) : ( + <> + + {activityLogLoaded && + activityLogData.map((logEntry) => ( + + ))} + {activityLogLoading && + activityLastLogData?.data.map((logEntry) => ( + + ))} + + + {activityLogLoading && } + {(!activityLogLoading || !isPagingDisabled) && ( + + )} + {isPagingDisabled && !activityLogLoading && ( + +

{i18.ACTIVITY_LOG.LogEntry.endOfLog}

+
+ )} +
+ + )} +
); } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx index d839bbfaae8756..d3c91f6f18499e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx @@ -20,7 +20,6 @@ export const dummyEndpointActivityLog = ( ): AsyncResourceState> => ({ type: 'LoadedResourceState', data: { - total: 20, page: 1, pageSize: 50, data: [ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 59e0c0e787a222..e295ea145edcbe 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -5,21 +5,16 @@ * 2.0. */ +import { useDispatch } from 'react-redux'; import React, { useCallback, useEffect, useMemo, memo } from 'react'; -import styled from 'styled-components'; import { EuiFlyout, EuiFlyoutBody, - EuiFlyoutHeader, EuiFlyoutFooter, EuiLoadingContent, - EuiTitle, EuiText, EuiSpacer, EuiEmptyPrompt, - EuiToolTip, - EuiFlexGroup, - EuiFlexItem, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -30,7 +25,6 @@ import { uiQueryParams, detailsData, detailsError, - detailsLoading, getActivityLogData, showView, policyResponseConfigurations, @@ -59,23 +53,12 @@ import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpo import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding'; import { getEndpointListPath } from '../../../../common/routing'; import { ActionsMenu } from './components/actions_menu'; - -const DetailsFlyoutBody = styled(EuiFlyoutBody)` - overflow-y: hidden; - flex: 1; - - .euiFlyoutBody__overflow { - overflow: hidden; - mask-image: none; - } - - .euiFlyoutBody__overflowContent { - height: 100%; - display: flex; - } -`; +import { EndpointIndexUIQueryParams } from '../../types'; +import { EndpointAction } from '../../store/action'; +import { EndpointDetailsFlyoutHeader } from './components/flyout_header'; export const EndpointDetailsFlyout = memo(() => { + const dispatch = useDispatch<(action: EndpointAction) => void>(); const history = useHistory(); const toasts = useToasts(); const queryParams = useEndpointSelector(uiQueryParams); @@ -86,13 +69,24 @@ export const EndpointDetailsFlyout = memo(() => { const activityLog = useEndpointSelector(getActivityLogData); const hostDetails = useEndpointSelector(detailsData); - const hostDetailsLoading = useEndpointSelector(detailsLoading); const hostDetailsError = useEndpointSelector(detailsError); const policyInfo = useEndpointSelector(policyVersionInfo); const hostStatus = useEndpointSelector(hostStatusInfo); const show = useEndpointSelector(showView); + const setFlyoutView = useCallback( + (flyoutView: EndpointIndexUIQueryParams['show']) => { + dispatch({ + type: 'endpointDetailsFlyoutTabChanged', + payload: { + flyoutView, + }, + }); + }, + [dispatch] + ); + const ContentLoadingMarkup = useMemo( () => ( <> @@ -133,9 +127,11 @@ export const EndpointDetailsFlyout = memo(() => { ...urlSearchParams, }) ); - }, [history, queryParamsWithoutSelectedEndpoint]); + setFlyoutView(undefined); + }, [setFlyoutView, history, queryParamsWithoutSelectedEndpoint]); useEffect(() => { + setFlyoutView(show); if (hostDetailsError !== undefined) { toasts.addDanger({ title: i18n.translate('xpack.securitySolution.endpoint.details.errorTitle', { @@ -146,7 +142,10 @@ export const EndpointDetailsFlyout = memo(() => { }), }); } - }, [hostDetailsError, toasts]); + return () => { + setFlyoutView(undefined); + }; + }, [hostDetailsError, setFlyoutView, show, toasts]); return ( { size="m" paddingSize="l" > - - {hostDetailsLoading ? ( - - ) : ( - - -

- {hostDetails?.host?.hostname} -

-
-
- )} -
+ {(show === 'policy_response' || show === 'isolate' || show === 'unisolate') && ( + + )} {hostDetails === undefined ? ( @@ -179,13 +165,11 @@ export const EndpointDetailsFlyout = memo(() => { ) : ( <> {(show === 'details' || show === 'activity_log') && ( - - - - - - - + )} {show === 'policy_response' && } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 6aab9336c21a43..4869ce84fad2cf 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -17,6 +17,7 @@ import { } from '../store/mock_endpoint_result_list'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { + ActivityLog, HostInfo, HostPolicyResponse, HostPolicyResponseActionStatus, @@ -32,12 +33,15 @@ import { KibanaServices, useKibana, useToasts } from '../../../../common/lib/kib import { hostIsolationHttpMocks } from '../../../../common/lib/endpoint_isolation/mocks'; import { fireEvent } from '@testing-library/dom'; import { + createFailedResourceState, + createLoadedResourceState, isFailedResourceState, isLoadedResourceState, isUninitialisedResourceState, } from '../../../state'; import { getCurrentIsolationRequestState } from '../store/selectors'; import { licenseService } from '../../../../common/hooks/use_license'; +import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator'; // not sure why this can't be imported from '../../../../common/mock/formatted_relative'; // but sure enough it needs to be inline in this one file @@ -625,6 +629,30 @@ describe('when on the endpoint list page', () => { }); }; + const dispatchEndpointDetailsActivityLogChanged = ( + dataState: 'failed' | 'success', + data: ActivityLog + ) => { + reactTestingLibrary.act(() => { + const getPayload = () => { + switch (dataState) { + case 'failed': + return createFailedResourceState({ + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred.', + }); + case 'success': + return createLoadedResourceState(data); + } + }; + store.dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: getPayload(), + }); + }); + }; + beforeEach(async () => { mockEndpointListApi(); @@ -746,6 +774,120 @@ describe('when on the endpoint list page', () => { expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull(); }); + describe('when showing Activity Log panel', () => { + let renderResult: ReturnType; + const agentId = 'some_agent_id'; + + let getMockData: () => ActivityLog; + beforeEach(async () => { + window.IntersectionObserver = jest.fn(() => ({ + root: null, + rootMargin: '', + thresholds: [], + takeRecords: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })); + + const fleetActionGenerator = new FleetActionGenerator('seed'); + const responseData = fleetActionGenerator.generateResponse({ + agent_id: agentId, + }); + const actionData = fleetActionGenerator.generate({ + agents: [agentId], + }); + getMockData = () => ({ + page: 1, + pageSize: 50, + data: [ + { + type: 'response', + item: { + id: 'some_id_0', + data: responseData, + }, + }, + { + type: 'action', + item: { + id: 'some_id_1', + data: actionData, + }, + }, + ], + }); + + renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedEndpointList'); + }); + const hostNameLinks = await renderResult.getAllByTestId('hostnameCellLink'); + reactTestingLibrary.fireEvent.click(hostNameLinks[0]); + }); + + afterEach(reactTestingLibrary.cleanup); + + it('should show the endpoint details flyout', async () => { + const activityLogTab = await renderResult.findByTestId('activity_log'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(activityLogTab); + }); + await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged'); + reactTestingLibrary.act(() => { + dispatchEndpointDetailsActivityLogChanged('success', getMockData()); + }); + const endpointDetailsFlyout = await renderResult.queryByTestId('endpointDetailsFlyoutBody'); + expect(endpointDetailsFlyout).not.toBeNull(); + }); + + it('should display log accurately', async () => { + const activityLogTab = await renderResult.findByTestId('activity_log'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(activityLogTab); + }); + await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged'); + reactTestingLibrary.act(() => { + dispatchEndpointDetailsActivityLogChanged('success', getMockData()); + }); + const logEntries = await renderResult.queryAllByTestId('timelineEntry'); + expect(logEntries.length).toEqual(2); + expect(`${logEntries[0]} .euiCommentTimeline__icon--update`).not.toBe(null); + expect(`${logEntries[1]} .euiCommentTimeline__icon--regular`).not.toBe(null); + }); + + it('should display empty state when API call has failed', async () => { + const activityLogTab = await renderResult.findByTestId('activity_log'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(activityLogTab); + }); + await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged'); + reactTestingLibrary.act(() => { + dispatchEndpointDetailsActivityLogChanged('failed', getMockData()); + }); + const emptyState = await renderResult.queryByTestId('activityLogEmpty'); + expect(emptyState).not.toBe(null); + }); + + it('should display empty state when no log data', async () => { + const activityLogTab = await renderResult.findByTestId('activity_log'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(activityLogTab); + }); + await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged'); + reactTestingLibrary.act(() => { + dispatchEndpointDetailsActivityLogChanged('success', { + page: 1, + pageSize: 50, + data: [], + }); + }); + + const emptyState = await renderResult.queryByTestId('activityLogEmpty'); + expect(emptyState).not.toBe(null); + }); + }); + describe('when showing host Policy Response panel', () => { let renderResult: ReturnType; beforeEach(async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts index 18a5bd1e5130a5..89ffd2d23807ef 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts @@ -16,6 +16,26 @@ export const ACTIVITY_LOG = { defaultMessage: 'Activity Log', }), LogEntry: { + endOfLog: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.endOfLog', + { + defaultMessage: 'Nothing more to show', + } + ), + emptyState: { + title: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.emptyState.title', + { + defaultMessage: 'No logged actions', + } + ), + body: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.emptyState.body', + { + defaultMessage: 'No actions have been logged for this endpoint.', + } + ), + }, action: { isolatedAction: i18n.translate( 'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.isolated', diff --git a/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx index a3fd32008062cd..63971ae508d5cd 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx @@ -14,6 +14,8 @@ import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { Ip } from '.'; +jest.mock('../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx index 7ec18c078c73d7..a811f5c92c37a9 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx @@ -25,6 +25,8 @@ import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { NetworkDnsTable } from '.'; import { mockData } from './mock'; +jest.mock('../../../common/lib/kibana'); + describe('NetworkTopNFlow Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx index f7f75d9f0a365d..f05372c76b36fc 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx @@ -25,6 +25,7 @@ import { networkModel } from '../../store'; import { NetworkHttpTable } from '.'; import { mockData } from './mock'; +jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/components/link_to'); describe('NetworkHttp Table Component', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx index 1501f56882290c..a0727fad65f188 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx @@ -27,6 +27,8 @@ import { networkModel } from '../../store'; import { NetworkTopCountriesTable } from '.'; import { mockData } from './mock'; +jest.mock('../../../common/lib/kibana'); + describe('NetworkTopCountries Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx index cd8c8c6543299c..e2b9447b588060 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx @@ -25,6 +25,7 @@ import { NetworkTopNFlowTable } from '.'; import { mockData } from './mock'; import { FlowTargetSourceDest } from '../../../../common/search_strategy'; +jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/components/link_to'); describe('NetworkTopNFlow Table Component', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/port/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/port/index.test.tsx index ef1039bfc92e37..dd7ad20d2384a3 100644 --- a/x-pack/plugins/security_solution/public/network/components/port/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/port/index.test.tsx @@ -15,6 +15,8 @@ import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { Port } from '.'; +jest.mock('../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/index.test.tsx index 01065ad5bf15f1..b59eb25cbfe256 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/index.test.tsx @@ -49,6 +49,8 @@ import { NETWORK_TRANSPORT_FIELD_NAME, } from './field_names'; +jest.mock('../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx index f767e793c8f214..91f7ea3d7ac7a5 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx @@ -38,6 +38,8 @@ import { SOURCE_GEO_REGION_NAME_FIELD_NAME, } from './geo_fields'; +jest.mock('../../../common/lib/kibana'); + jest.mock('../../../common/components/link_to'); describe('SourceDestinationIp', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx index 4b6c31f5b61768..8f2c7a098a0457 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx @@ -24,6 +24,8 @@ import { networkModel } from '../../store'; import { TlsTable } from '.'; import { mockTlsData } from './mock'; +jest.mock('../../../common/lib/kibana'); + describe('Tls Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx index 4b613e79a1d1a3..69027ad9bd9f8a 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx @@ -26,6 +26,8 @@ import { UsersTable } from '.'; import { mockUsersData } from './mock'; import { FlowTarget } from '../../../../common/search_strategy'; +jest.mock('../../../common/lib/kibana'); + describe('Users Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; diff --git a/x-pack/plugins/security_solution/public/network/pages/network.tsx b/x-pack/plugins/security_solution/public/network/pages/network.tsx index e1aa7ca0c1840c..13c04a5e5ec5b7 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -12,6 +12,7 @@ import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; +import { isTab } from '../../../../timelines/public'; import { esQuery } from '../../../../../../src/plugins/data/public'; import { SecurityPageName } from '../../app/types'; import { UpdateDateRange } from '../../common/components/charts/common'; @@ -46,7 +47,6 @@ import { showGlobalFilters, } from '../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../timelines/store/timeline'; -import { isTab } from '../../common/components/accessibility/helpers'; import { TimelineId } from '../../../common/types/timeline'; import { timelineDefaults } from '../../timelines/store/timeline/defaults'; import { useSourcererScope } from '../../common/containers/sourcerer'; diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx index b43d5af029ec47..45898427ee60b8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx @@ -15,6 +15,8 @@ import { TestProviders } from '../../../../common/mock'; import { EndpointOverview } from './index'; import { HostPolicyResponseActionStatus } from '../../../../../common/search_strategy/security_solution/hosts'; +jest.mock('../../../../common/lib/kibana'); + describe('EndpointOverview Component', () => { test('it renders with endpoint data', () => { const endpointData = { diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 6dd5ba694228c3..32e6748f38141c 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -6,8 +6,10 @@ */ import { i18n } from '@kbn/i18n'; +import reduceReducers from 'reduce-reducers'; import { BehaviorSubject, Subject, Subscription } from 'rxjs'; import { pluck } from 'rxjs/operators'; +import { AnyAction, Reducer } from 'redux'; import { PluginSetup, PluginStart, @@ -72,6 +74,7 @@ import { getLazyEndpointPolicyEditExtension } from './management/pages/policy/vi import { LazyEndpointPolicyCreateExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_create_extension'; import { getLazyEndpointPackageCustomExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_package_custom_extension'; import { parseExperimentalConfigValue } from '../common/experimental_features'; +import type { TimelineState } from '../../timelines/public'; export class Plugin implements IPlugin { private kibanaVersion: string; @@ -471,7 +474,7 @@ export class Plugin implements IPlugin( { indices: defaultIndicesName, onlyCheckIfIndicesExist: true }, { - strategy: 'securitySolutionIndexFields', + strategy: 'indexFields', } ) .toPromise(), @@ -500,7 +503,6 @@ export class Plugin implements IPlugin; + this._store = createStore( createInitialState( { @@ -531,13 +540,17 @@ export class Plugin implements IPlugin { const mount = useMountAppended(); test('renders the expected label', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx index 4c90d3738a1985..ea8317346cd998 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx @@ -14,6 +14,8 @@ import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { Duration } from '.'; +jest.mock('../../../common/lib/kibana'); + describe('Duration', () => { const mount = useMountAppended(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx index 5becf7ea8bc6b5..e2194156ecf4de 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx @@ -29,6 +29,8 @@ import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { AutonomousSystem, FlowTarget } from '../../../../common/search_strategy'; import { HostEcs } from '../../../../common/ecs/host'; +jest.mock('../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx index 77a8d0082bf23d..da2ff248d9a5df 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx @@ -14,7 +14,7 @@ import { DATA_COLINDEX_ATTRIBUTE, DATA_ROWINDEX_ATTRIBUTE, onKeyDownFocusHandler, -} from '../../../common/components/accessibility/helpers'; +} from '../../../../../timelines/public'; import { BrowserFields } from '../../../common/containers/source'; import { getCategoryColumns } from './category_columns'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.test.tsx index c3c55206f8d530..c95463dea5b279 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.test.tsx @@ -17,6 +17,9 @@ import { TestProviders } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; import * as i18n from './translations'; + +jest.mock('../../../common/lib/kibana'); + describe('Category', () => { const timelineId = 'test'; const selectedCategoryId = 'client'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.tsx index 636ebf022cffb2..deafda95ceab2c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.tsx @@ -9,13 +9,13 @@ import { EuiInMemoryTable } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React, { useCallback, useMemo, useRef } from 'react'; import styled from 'styled-components'; - import { arrayIndexToAriaIndex, DATA_COLINDEX_ATTRIBUTE, DATA_ROWINDEX_ATTRIBUTE, onKeyDownFocusHandler, -} from '../../../common/components/accessibility/helpers'; +} from '../../../../../timelines/public'; + import { BrowserFields } from '../../../common/containers/source'; import { OnUpdateColumns } from '../timeline/events'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.tsx index 15164cd151574f..528791328fdb99 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.tsx @@ -18,6 +18,7 @@ import { import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { BrowserFields } from '../../../common/containers/source'; import { getColumnsWithTimestamp } from '../../../common/components/event_details/helpers'; import { CountBadge } from '../../../common/components/page'; @@ -29,7 +30,7 @@ import { VIEW_ALL_BUTTON_CLASS_NAME, } from './helpers'; import * as i18n from './translations'; -import { useManageTimeline } from '../manage_timeline'; +import { timelineSelectors } from '../../store/timeline'; const CategoryName = styled.span<{ bold: boolean }>` .euiText { @@ -67,11 +68,10 @@ interface ViewAllButtonProps { export const ViewAllButton = React.memo( ({ categoryId, browserFields, onUpdateColumns, timelineId }) => { - const { getManageTimelineById } = useManageTimeline(); - const { isLoading } = useMemo(() => getManageTimelineById(timelineId) ?? { isLoading: false }, [ - getManageTimelineById, - timelineId, - ]); + const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); + const { isLoading } = useDeepEqualSelector((state) => + getManageTimeline(state, timelineId ?? '') + ); const handleClick = useCallback(() => { onUpdateColumns( diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_title.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_title.test.tsx index 70cc535cb59a90..6af4b5c5c312e8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_title.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_title.test.tsx @@ -9,6 +9,7 @@ import { mount } from 'enzyme'; import React from 'react'; import { mockBrowserFields } from '../../../common/containers/source/mock'; +import { TestProviders } from '../../../common/mock'; import { CategoryTitle } from './category_title'; import { getFieldCount } from './helpers'; @@ -19,12 +20,14 @@ describe('CategoryTitle', () => { test('it renders the category id as the value of the title', () => { const categoryId = 'client'; const wrapper = mount( - + + + ); expect(wrapper.find('[data-test-subj="selected-category-title"]').first().text()).toEqual( @@ -35,12 +38,14 @@ describe('CategoryTitle', () => { test('when `categoryId` specifies a valid category in `filteredBrowserFields`, a count of the field is displayed in the badge', () => { const validCategoryId = 'client'; const wrapper = mount( - + + + ); expect(wrapper.find(`[data-test-subj="selected-category-count-badge"]`).first().text()).toEqual( @@ -51,12 +56,14 @@ describe('CategoryTitle', () => { test('when `categoryId` specifies an INVALID category in `filteredBrowserFields`, a count of zero is displayed in the badge', () => { const invalidCategoryId = 'this.is.not.happening'; const wrapper = mount( - + + + ); expect(wrapper.find(`[data-test-subj="selected-category-count-badge"]`).first().text()).toEqual( 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 c4f76c639c7c1d..0496b9d7c8886f 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 @@ -19,13 +19,8 @@ import { noop } from 'lodash/fp'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; -import { - isEscape, - isTab, - stopPropagationAndPreventDefault, -} from '../../../common/components/accessibility/helpers'; +import { isEscape, isTab, stopPropagationAndPreventDefault } from '../../../../../timelines/public'; import { BrowserFields } from '../../../common/containers/source'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { CategoriesPane } from './categories_pane'; import { FieldsPane } from './fields_pane'; import { Header } from './header'; @@ -42,6 +37,7 @@ import { FieldBrowserProps, OnHideFieldBrowser } from './types'; import { timelineActions } from '../../store/timeline'; import * as i18n from './translations'; +import { ColumnHeaderOptions } from '../../../../common'; const FieldsBrowserContainer = styled.div<{ width: number }>` background-color: ${({ theme }) => theme.eui.euiColorLightestShade}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx index 07911541bb2fe6..e40807dc85dc7e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx @@ -12,7 +12,6 @@ import { waitFor } from '@testing-library/react'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { TestProviders } from '../../../common/mock'; import '../../../common/mock/match_media'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../timeline/body/constants'; @@ -20,6 +19,9 @@ import { Category } from './category'; import { getFieldColumns, getFieldItems } from './field_items'; import { FIELDS_PANE_WIDTH } from './helpers'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; +import { ColumnHeaderOptions } from '../../../../common'; + +jest.mock('../../../common/lib/kibana'); const selectedCategoryId = 'base'; const selectedCategoryFields = mockBrowserFields[selectedCategoryId].fields; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx index a2db284e517901..89a91ee6da305d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx @@ -18,14 +18,12 @@ import React, { useCallback, useRef, useState } from 'react'; import { Draggable } from 'react-beautiful-dnd'; import styled from 'styled-components'; +import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-grid'; import { BrowserField, BrowserFields } from '../../../common/containers/source'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; -import { useDraggableKeyboardWrapper } from '../../../common/components/drag_and_drop/draggable_keyboard_wrapper_hook'; import { DragEffects } from '../../../common/components/drag_and_drop/draggable_wrapper'; import { DroppableWrapper } from '../../../common/components/drag_and_drop/droppable_wrapper'; import { DRAG_TYPE_FIELD, - DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, getDraggableFieldId, getDroppableId, } from '../../../common/components/drag_and_drop/helpers'; @@ -43,6 +41,8 @@ import { TruncatableText } from '../../../common/components/truncatable_text'; import { FieldName } from './field_name'; import * as i18n from './translations'; import { getAlertColumnHeader } from './helpers'; +import { ColumnHeaderOptions } from '../../../../common'; +import { useKibana } from '../../../common/lib/kibana'; const TypeIcon = styled(EuiIcon)` margin: 0 4px; @@ -92,6 +92,7 @@ const DraggableFieldsBrowserFieldComponent = ({ const keyboardHandlerRef = useRef(null); const [closePopOverTrigger, setClosePopOverTrigger] = useState(false); const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState(false); + const { timelines } = useKibana().services; const handleClosePopOverTrigger = useCallback(() => { setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger); @@ -115,7 +116,7 @@ const DraggableFieldsBrowserFieldComponent = ({ setHoverActionsOwnFocus(true); }, [setHoverActionsOwnFocus]); - const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({ + const { onBlur, onKeyDown } = timelines.getUseDraggableKeyboardWrapper()({ closePopover: handleClosePopOverTrigger, draggableId: getDraggableFieldId({ contextId: `field-browser-field-items-field-draggable-${timelineId}-${categoryId}-${fieldName}`, diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx index 493f2e44263e39..5014a198e8bd5d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx @@ -15,6 +15,8 @@ import { getColumnsWithTimestamp } from '../../../common/components/event_detail import { FieldName } from './field_name'; +jest.mock('../../../common/lib/kibana'); + const categoryId = 'base'; const timestampFieldId = '@timestamp'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx index 09bd18ef62fb10..2e76e43227506b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx @@ -9,13 +9,13 @@ import { EuiHighlight, EuiText } from '@elastic/eui'; import React, { useCallback, useState, useMemo, useRef } from 'react'; import styled from 'styled-components'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { OnUpdateColumns } from '../timeline/events'; import { WithHoverActions } from '../../../common/components/with_hover_actions'; import { DraggableWrapperHoverContent, useGetTimelineId, } from '../../../common/components/drag_and_drop/draggable_wrapper_hover_content'; +import { ColumnHeaderOptions } from '../../../../common'; /** * The name of a (draggable) field diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.test.tsx index 3f1b0300ad70df..6d17f148aa1dcf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.test.tsx @@ -15,6 +15,8 @@ import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { FIELDS_PANE_WIDTH } from './helpers'; import { FieldsPane } from './fields_pane'; +jest.mock('../../../common/lib/kibana'); + const timelineId = 'test'; describe('FieldsPane', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.tsx index 15df232a1a4545..dfb4edad17414e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.tsx @@ -11,7 +11,6 @@ import styled from 'styled-components'; import { useDispatch } from 'react-redux'; import { BrowserFields } from '../../../common/containers/source'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { timelineActions } from '../../../timelines/store/timeline'; import { OnUpdateColumns } from '../timeline/events'; import { Category } from './category'; @@ -20,6 +19,7 @@ import { getFieldItems } from './field_items'; import { FIELDS_PANE_WIDTH, TABLE_HEIGHT } from './helpers'; import * as i18n from './translations'; +import { ColumnHeaderOptions } from '../../../../common'; const NoFieldsPanel = styled.div` background-color: ${(props) => props.theme.eui.euiColorLightestShade}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.test.tsx index aa53b1922f3a35..89b361e86422ea 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.test.tsx @@ -9,7 +9,6 @@ import { mount } from 'enzyme'; import React from 'react'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { TestProviders } from '../../../common/mock'; -import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; import { Header } from './header'; const timelineId = 'test'; @@ -72,7 +71,7 @@ describe('Header', () => { wrapper.find('[data-test-subj="reset-fields"]').first().simulate('click'); - expect(onUpdateColumns).toBeCalledWith(defaultHeaders); + expect(onUpdateColumns).toBeCalled(); }); test('it invokes onOutsideClick when the user clicks the Reset Fields button', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.tsx index 120a82a4046e39..b52c6cd672ac79 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.tsx @@ -13,10 +13,12 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../../common/containers/source'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { timelineSelectors } from '../../store/timeline'; import { OnUpdateColumns } from '../timeline/events'; import { @@ -27,7 +29,6 @@ import { } from './helpers'; import * as i18n from './translations'; -import { useManageTimeline } from '../manage_timeline'; const CountsFlexGroup = styled(EuiFlexGroup)` margin-top: 5px; @@ -101,13 +102,13 @@ const TitleRow = React.memo<{ onOutsideClick: () => void; onUpdateColumns: OnUpdateColumns; }>(({ id, onOutsideClick, onUpdateColumns }) => { - const { getManageTimelineById } = useManageTimeline(); + const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); + const { defaultColumns } = useDeepEqualSelector((state) => getManageTimeline(state, id)); const handleResetColumns = useCallback(() => { - const timeline = getManageTimelineById(id); - onUpdateColumns(timeline.defaultModel.columns); + onUpdateColumns(defaultColumns); onOutsideClick(); - }, [id, onUpdateColumns, onOutsideClick, getManageTimelineById]); + }, [onUpdateColumns, onOutsideClick, defaultColumns]); return ( { const timelineId = 'test'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/types.ts b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/types.ts index 4d912f73c7ef28..ea71a8860ab016 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/types.ts @@ -5,8 +5,8 @@ * 2.0. */ +import { ColumnHeaderOptions } from '../../../../common'; import { BrowserFields } from '../../../common/containers/source'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; export type OnFieldSelected = (fieldId: string) => void; export type OnHideFieldBrowser = () => void; diff --git a/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx index 802dd74c1892b4..31f2fec9424907 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx @@ -14,6 +14,8 @@ import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { Ja3Fingerprint } from '.'; +jest.mock('../../../common/lib/kibana'); + describe('Ja3Fingerprint', () => { const mount = useMountAppended(); 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 deleted file mode 100644 index ed299c3a4ef1a1..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { 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'; - -const isStringifiedComparisonEqual = (a: {}, b: {}): boolean => - JSON.stringify(a) === JSON.stringify(b); - -describe('useTimelineManager', () => { - const setupMock = coreMock.createSetup(); - const testId = 'coolness'; - const timelineDefaults = getTimelineDefaults(testId); - const mockFilterManager = new FilterManager(setupMock.uiSettings); - - beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - it('initializes an undefined timeline', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useTimelineManager() - ); - await waitForNextUpdate(); - const uninitializedTimeline = result.current.getManageTimelineById(testId); - expect(isStringifiedComparisonEqual(uninitializedTimeline, timelineDefaults)).toBeTruthy(); - }); - }); - // TO DO sourcerer - // it('getIndexToAddById', async () => { - // await act(async () => { - // const { result, waitForNextUpdate } = renderHook(() => - // useTimelineManager() - // ); - // await waitForNextUpdate(); - // const data = result.current.getIndexToAddById(testId); - // expect(data).toEqual(timelineDefaults.indexToAdd); - // }); - // }); - // - // it('setIndexToAdd', async () => { - // await act(async () => { - // const indexToAddArgs = { id: testId, indexToAdd: ['example'] }; - // const { result, waitForNextUpdate } = renderHook(() => - // useTimelineManager() - // ); - // await waitForNextUpdate(); - // result.current.initializeTimeline({ - // id: testId, - // }); - // 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 }; - const { result, waitForNextUpdate } = renderHook(() => - useTimelineManager() - ); - await waitForNextUpdate(); - result.current.initializeTimeline({ - id: testId, - }); - let timeline = result.current.getManageTimelineById(testId); - expect(timeline.isLoading).toBeFalsy(); - result.current.setIsTimelineLoading(isLoadingArgs); - timeline = result.current.getManageTimelineById(testId); - expect(timeline.isLoading).toBeTruthy(); - }); - }); - - it('getTimelineFilterManager undefined on uninitialized', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useTimelineManager() - ); - await waitForNextUpdate(); - const data = result.current.getTimelineFilterManager(testId); - expect(data).toEqual(undefined); - }); - }); - - it('getTimelineFilterManager defined at initialize', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useTimelineManager() - ); - await waitForNextUpdate(); - result.current.initializeTimeline({ - id: testId, - 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(() => - useTimelineManager() - ); - await waitForNextUpdate(); - let data = result.current.isManagedTimeline(testId); - expect(data).toBeFalsy(); - result.current.initializeTimeline({ - id: testId, - filterManager: mockFilterManager, - }); - data = result.current.isManagedTimeline(testId); - expect(data).toBeTruthy(); - }); - }); -}); 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 deleted file mode 100644 index 1f215ee8f2141b..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { createContext, useCallback, useContext, useReducer } from 'react'; -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 { 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'; - -interface ManageTimelineInit { - documentType?: string; - defaultModel?: SubsetTimelineModel; - filterManager?: FilterManager; - footerText?: string; - id: string; - loadingText?: string; - selectAll?: boolean; - queryFields?: string[]; - title?: string; - unit?: (totalCount: number) => string; -} - -interface ManageTimeline { - documentType: string; - defaultModel: SubsetTimelineModel; - filterManager?: FilterManager; - footerText: string; - id: string; - isLoading: boolean; - loadingText: string; - queryFields: string[]; - selectAll: boolean; - title: string; - unit: (totalCount: number) => string; -} - -interface ManageTimelineById { - [id: string]: ManageTimeline; -} -const initManageTimeline: ManageTimelineById = {}; -type ActionManageTimeline = - | { - type: 'INITIALIZE_TIMELINE'; - id: string; - payload: ManageTimelineInit; - } - | { - type: 'SET_IS_LOADING'; - id: string; - payload: boolean; - } - | { - type: 'SET_SELECT_ALL'; - id: string; - payload: boolean; - }; - -export const getTimelineDefaults = (id: string) => ({ - defaultModel: timelineDefaultModel, - loadingText: i18n.LOADING_EVENTS, - footerText: i18nF.TOTAL_COUNT_OF_EVENTS, - documentType: i18nF.TOTAL_COUNT_OF_EVENTS, - selectAll: false, - id, - isLoading: false, - queryFields: [], - title: i18n.EVENTS, - unit: (n: number) => i18n.UNIT(n), -}); -const reducerManageTimeline = ( - state: ManageTimelineById, - action: ActionManageTimeline -): ManageTimelineById => { - switch (action.type) { - case 'INITIALIZE_TIMELINE': - return { - ...state, - [action.id]: { - ...getTimelineDefaults(action.id), - ...state[action.id], - ...action.payload, - }, - } as ManageTimelineById; - case 'SET_SELECT_ALL': - return { - ...state, - [action.id]: { - ...state[action.id], - selectAll: action.payload, - }, - } as ManageTimelineById; - - case 'SET_IS_LOADING': - return { - ...state, - [action.id]: { - ...state[action.id], - isLoading: action.payload, - }, - } as ManageTimelineById; - default: - return state; - } -}; - -export interface UseTimelineManager { - getManageTimelineById: (id: string) => ManageTimeline; - getTimelineFilterManager: (id: string) => FilterManager | undefined; - initializeTimeline: (newTimeline: ManageTimelineInit) => void; - isManagedTimeline: (id: string) => boolean; - setIsTimelineLoading: (isLoadingArgs: { id: string; isLoading: boolean }) => void; - setSelectAll: (selectAllArgs: { id: string; selectAll: boolean }) => void; -} - -export const useTimelineManager = ( - manageTimelineForTesting?: ManageTimelineById -): UseTimelineManager => { - const [state, dispatch] = useReducer< - (state: ManageTimelineById, action: ActionManageTimeline) => ManageTimelineById - >(reducerManageTimeline, manageTimelineForTesting ?? initManageTimeline); - - const initializeTimeline = useCallback((newTimeline: ManageTimelineInit) => { - dispatch({ - type: 'INITIALIZE_TIMELINE', - id: newTimeline.id, - payload: newTimeline, - }); - }, []); - - const setIsTimelineLoading = useCallback( - ({ id, isLoading }: { id: string; isLoading: boolean }) => { - dispatch({ - type: 'SET_IS_LOADING', - id, - payload: isLoading, - }); - }, - [] - ); - - const setSelectAll = useCallback(({ id, selectAll }: { id: string; selectAll: boolean }) => { - dispatch({ - type: 'SET_SELECT_ALL', - id, - payload: selectAll, - }); - }, []); - - const getTimelineFilterManager = useCallback( - (id: string): FilterManager | undefined => state[id]?.filterManager, - [state] - ); - const getManageTimelineById = useCallback( - (id: string): ManageTimeline => { - if (state[id] != null) { - return state[id]; - } - initializeTimeline({ id }); - return getTimelineDefaults(id); - }, - [initializeTimeline, state] - ); - const isManagedTimeline = useCallback((id: string): boolean => state[id] != null, [state]); - - return { - getManageTimelineById, - getTimelineFilterManager, - initializeTimeline, - isManagedTimeline, - setIsTimelineLoading, - setSelectAll, - }; -}; - -const init = { - getManageTimelineById: (id: string) => getTimelineDefaults(id), - getTimelineFilterManager: () => undefined, - initializeTimeline: () => noop, - isManagedTimeline: () => false, - setIsTimelineLoading: () => noop, - setSelectAll: () => noop, -}; - -const ManageTimelineContext = createContext(init); - -export const useManageTimeline = () => useContext(ManageTimelineContext); - -interface ManageGlobalTimelineProps { - children: React.ReactNode; - manageTimelineForTesting?: ManageTimelineById; -} - -export const ManageGlobalTimeline = ({ - children, - manageTimelineForTesting, -}: ManageGlobalTimelineProps) => { - const timelineManager = useTimelineManager(manageTimelineForTesting); - - return ( - - {children} - - ); -}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx index e2c8b8854504a2..c73e372b4a71c9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx @@ -62,6 +62,8 @@ import { } from '../../../network/components/source_destination/field_names'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; +jest.mock('../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx index 0544b00a79227a..00d2a7b35483e9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx @@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiPanel, EuiScreenReaderOnly } from '@elastic/eui'; import React, { useState, useCallback } from 'react'; import styled from 'styled-components'; -import { getNotesContainerClassName } from '../../../../common/components/accessibility/helpers'; +import { getNotesContainerClassName } from '../../../../../../timelines/public'; import { AddNote } from '../add_note'; import { AssociateNote } from '../helpers'; import { NotePreviews, NotePreviewsContainer } from '../../open_timeline/note_previews'; 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 c06c3f076e097a..c0fea1f210a8a5 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,6 @@ import { formatTimelineResultToModel, } from './helpers'; import { OpenTimelineResult, DispatchUpdateTimeline } from './types'; -import { KueryFilterQueryKind } from '../../../common/store'; import { Note } from '../../../common/lib/note'; import moment from 'moment'; import sinon from 'sinon'; @@ -45,6 +44,7 @@ import { TimelineType, TimelineStatus, TimelineTabs, + KueryFilterQueryKind, } from '../../../../common/types/timeline'; import { mockTimeline as mockSelectedTimeline, 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 e45a1a117769b0..03ac0b3d14342a 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 @@ -13,6 +13,7 @@ import { Dispatch } from 'redux'; import deepMerge from 'deepmerge'; import { + ColumnHeaderOptions, DataProviderType, TimelineId, TimelineStatus, @@ -37,7 +38,7 @@ import { addTimeline as dispatchAddTimeline, addNote as dispatchAddGlobalTimelineNote, } from '../../../timelines/store/timeline/actions'; -import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model'; +import { TimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx index 9887563c0fef6a..2daebdf37e77fb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx @@ -16,41 +16,28 @@ import { import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline'; +import { + HeaderActionProps, + SortDirection, + TimelineId, + TimelineTabs, +} from '../../../../../../common/types/timeline'; import { EXIT_FULL_SCREEN } from '../../../../../common/components/exit_full_screen/translations'; import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../../../common/constants'; import { useGlobalFullScreen, useTimelineFullScreen, } from '../../../../../common/containers/use_full_screen'; -import { BrowserFields } from '../../../../../common/containers/source'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { OnSelectAll } from '../../events'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; import { StatefulFieldsBrowser } from '../../../fields_browser'; import { StatefulRowRenderersBrowser } from '../../../row_renderers_browser'; import { FIELD_BROWSER_HEIGHT, FIELD_BROWSER_WIDTH } from '../../../fields_browser/helpers'; import { EventsTh, EventsThContent } from '../../styles'; -import { Sort, SortDirection } from '../sort'; import { EventsSelect } from '../column_headers/events_select'; import * as i18n from '../column_headers/translations'; import { timelineActions } from '../../../../store/timeline'; import { isFullScreen } from '../column_headers'; -export interface HeaderActionProps { - width: number; - browserFields: BrowserFields; - columnHeaders: ColumnHeaderOptions[]; - isEventViewer?: boolean; - isSelectAllChecked: boolean; - onSelectAll: OnSelectAll; - showEventsSelect: boolean; - showSelectAllCheckbox: boolean; - sort: Sort[]; - tabType: TimelineTabs; - timelineId: string; -} - const SortingColumnsContainer = styled.div` button { color: ${({ theme }) => theme.eui.euiColorPrimary}; 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 a186b324cc03ac..82d593e80bc443 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 @@ -41,8 +41,6 @@ describe('Actions', () => { eventId="abc" loadingEventIds={[]} onEventDetailsPanelOpened={jest.fn()} - onPinEvent={jest.fn()} - onUnPinEvent={jest.fn()} onRowSelected={jest.fn()} showNotes={false} isEventPinned={false} @@ -74,8 +72,6 @@ describe('Actions', () => { toggleShowNotes={jest.fn()} timelineId={'test'} refetch={jest.fn()} - onPinEvent={jest.fn()} - onUnPinEvent={jest.fn()} columnId={''} index={2} eventId="abc" 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 2053b9a0da942d..0a3a1cd88acccd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -6,7 +6,9 @@ */ import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import { eventHasNotes, getEventType, @@ -22,45 +24,9 @@ import * as i18n from '../translations'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; import { AddToCaseAction } from '../../../../../cases/components/timeline_actions/add_to_case_action'; -import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline'; -import { timelineSelectors } from '../../../../store/timeline'; +import { TimelineId, ActionProps, OnPinEvent } from '../../../../../../common/types/timeline'; +import { timelineActions, timelineSelectors } from '../../../../store/timeline'; import { timelineDefaults } from '../../../../store/timeline/defaults'; -import { Ecs } from '../../../../../../common/ecs'; -import { inputsModel } from '../../../../../common/store'; -import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; -import { RowCellRender } from '../control_columns'; - -interface Props { - ariaRowindex: number; - action?: RowCellRender; - width?: number; - columnId: string; - columnValues: string; - checked: boolean; - onRowSelected: OnRowSelected; - eventId: string; - loadingEventIds: Readonly; - onEventDetailsPanelOpened: () => void; - showCheckboxes: boolean; - data: TimelineNonEcsData[]; - ecsData: Ecs; - index: number; - eventIdToNoteIds: Readonly>; - isEventPinned: boolean; - isEventViewer?: boolean; - onPinEvent: OnPinEvent; - onUnPinEvent: OnUnPinEvent; - refetch: inputsModel.Refetch; - rowIndex: number; - onRuleChange?: () => void; - showNotes: boolean; - tabType?: TimelineTabs; - timelineId: string; - toggleShowNotes: () => void; -} - -export type ActionProps = Props; const ActionsComponent: React.FC = ({ ariaRowindex, @@ -75,9 +41,7 @@ const ActionsComponent: React.FC = ({ isEventViewer = false, loadingEventIds, onEventDetailsPanelOpened, - onPinEvent, onRowSelected, - onUnPinEvent, refetch, onRuleChange, showCheckboxes, @@ -85,9 +49,20 @@ const ActionsComponent: React.FC = ({ timelineId, toggleShowNotes, }) => { + const dispatch = useDispatch(); const emptyNotes: string[] = []; const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const onPinEvent: OnPinEvent = useCallback( + (evtId) => dispatch(timelineActions.pinEvent({ id: timelineId, eventId: evtId })), + [dispatch, timelineId] + ); + + const onUnPinEvent: OnPinEvent = useCallback( + (evtId) => dispatch(timelineActions.unPinEvent({ id: timelineId, eventId: evtId })), + [dispatch, timelineId] + ); + const handleSelectEvent = useCallback( (event: React.ChangeEvent) => onRowSelected({ @@ -99,7 +74,7 @@ const ActionsComponent: React.FC = ({ const handlePinClicked = useCallback( () => getPinOnClick({ - allowUnpinning: !eventHasNotes(eventIdToNoteIds[eventId]), + allowUnpinning: eventIdToNoteIds ? !eventHasNotes(eventIdToNoteIds[eventId]) : true, eventId, onPinEvent, onUnPinEvent, @@ -164,12 +139,12 @@ const ActionsComponent: React.FC = ({ /> )} - {!isEventViewer && ( + {!isEventViewer && toggleShowNotes && ( <> @@ -177,7 +152,7 @@ const ActionsComponent: React.FC = ({ ariaLabel={i18n.PIN_EVENT_FOR_ROW({ ariaRowindex, columnValues, isEventPinned })} key="pin-event" onPinClicked={handlePinClicked} - noteIds={eventIdToNoteIds[eventId] || emptyNotes} + noteIds={eventIdToNoteIds ? eventIdToNoteIds[eventId] || emptyNotes : emptyNotes} eventIsPinned={isEventPinned} timelineType={timelineType} /> @@ -200,7 +175,7 @@ const ActionsComponent: React.FC = ({ ecsRowData={ecsData} timelineId={timelineId} disabled={eventType !== 'signal' && !isEventContextMenuEnabled} - refetch={refetch} + refetch={refetch ?? noop} onRuleChange={onRuleChange} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx index f9eda55c237aea..8795255dfcfd4d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx @@ -8,7 +8,7 @@ import { EuiButtonIcon } from '@elastic/eui'; import React, { useCallback } from 'react'; -import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions } from '../../../../../../../common'; import { OnColumnRemoved } from '../../../events'; import { EventsHeadingExtra, EventsLoading } from '../../../styles'; import { Sort } from '../../sort'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx index 3ab4d564391f31..74593e40ddf4cd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx @@ -12,16 +12,12 @@ import { Resizable, ResizeCallback } from 're-resizable'; import deepEqual from 'fast-deep-equal'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-grid'; -import { useDraggableKeyboardWrapper } from '../../../../../common/components/drag_and_drop/draggable_keyboard_wrapper_hook'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../constants'; -import { - DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, - getDraggableFieldId, -} from '../../../../../common/components/drag_and_drop/helpers'; -import { TimelineTabs } from '../../../../../../common/types/timeline'; +import { getDraggableFieldId } from '../../../../../common/components/drag_and_drop/helpers'; +import { ColumnHeaderOptions, TimelineTabs } from '../../../../../../common/types/timeline'; import { Direction } from '../../../../../../common/search_strategy'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { OnFilterChange } from '../../events'; import { ARIA_COLUMN_INDEX_OFFSET } from '../../helpers'; import { EventsTh, EventsThContent, EventsHeadingHandle } from '../../styles'; @@ -31,6 +27,7 @@ import { Header } from './header'; import { timelineActions } from '../../../../store/timeline'; import * as i18n from './translations'; +import { useKibana } from '../../../../../common/lib/kibana'; const ContextMenu = styled(EuiContextMenu)` width: 115px; @@ -75,6 +72,7 @@ const ColumnHeaderComponent: React.FC = ({ const restoreFocus = useCallback(() => keyboardHandlerRef.current?.focus(), []); const dispatch = useDispatch(); + const { timelines } = useKibana().services; const resizableSize = useMemo( () => ({ width: header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH, @@ -247,7 +245,7 @@ const ColumnHeaderComponent: React.FC = ({ setHoverActionsOwnFocus(true); }, []); - const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({ + const { onBlur, onKeyDown } = timelines.getUseDraggableKeyboardWrapper()({ closePopover: handleClosePopOverTrigger, draggableId, fieldName: header.id, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts index fea65d0499a13c..7eb98b74759523 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { ColumnHeaderOptions, ColumnHeaderType } from '../../../../store/timeline/model'; +import { ColumnHeaderOptions } from '../../../../../../common'; +import { ColumnHeaderType } from '../../../../store/timeline/model'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; export const defaultColumnHeaderType: ColumnHeaderType = 'not-filtered'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx index bdf4cc42fa794b..828b8d8701188b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx @@ -8,9 +8,9 @@ import { noop } from 'lodash/fp'; import React from 'react'; +import { ColumnHeaderOptions } from '../../../../../../../common'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../constants'; import { OnFilterChange } from '../../../events'; -import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; import { TextFilter } from '../text_filter'; interface Props { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx index 484cb78417c2f2..ffab38b64bef86 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx @@ -8,8 +8,8 @@ import { EuiToolTip } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React from 'react'; +import { ColumnHeaderOptions } from '../../../../../../../common/types/timeline'; -import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; import { TruncatableText } from '../../../../../../common/components/truncatable_text'; import { EventsHeading, EventsHeadingTitleButton, EventsHeadingTitleSpan } from '../../../styles'; import { Sort } from '../../sort'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts index b52fa292413df4..257b88944c14e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts @@ -6,9 +6,8 @@ */ import { Direction } from '../../../../../../../common/search_strategy'; -import { assertUnreachable } from '../../../../../../../common/utility_types'; -import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; -import { Sort, SortDirection } from '../../sort'; +import { ColumnHeaderOptions, SortDirection } from '../../../../../../../common/types/timeline'; +import { Sort } from '../../sort'; interface GetNewSortDirectionOnClickParams { clickedHeader: ColumnHeaderOptions; @@ -35,7 +34,7 @@ export const getNextSortDirection = (currentSort: Sort): Direction => { case 'none': return Direction.desc; default: - return assertUnreachable(currentSort.sortDirection, 'Unhandled sort direction'); + return Direction.desc; } }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx index f2496484c25eae..4fa72fa5da4240 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx @@ -18,6 +18,7 @@ import { defaultHeaders } from '../default_headers'; import { HeaderComponent } from '.'; import { getNewSortDirectionOnClick, getNextSortDirection, getSortDirection } from './helpers'; import { Direction } from '../../../../../../../common/search_strategy'; +import { useDeepEqualSelector } from '../../../../../../common/hooks/use_selector'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => { @@ -30,6 +31,11 @@ jest.mock('react-redux', () => { }; }); +jest.mock('../../../../../../common/hooks/use_selector', () => ({ + useShallowEqualSelector: jest.fn(), + useDeepEqualSelector: jest.fn(), +})); + const filteredColumnHeader: ColumnHeaderType = 'text-filter'; describe('Header', () => { @@ -41,7 +47,11 @@ describe('Header', () => { sortDirection: Direction.desc, }, ]; - const timelineId = 'fakeId'; + const timelineId = 'test'; + + beforeEach(() => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: false }); + }); test('renders correctly against snapshot', () => { const wrapper = shallow( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx index ece28faedb9511..60a241a340d99d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx @@ -9,16 +9,18 @@ import { noop } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { useShallowEqualSelector } from '../../../../../../common/hooks/use_selector'; -import { timelineActions } from '../../../../../store/timeline'; -import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions } from '../../../../../../../common'; +import { + useDeepEqualSelector, + useShallowEqualSelector, +} from '../../../../../../common/hooks/use_selector'; +import { timelineActions, timelineSelectors } from '../../../../../store/timeline'; import { OnFilterChange } from '../../../events'; import { Sort } from '../../sort'; import { Actions } from '../actions'; import { Filter } from '../filter'; import { getNewSortDirectionOnClick } from './helpers'; import { HeaderContent } from './header_content'; -import { useManageTimeline } from '../../../../manage_timeline'; import { isEqlOnSelector } from './selectors'; interface Props { @@ -80,12 +82,10 @@ export const HeaderComponent: React.FC = ({ [dispatch, timelineId] ); - const { getManageTimelineById } = useManageTimeline(); - - const isLoading = useMemo(() => getManageTimelineById(timelineId).isLoading, [ - getManageTimelineById, - timelineId, - ]); + const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); + const { isLoading } = useDeepEqualSelector( + (state) => getManageTimeline(state, timelineId) || { isLoading: false } + ); const showSortingCapability = !isEqlOn && !(header.subType && header.subType.nested); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.test.tsx index 5b5a8b10591d45..b33e47dd27b965 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.test.tsx @@ -9,9 +9,8 @@ import { mount, shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; -import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions } from '../../../../../../../common'; import { defaultHeaders } from '../../../../../../common/mock'; - import { HeaderToolTipContent } from '.'; describe('HeaderToolTipContent', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.tsx index f4e7b6459bd148..0ae8dbb537fb8f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header_tooltip_content/index.tsx @@ -10,7 +10,7 @@ import { isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; -import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions } from '../../../../../../../common'; import { getIconFromType } from '../../../../../../common/components/event_details/helpers'; import * as i18n from '../translations'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts index d19c5689ab0499..c49d088d6241d3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts @@ -6,9 +6,9 @@ */ import { get } from 'lodash/fp'; +import { ColumnHeaderOptions } from '../../../../../../common'; import { BrowserFields } from '../../../../../common/containers/source'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx index 41f9db3f1c25b4..378f7fce250fe7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx @@ -24,6 +24,8 @@ import { Direction } from '../../../../../../common/search_strategy'; import { defaultControlColumn } from '../control_columns'; import { testTrailingControlColumns } from '../../../../../common/mock/mock_timeline_control_columns'; +jest.mock('../../../../../common/lib/kibana'); + const mockDispatch = jest.fn(); jest.mock('react-redux', () => { const original = jest.requireActual('react-redux'); 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 3b0b935bfcff49..25aefd513f806f 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 @@ -11,12 +11,17 @@ import { Droppable, DraggableChildrenFn } from 'react-beautiful-dnd'; import { DragEffects } from '../../../../../common/components/drag_and_drop/draggable_wrapper'; import { DraggableFieldBadge } from '../../../../../common/components/draggables/field_badge'; import { BrowserFields } from '../../../../../common/containers/source'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { DRAG_TYPE_FIELD, droppableTimelineColumnsPrefix, } from '../../../../../common/components/drag_and_drop/helpers'; -import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline'; +import { + ColumnHeaderOptions, + ControlColumnProps, + HeaderActionProps, + TimelineId, + TimelineTabs, +} from '../../../../../../common/types/timeline'; import { OnSelectAll } from '../../events'; import { EventsTh, @@ -27,8 +32,6 @@ import { } from '../../styles'; import { Sort } from '../sort'; import { ColumnHeader } from './column_header'; -import { ControlColumnProps } from '../control_columns'; -import { HeaderActionProps } from '../actions/header_actions'; interface Props { actionsColumnWidth: number; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx index 8ef69697af1d0d..e4f4c26417351b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx @@ -5,48 +5,9 @@ * 2.0. */ -import { ComponentType, JSXElementConstructor } from 'react'; -import { EuiDataGridControlColumn, EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { OnRowSelected } from '../../events'; -import { ActionProps, Actions } from '../actions'; -import { HeaderActions, HeaderActionProps } from '../actions/header_actions'; - -export type GenericActionRowCellRenderProps = Pick< - EuiDataGridCellValueElementProps, - 'rowIndex' | 'columnId' ->; - -export type HeaderCellRender = ComponentType | ComponentType; -export type RowCellRender = - | JSXElementConstructor - | ((props: GenericActionRowCellRenderProps) => JSX.Element) - | JSXElementConstructor - | ((props: ActionProps) => JSX.Element); - -interface AdditionalControlColumnProps { - ariaRowindex: number; - actionsColumnWidth: number; - columnValues: string; - checked: boolean; - onRowSelected: OnRowSelected; - eventId: string; - id: string; - columnId: string; - loadingEventIds: Readonly; - onEventDetailsPanelOpened: () => void; - showCheckboxes: boolean; - // Override these type definitions to support either a generic custom component or the one used in security_solution today. - headerCellRender: HeaderCellRender; - rowCellRender: RowCellRender; - // If not provided, calculated dynamically - width?: number; -} - -export type ControlColumnProps = Omit< - EuiDataGridControlColumn, - keyof AdditionalControlColumnProps -> & - Partial; +import { ControlColumnProps } from '../../../../../../common/types/timeline'; +import { Actions } from '../actions'; +import { HeaderActions } from '../actions/header_actions'; export const defaultControlColumn: ControlColumnProps = { id: 'default-timeline-control-column', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx index ae6307c0a294be..ecacbc51e395a9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx @@ -36,11 +36,9 @@ describe('Columns', () => { timelineId="test" columnValues={'abc def'} showCheckboxes={false} - onPinEvent={jest.fn()} selectedEventIds={{}} loadingEventIds={[]} onEventDetailsPanelOpened={jest.fn()} - onUnPinEvent={jest.fn()} onRowSelected={jest.fn()} showNotes={false} isEventPinned={false} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx index ecabc3eae51c4a..11bf88977fe61c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx @@ -8,17 +8,20 @@ import { EuiScreenReaderOnly } from '@elastic/eui'; import React, { useMemo } from 'react'; import { getOr } from 'lodash/fp'; +import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-grid'; -import { CellValueElementProps } from '../../cell_rendering'; -import { ControlColumnProps, RowCellRender } from '../control_columns'; -import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '../../../../../common/components/drag_and_drop/helpers'; import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { TimelineTabs } from '../../../../../../common/types/timeline'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; +import { + ColumnHeaderOptions, + CellValueElementProps, + ActionProps, + ControlColumnProps, + TimelineTabs, + RowCellRender, +} from '../../../../../../common/types/timeline'; import { ARIA_COLUMN_INDEX_OFFSET } from '../../helpers'; -import { OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; -import { ActionProps } from '../actions'; +import { OnRowSelected } from '../../events'; import { inputsModel } from '../../../../../common/store'; import { EventsTd, @@ -60,9 +63,7 @@ interface DataDrivenColumnProps { loadingEventIds: Readonly; notesCount: number; onEventDetailsPanelOpened: () => void; - onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; - onUnPinEvent: OnUnPinEvent; refetch: inputsModel.Refetch; onRuleChange?: () => void; hasRowRenderers: boolean; @@ -137,9 +138,7 @@ const TgridActionTdCell = ({ loadingEventIds, notesCount, onEventDetailsPanelOpened, - onPinEvent, onRowSelected, - onUnPinEvent, refetch, rowIndex, hasRowRenderers, @@ -193,9 +192,7 @@ const TgridActionTdCell = ({ isEventViewer={isEventViewer} loadingEventIds={loadingEventIds} onEventDetailsPanelOpened={onEventDetailsPanelOpened} - onPinEvent={onPinEvent} onRowSelected={onRowSelected} - onUnPinEvent={onUnPinEvent} refetch={refetch} rowIndex={rowIndex} onRuleChange={onRuleChange} @@ -292,9 +289,7 @@ export const DataDrivenColumns = React.memo( loadingEventIds, notesCount, onEventDetailsPanelOpened, - onPinEvent, onRowSelected, - onUnPinEvent, refetch, hasRowRenderers, onRuleChange, @@ -345,8 +340,6 @@ export const DataDrivenColumns = React.memo( isEventPinned={isEventPinned} isEventViewer={isEventViewer} notesCount={notesCount} - onPinEvent={onPinEvent} - onUnPinEvent={onUnPinEvent} refetch={refetch} hasRowRenderers={hasRowRenderers} onRuleChange={onRuleChange} @@ -365,7 +358,6 @@ export const DataDrivenColumns = React.memo( data, ecsData, onRowSelected, - onPinEvent, isEventPinned, isEventViewer, actionsColumnWidth, @@ -378,7 +370,6 @@ export const DataDrivenColumns = React.memo( notesCount, onEventDetailsPanelOpened, onRuleChange, - onUnPinEvent, refetch, selectedEventIds, showCheckboxes, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx index 3c75bc7fb2649c..3e22cba208ca2d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx @@ -9,11 +9,13 @@ import { mount } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React, { useEffect } from 'react'; -import { CellValueElementProps } from '../../cell_rendering'; import { defaultHeaders, mockTimelineData } from '../../../../../common/mock'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { TimelineTabs } from '../../../../../../common/types/timeline'; -import { ColumnHeaderOptions } from '../../../../store/timeline/model'; +import { + ColumnHeaderOptions, + CellValueElementProps, + TimelineTabs, +} from '../../../../../../common/types/timeline'; import { StatefulCell } from './stateful_cell'; import { getMappedNonEcsValue } from '.'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx index a5f8336cc7997e..7931e0739aa68a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx @@ -7,10 +7,12 @@ import React, { HTMLAttributes, useState } from 'react'; -import { CellValueElementProps } from '../../cell_rendering'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { TimelineTabs } from '../../../../../../common/types/timeline'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; +import { + ColumnHeaderOptions, + CellValueElementProps, + TimelineTabs, +} from '../../../../../../common/types/timeline'; export interface CommonProps { className?: string; 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 index e56171aae003cc..17f231c0fdad9b 100644 --- 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 @@ -60,9 +60,7 @@ describe('EventColumnView', () => { loadingEventIds: [], notesCount: 0, onEventDetailsPanelOpened: jest.fn(), - onPinEvent: jest.fn(), onRowSelected: jest.fn(), - onUnPinEvent: jest.fn(), refetch: jest.fn(), renderCellValue: DefaultCellRenderer, selectedEventIds: {}, @@ -120,16 +118,6 @@ describe('EventColumnView', () => { 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(); - }); - test('it render AddToCaseAction if timelineId === TimelineId.detectionsPage', () => { const wrapper = mount(, { wrappingComponent: TestProviders, 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 5dc718f90a91a6..298ce252ba925f 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 @@ -7,16 +7,19 @@ import React, { useMemo } from 'react'; -import { CellValueElementProps } from '../../cell_rendering'; -import { ControlColumnProps, RowCellRender } from '../control_columns'; import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; +import { OnRowSelected } from '../../events'; import { EventsTrData, EventsTdGroupActions } from '../../styles'; import { DataDrivenColumns, getMappedNonEcsValue } from '../data_driven_columns'; import { inputsModel } from '../../../../../common/store'; -import { TimelineTabs } from '../../../../../../common/types/timeline'; +import { + ColumnHeaderOptions, + CellValueElementProps, + ControlColumnProps, + RowCellRender, + TimelineTabs, +} from '../../../../../../common/types/timeline'; interface Props { id: string; @@ -31,9 +34,7 @@ interface Props { loadingEventIds: Readonly; notesCount: number; onEventDetailsPanelOpened: () => void; - onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; - onUnPinEvent: OnUnPinEvent; refetch: inputsModel.Refetch; renderCellValue: (props: CellValueElementProps) => React.ReactNode; onRuleChange?: () => void; @@ -62,9 +63,7 @@ export const EventColumnView = React.memo( loadingEventIds, notesCount, onEventDetailsPanelOpened, - onPinEvent, onRowSelected, - onUnPinEvent, refetch, hasRowRenderers, onRuleChange, @@ -134,10 +133,8 @@ export const EventColumnView = React.memo( eventIdToNoteIds={eventIdToNoteIds} isEventPinned={isEventPinned} isEventViewer={isEventViewer} - onPinEvent={onPinEvent} - onUnPinEvent={onUnPinEvent} - refetch={refetch} onRuleChange={onRuleChange} + refetch={refetch} showNotes={showNotes} tabType={tabType} timelineId={timelineId} @@ -161,10 +158,8 @@ export const EventColumnView = React.memo( leadingControlColumns, loadingEventIds, onEventDetailsPanelOpened, - onPinEvent, onRowSelected, onRuleChange, - onUnPinEvent, refetch, selectedEventIds, showCheckboxes, @@ -201,8 +196,6 @@ export const EventColumnView = React.memo( eventIdToNoteIds={eventIdToNoteIds} isEventPinned={isEventPinned} isEventViewer={isEventViewer} - onPinEvent={onPinEvent} - onUnPinEvent={onUnPinEvent} refetch={refetch} onRuleChange={onRuleChange} selectedEventIds={selectedEventIds} 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 c3097ad68aba11..c09de87c87f327 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 @@ -8,19 +8,21 @@ import React from 'react'; import { isEmpty } from 'lodash'; -import { CellValueElementProps } from '../../cell_rendering'; -import { ControlColumnProps } from '../control_columns'; import { inputsModel } from '../../../../../common/store'; import { BrowserFields } from '../../../../../common/containers/source'; import { TimelineItem, TimelineNonEcsData, } from '../../../../../../common/search_strategy/timeline'; -import { TimelineTabs } from '../../../../../../common/types/timeline'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; +import { + ColumnHeaderOptions, + CellValueElementProps, + ControlColumnProps, + RowRenderer, + TimelineTabs, +} from '../../../../../../common/types/timeline'; import { OnRowSelected } from '../../events'; import { EventsTbody } from '../../styles'; -import { RowRenderer } from '../renderers/row_renderer'; import { StatefulEvent } from './stateful_event'; import { eventIsPinned } from '../helpers'; 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 701dc549467e91..b8840a75cc9b4e 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 @@ -8,10 +8,12 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { CellValueElementProps } from '../../cell_rendering'; -import { ControlColumnProps } from '../control_columns'; import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { + ColumnHeaderOptions, + CellValueElementProps, + ControlColumnProps, + RowRenderer, TimelineExpandedDetailType, TimelineId, TimelineTabs, @@ -21,11 +23,9 @@ import { TimelineItem, TimelineNonEcsData, } from '../../../../../../common/search_strategy/timeline'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { OnPinEvent, OnRowSelected } from '../../events'; +import { OnRowSelected } from '../../events'; import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers'; import { EventsTrGroup, EventsTrSupplement, EventsTrSupplementContainer } from '../../styles'; -import { RowRenderer } from '../renderers/row_renderer'; import { isEventBuildingBlockType, getEventType, isEvenEqlSequence } from '../helpers'; import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; @@ -176,16 +176,6 @@ const StatefulEventComponent: React.FC = ({ }); }, [event]); - const onPinEvent: OnPinEvent = useCallback( - (eventId) => dispatch(timelineActions.pinEvent({ id: timelineId, eventId })), - [dispatch, timelineId] - ); - - const onUnPinEvent: OnPinEvent = useCallback( - (eventId) => dispatch(timelineActions.unPinEvent({ id: timelineId, eventId })), - [dispatch, timelineId] - ); - const handleOnEventDetailPanelOpened = useCallback(() => { const eventId = event._id; const indexName = event._index!; @@ -215,10 +205,10 @@ const StatefulEventComponent: React.FC = ({ (noteId: string) => { dispatch(timelineActions.addNoteToEvent({ eventId: event._id, id: timelineId, noteId })); if (!isEventPinned) { - onPinEvent(event._id); // pin the event, because it has notes + dispatch(timelineActions.pinEvent({ id: timelineId, eventId: event._id })); } }, - [dispatch, event, isEventPinned, onPinEvent, timelineId] + [dispatch, event, isEventPinned, timelineId] ); const RowRendererContent = useMemo( @@ -273,9 +263,7 @@ const StatefulEventComponent: React.FC = ({ loadingEventIds={loadingEventIds} notesCount={notes.length} onEventDetailsPanelOpened={handleOnEventDetailPanelOpened} - onPinEvent={onPinEvent} onRowSelected={onRowSelected} - onUnPinEvent={onUnPinEvent} refetch={refetch} renderCellValue={renderCellValue} onRuleChange={onRuleChange} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/index.tsx index 10a25538c1ba39..19abd6841e7e89 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/index.tsx @@ -9,15 +9,15 @@ import { noop } from 'lodash/fp'; import { EuiFocusTrap, EuiOutsideClickDetector, EuiScreenReaderOnly } from '@elastic/eui'; import React, { useMemo } from 'react'; -import { BrowserFields } from '../../../../../../common/containers/source'; import { ARIA_COLINDEX_ATTRIBUTE, ARIA_ROWINDEX_ATTRIBUTE, getRowRendererClassName, -} from '../../../../../../common/components/accessibility/helpers'; +} from '../../../../../../../../timelines/public'; +import { RowRenderer } from '../../../../../../../common'; +import { BrowserFields } from '../../../../../../common/containers/source'; import { TimelineItem } from '../../../../../../../common/search_strategy/timeline'; import { getRowRenderer } from '../../renderers/get_row_renderer'; -import { RowRenderer } from '../../renderers/row_renderer'; import { useStatefulEventFocus } from '../use_stateful_event_focus'; import * as i18n from '../translations'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/use_stateful_event_focus/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/use_stateful_event_focus/index.tsx index 5f3c4dac8b73d7..4e8fd7dc48968f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/use_stateful_event_focus/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/use_stateful_event_focus/index.tsx @@ -13,7 +13,7 @@ import { isEscape, focusColumn, OnColumnFocused, -} from '../../../../../../common/components/accessibility/helpers'; +} from '../../../../../../../../timelines/public'; type FocusOwnership = 'not-owned' | 'owned'; 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 61601c3921445e..19059b5fb45994 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 @@ -23,6 +23,8 @@ import { timelineActions } from '../../../store/timeline'; import { TimelineTabs } from '../../../../../common/types/timeline'; import { defaultRowRenderers } from './renderers'; +jest.mock('../../../../common/lib/kibana'); + const mockSort: Sort[] = [ { columnId: '@timestamp', @@ -255,7 +257,7 @@ describe('Body', () => { tabType: 'query', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', + type: 'x-pack/timelines/t-grid/TOGGLE_DETAIL_PANEL', }); }); @@ -279,7 +281,7 @@ describe('Body', () => { tabType: 'pinned', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', + type: 'x-pack/timelines/t-grid/TOGGLE_DETAIL_PANEL', }); }); @@ -303,7 +305,7 @@ describe('Body', () => { tabType: 'notes', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', + type: 'x-pack/timelines/t-grid/TOGGLE_DETAIL_PANEL', }); }); }); 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 64f61232377e81..fc8bf2086471c7 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 @@ -11,21 +11,26 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; -import { CellValueElementProps } from '../cell_rendering'; -import { DEFAULT_COLUMN_MIN_WIDTH } from './constants'; -import { ControlColumnProps } from './control_columns'; -import { RowRendererId, TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; import { FIRST_ARIA_INDEX, ARIA_COLINDEX_ATTRIBUTE, ARIA_ROWINDEX_ATTRIBUTE, onKeyDownFocusHandler, -} from '../../../../common/components/accessibility/helpers'; +} from '../../../../../../timelines/public'; +import { CellValueElementProps } from '../cell_rendering'; +import { DEFAULT_COLUMN_MIN_WIDTH } from './constants'; +import { + ColumnHeaderOptions, + ControlColumnProps, + RowRendererId, + RowRenderer, + TimelineId, + TimelineTabs, +} from '../../../../../common/types/timeline'; import { BrowserFields } from '../../../../common/containers/source'; import { TimelineItem } from '../../../../../common/search_strategy/timeline'; import { inputsModel, State } from '../../../../common/store'; -import { useManageTimeline } from '../../manage_timeline'; -import { ColumnHeaderOptions, TimelineModel } from '../../../store/timeline/model'; +import { TimelineModel } from '../../../store/timeline/model'; import { timelineDefaults } from '../../../store/timeline/defaults'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { OnRowSelected, OnSelectAll } from '../events'; @@ -33,11 +38,11 @@ import { getActionsColumnWidth, getColumnHeaders } from './column_headers/helper import { getEventIdToDataMapping } from './helpers'; import { Sort } from './sort'; import { plainRowRenderer } from './renderers/plain_row_renderer'; -import { RowRenderer } from './renderers/row_renderer'; import { EventsTable, TimelineBody, TimelineBodyGlobalStyle } from '../styles'; import { ColumnHeaders } from './column_headers'; import { Events } from './events'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; interface OwnProps { activePage: number; @@ -99,11 +104,10 @@ export const BodyComponent = React.memo( trailingControlColumns = [], }) => { const containerRef = useRef(null); - const { getManageTimelineById } = useManageTimeline(); - const { queryFields, selectAll } = useMemo(() => getManageTimelineById(id), [ - getManageTimelineById, - id, - ]); + const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); + const { queryFields, selectAll } = useDeepEqualSelector((state) => + getManageTimeline(state, id) + ); const onRowSelected: OnRowSelected = useCallback( ({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/args.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/args.test.tsx index 21c44cb26e2e50..d5ec8b6f948627 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/args.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/args.test.tsx @@ -13,6 +13,8 @@ import { useMountAppended } from '../../../../../common/utils/use_mount_appended import { TestProviders } from '../../../../../common/mock'; import { ArgsComponent } from './args'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.test.tsx index f45c049ca137af..2a5764e53756a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.test.tsx @@ -15,6 +15,8 @@ import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; import { AuditdGenericDetails, AuditdGenericLine } from './generic_details'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_file_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_file_details.test.tsx index 51676c067cd797..009ffecf28f741 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_file_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_file_details.test.tsx @@ -15,6 +15,8 @@ import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; import { AuditdGenericFileDetails, AuditdGenericFileLine } from './generic_file_details'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_row_renderer.test.tsx index 31fea6fa25e651..74a5ff472b5815 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_row_renderer.test.tsx @@ -9,17 +9,19 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; +import { RowRenderer } from '../../../../../../../common'; import { BrowserFields } from '../../../../../../common/containers/source'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../../../common/ecs'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; -import { RowRenderer } from '../row_renderer'; import { createGenericAuditRowRenderer, createGenericFileRowRenderer, } from './generic_row_renderer'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_row_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_row_renderer.tsx index 9133e500162bc2..765bfd3d21351b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_row_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_row_renderer.tsx @@ -11,9 +11,9 @@ import { IconType } from '@elastic/eui'; import { get } from 'lodash/fp'; import React from 'react'; -import { RowRendererId } from '../../../../../../../common/types/timeline'; +import { RowRendererId, RowRenderer } from '../../../../../../../common/types/timeline'; -import { RowRenderer, RowRendererContainer } from '../row_renderer'; +import { RowRendererContainer } from '../row_renderer'; import { AuditdGenericDetails } from './generic_details'; import { AuditdGenericFileDetails } from './generic_file_details'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.test.tsx index 24b9f8d40eb17e..d6037a310dc7ed 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.test.tsx @@ -13,6 +13,8 @@ import { TestProviders } from '../../../../../../common/mock'; import { PrimarySecondaryUserInfo, nilOrUnSet } from './primary_secondary_user_info'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.test.tsx index 22cd8446a51c08..fa6eda6bce37df 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.test.tsx @@ -14,6 +14,8 @@ import { TestProviders } from '../../../../../../common/mock'; import { SessionUserHostWorkingDir } from './session_user_host_working_dir'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx index 8b4a9f72b1a453..c7da6f758766e0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx @@ -14,6 +14,8 @@ import { useMountAppended } from '../../../../../../common/utils/use_mount_appen import { Bytes } from '.'; +jest.mock('../../../../../../common/lib/kibana'); + describe('Bytes', () => { const mount = useMountAppended(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts index cb670b53a9679e..65bb67458ab2a8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts @@ -6,8 +6,8 @@ */ import type React from 'react'; +import { ColumnHeaderOptions } from '../../../../../../common'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; export interface ColumnRenderer { isInstance: (columnName: string, data: TimelineNonEcsData[]) => boolean; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_row.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_row.test.tsx index 7f580642130fe4..872ca017d7f7d8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_row.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_row.test.tsx @@ -12,6 +12,8 @@ import { TestProviders } from '../../../../../../common/mock'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { ThreatMatchRowProps, ThreatMatchRowView } from './threat_match_row'; +jest.mock('../../../../../../common/lib/kibana'); + describe('ThreatMatchRowView', () => { const mount = useMountAppended(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_row_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_row_renderer.tsx index 2a7e8ce02d79f6..16426bf74aba7c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_row_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_row_renderer.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import { RowRendererId } from '../../../../../../../common/types/timeline'; -import { RowRenderer } from '../row_renderer'; +import { RowRendererId, RowRenderer } from '../../../../../../../common/types/timeline'; import { hasThreatMatchValue } from './helpers'; import { ThreatMatchRows } from './threat_match_rows'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_rows.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_rows.tsx index cc34f9e63b5e2d..f6feb6dd1b126a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_rows.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/cti/threat_match_rows.tsx @@ -10,9 +10,10 @@ import { get } from 'lodash'; import React, { Fragment } from 'react'; import styled from 'styled-components'; +import { RowRenderer } from '../../../../../../../common'; import { Fields } from '../../../../../../../common/search_strategy'; import { ID_FIELD_NAME } from '../../../../../../common/components/event_details/event_id'; -import { RowRenderer, RowRendererContainer } from '../row_renderer'; +import { RowRendererContainer } from '../row_renderer'; import { ThreatMatchRow } from './threat_match_row'; const SpacedContainer = styled.div` diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details.test.tsx index d3e870aa92ef09..9e6c5b819a20ba 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details.test.tsx @@ -15,6 +15,8 @@ import { useMountAppended } from '../../../../../../common/utils/use_mount_appen import { DnsRequestEventDetails } from './dns_request_event_details'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.test.tsx index 2809b06c774697..5c0aecf5fbbc7a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.test.tsx @@ -12,6 +12,8 @@ import '../../../../../../common/mock/match_media'; import { DnsRequestEventDetailsLine } from './dns_request_event_details_line'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.test.tsx index 034ade75ef2c03..5144705f261745 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.test.tsx @@ -18,6 +18,8 @@ import { getEmptyValue } from '../../../../../common/components/empty_value'; import { deleteItemIdx, findItem } from './helpers'; import { emptyColumnRenderer } from './empty_column_renderer'; +jest.mock('../../../../../common/lib/kibana'); + describe('empty_column_renderer', () => { let mockDatum: TimelineNonEcsData[]; const _id = mockTimelineData[0]._id; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.tsx index 400ccf47201ac3..37873df7f4e7be 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.tsx @@ -8,9 +8,8 @@ /* eslint-disable react/display-name */ import React from 'react'; - +import { ColumnHeaderOptions } from '../../../../../../common'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { DraggableWrapper, DragEffects, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.test.tsx index c1df6d6eb48c8a..613d66505601ae 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.test.tsx @@ -20,6 +20,8 @@ import { useMountAppended } from '../../../../../../common/utils/use_mount_appen import { EndgameSecurityEventDetails } from './endgame_security_event_details'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx index 5d088987898215..879862d06b2504 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx @@ -13,6 +13,8 @@ import '../../../../../../common/mock/match_media'; import { EndgameSecurityEventDetailsLine } from './endgame_security_event_details_line'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx index a6f15a9f79f4e1..1bf8d1a4a4f51d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx @@ -13,6 +13,8 @@ import { useMountAppended } from '../../../../../common/utils/use_mount_appended import { ExitCodeDraggable } from './exit_code_draggable'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.test.tsx index d7274f0774fc52..cf3fce2c25c0be 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.test.tsx @@ -13,6 +13,8 @@ import { TestProviders } from '../../../../../common/mock'; import { FileDraggable } from './file_draggable'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_hash.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_hash.test.tsx index e7e6274942beab..8ebd3ae8a67c21 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_hash.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_hash.test.tsx @@ -13,6 +13,8 @@ import { useMountAppended } from '../../../../../common/utils/use_mount_appended import { FileHash } from './file_hash'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx index 8e54f13ec9cbf3..852331aa021ddc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx @@ -21,6 +21,8 @@ import { getColumnRenderer } from './get_column_renderer'; import { getValues, findItem, deleteItemIdx } from './helpers'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx index 56dbc99d47c66a..104550f138f16e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx @@ -20,6 +20,8 @@ import { useMountAppended } from '../../../../../common/utils/use_mount_appended import { defaultRowRenderers } from '.'; import { getRowRenderer } from './get_row_renderer'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.ts index bfe60a14e042de..2d1be6ee7914ad 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.ts @@ -5,8 +5,8 @@ * 2.0. */ +import { RowRenderer } from '../../../../../../common'; import { Ecs } from '../../../../../../common/ecs'; -import { RowRenderer } from './row_renderer'; export const getRowRenderer = (ecs: Ecs, rowRenderers: RowRenderer[]): RowRenderer | null => rowRenderers.find((rowRenderer) => rowRenderer.isInstance(ecs)) ?? null; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_working_dir.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_working_dir.test.tsx index 9412ecfd364ba5..d650710b25cad0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_working_dir.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_working_dir.test.tsx @@ -13,6 +13,8 @@ import { mockTimelineData, TestProviders } from '../../../../../common/mock'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; import { HostWorkingDir } from './host_working_dir'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts index 537a24bbfd9539..911dcc8cd2e875 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts @@ -5,12 +5,12 @@ * 2.0. */ +import { RowRenderer } from '../../../../../../common'; import { auditdRowRenderers } from './auditd/generic_row_renderer'; import { ColumnRenderer } from './column_renderer'; import { emptyColumnRenderer } from './empty_column_renderer'; import { netflowRowRenderer } from './netflow/netflow_row_renderer'; import { plainColumnRenderer } from './plain_column_renderer'; -import { RowRenderer } from './row_renderer'; import { suricataRowRenderer } from './suricata/suricata_row_renderer'; import { unknownColumnRenderer } from './unknown_column_renderer'; import { zeekRowRenderer } from './zeek/zeek_row_renderer'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.test.tsx index 72e3516827c8a5..fc97624dbfc961 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.test.tsx @@ -26,6 +26,8 @@ export const justIdAndTimestamp: Ecs = { timestamp: '2018-11-12T19:03:25.936Z', }; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('../../../../../../common/components/link_to'); describe('netflowRowRenderer', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.tsx index 2605670ee8b384..35406dce6ff72b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.tsx @@ -11,7 +11,7 @@ import { get } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; -import { RowRendererId } from '../../../../../../../common/types/timeline'; +import { RowRendererId, RowRenderer } from '../../../../../../../common/types/timeline'; import { asArrayIfExists } from '../../../../../../common/lib/helpers'; import { TLS_CLIENT_CERTIFICATE_FINGERPRINT_SHA1_FIELD_NAME, @@ -63,7 +63,7 @@ import { SOURCE_BYTES_FIELD_NAME, SOURCE_PACKETS_FIELD_NAME, } from '../../../../../../network/components/source_destination/source_destination_arrows'; -import { RowRenderer, RowRendererContainer } from '../row_renderer'; +import { RowRendererContainer } from '../row_renderer'; const Details = styled.div` margin: 5px 0; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx index 2402be88dea188..7c28747cc84ef2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx @@ -13,6 +13,8 @@ import { TestProviders } from '../../../../../common/mock'; import { ParentProcessDraggable } from './parent_process_draggable'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.test.tsx index a56acbe48685c1..e970aaad026b17 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.test.tsx @@ -18,6 +18,8 @@ import { useMountAppended } from '../../../../../common/utils/use_mount_appended import { plainColumnRenderer } from './plain_column_renderer'; import { getValues, deleteItemIdx, findItem } from './helpers'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx index a2b7750d9bb59d..77039ddc4a586c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx @@ -8,8 +8,8 @@ import { head } from 'lodash/fp'; import React from 'react'; +import { ColumnHeaderOptions } from '../../../../../../common'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { getEmptyTagValue } from '../../../../../common/components/empty_value'; import { ColumnRenderer } from './column_renderer'; import { FormattedFieldValue } from './formatted_field'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_row_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_row_renderer.tsx index 0b5afd579d08c3..15620a7fc04b49 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_row_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_row_renderer.tsx @@ -7,9 +7,7 @@ import React from 'react'; -import { RowRendererId } from '../../../../../../common/types/timeline'; - -import { RowRenderer } from './row_renderer'; +import { RowRendererId, RowRenderer } from '../../../../../../common/types/timeline'; const PlainRowRenderer = () => <>; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.test.tsx index 31a1745fa2a6d6..6509808fb0c9f1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.test.tsx @@ -13,6 +13,8 @@ import '../../../../../common/mock/match_media'; import { ProcessDraggable, ProcessDraggableWithNonExistentProcess } from './process_draggable'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.test.tsx index 9e90e061e94d58..7135f2a5fed6a8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.test.tsx @@ -13,6 +13,8 @@ import { useMountAppended } from '../../../../../common/utils/use_mount_appended import { ProcessHash } from './process_hash'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details.test.tsx index f37adef7e73cb0..e5bb91c5325057 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details.test.tsx @@ -18,6 +18,8 @@ import { MODIFIED_REGISTRY_KEY } from '../system/translations'; import { RegistryEventDetails } from './registry_event_details'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details_line.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details_line.test.tsx index 6be1529152523f..d0287f2b010ae4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details_line.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/registry/registry_event_details_line.test.tsx @@ -13,6 +13,8 @@ import { useMountAppended } from '../../../../../../common/utils/use_mount_appen import { RegistryEventDetailsLine } from './registry_event_details_line'; import { MODIFIED_REGISTRY_KEY } from '../system/translations'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/row_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/row_renderer.tsx index 679da28e622bfb..9099f76b8305c4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/row_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/row_renderer.tsx @@ -7,11 +7,7 @@ import React from 'react'; -import { BrowserFields } from '../../../../../common/containers/source'; -import type { RowRendererId } from '../../../../../../common/types/timeline'; -import { Ecs } from '../../../../../../common/ecs'; import { EventsTrSupplement } from '../../styles'; - interface RowRendererContainerProps { children: React.ReactNode; } @@ -22,17 +18,3 @@ export const RowRendererContainer = React.memo(({ chi )); RowRendererContainer.displayName = 'RowRendererContainer'; - -export interface RowRenderer { - id: RowRendererId; - isInstance: (data: Ecs) => boolean; - renderRow: ({ - browserFields, - data, - timelineId, - }: { - browserFields: BrowserFields; - data: Ecs; - timelineId: string; - }) => React.ReactNode; -} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx index 5960f43174b985..355077ee500668 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx @@ -16,6 +16,8 @@ import { TestProviders } from '../../../../../../common/mock/test_providers'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { SuricataDetails } from './suricata_details'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx index 098d6775cfaa46..998233b2278c9e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx @@ -18,6 +18,8 @@ import { TestProviders } from '../../../../../../common/mock/test_providers'; import { suricataRowRenderer } from './suricata_row_renderer'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.tsx index 5a68bc6fe28c81..aa482926bf007e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.tsx @@ -10,9 +10,9 @@ import { get } from 'lodash/fp'; import React from 'react'; -import { RowRendererId } from '../../../../../../../common/types/timeline'; +import { RowRendererId, RowRenderer } from '../../../../../../../common/types/timeline'; -import { RowRenderer, RowRendererContainer } from '../row_renderer'; +import { RowRendererContainer } from '../row_renderer'; import { SuricataDetails } from './suricata_details'; export const suricataRowRenderer: RowRenderer = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_signature.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_signature.test.tsx index 4a727e4e7bc27f..b3911f9eded67e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_signature.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_signature.test.tsx @@ -18,6 +18,8 @@ import { SURICATA_SIGNATURE_ID_FIELD_NAME, } from './suricata_signature'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.test.tsx index 001b7f4b68baba..35872d0093f029 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.test.tsx @@ -15,6 +15,8 @@ import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; import { SystemGenericDetails, SystemGenericLine } from './generic_details'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx index b660d823954eee..f5dc4c6fdf5999 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx @@ -16,6 +16,8 @@ import { mockEndgameCreationEvent } from '../../../../../../common/mock/mock_end import { SystemGenericFileDetails, SystemGenericFileLine } from './generic_file_details'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx index 8e8ce9cb2f988f..6f5b225f0690bf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.test.tsx @@ -67,7 +67,6 @@ import { mockEndpointSecurityLogOffEvent, } from '../../../../../../common/mock/mock_endgame_ecs_data'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; -import { RowRenderer } from '../row_renderer'; import { createDnsRowRenderer, createEndgameProcessRowRenderer, @@ -82,6 +81,9 @@ import { EndpointAlertCriteria, } from './generic_row_renderer'; import * as i18n from './translations'; +import { RowRenderer } from '../../../../../../../common'; + +jest.mock('../../../../../../common/lib/kibana'); jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.tsx index 211fa9152dc8d1..c6845d7d672d26 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_row_renderer.tsx @@ -10,13 +10,13 @@ import { get } from 'lodash/fp'; import React from 'react'; -import { RowRendererId } from '../../../../../../../common/types/timeline'; +import { RowRendererId, RowRenderer } from '../../../../../../../common/types/timeline'; import { DnsRequestEventDetails } from '../dns/dns_request_event_details'; import { EndgameSecurityEventDetails } from '../endgame/endgame_security_event_details'; import { isFileEvent, isNillEmptyOrNotFinite } from '../helpers'; import { RegistryEventDetails } from '../registry/registry_event_details'; -import { RowRenderer, RowRendererContainer } from '../row_renderer'; +import { RowRendererContainer } from '../row_renderer'; import { SystemGenericDetails } from './generic_details'; import { SystemGenericFileDetails } from './generic_file_details'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/package.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/package.test.tsx index ac1e4d6748dcde..be11955169bd7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/package.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/package.test.tsx @@ -13,6 +13,8 @@ import { TestProviders } from '../../../../../../common/mock'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { Package } from './package'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.test.tsx index dfb9ae69ac2d4d..7cff1166cd0de1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.test.tsx @@ -13,6 +13,8 @@ import '../../../../../common/mock/match_media'; import { UserHostWorkingDir } from './user_host_working_dir'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; +jest.mock('../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx index 04150163fb4d47..7f0ec8b7b0b79c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx @@ -14,6 +14,8 @@ import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { ZeekDetails } from './zeek_details'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx index 749e450b36ae43..6b154d4d327075 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx @@ -17,6 +17,8 @@ import '../../../../../../common/mock/match_media'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { zeekRowRenderer } from './zeek_row_renderer'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.tsx index 7a8d284d0ec1e3..2b6311b8cae83a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.tsx @@ -10,9 +10,9 @@ import { get } from 'lodash/fp'; import React from 'react'; -import { RowRendererId } from '../../../../../../../common/types/timeline'; +import { RowRendererId, RowRenderer } from '../../../../../../../common/types/timeline'; -import { RowRenderer, RowRendererContainer } from '../row_renderer'; +import { RowRendererContainer } from '../row_renderer'; import { ZeekDetails } from './zeek_details'; export const zeekRowRenderer: RowRenderer = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx index 61155331b1a4bc..28034dac8f5753 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx @@ -28,6 +28,8 @@ import { defaultStringRenderer, } from './zeek_signature'; +jest.mock('../../../../../../common/lib/kibana'); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/index.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/index.ts index e7c69b9229d704..bd05bf06566872 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/index.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/index.ts @@ -5,15 +5,7 @@ * 2.0. */ -import { Direction } from '../../../../../../common/search_strategy'; -import { ColumnId } from '../column_id'; - -/** Specifies a column's sort direction */ -export type SortDirection = 'none' | Direction; +import { SortColumnTimeline } from '../../../../../../common/types/timeline'; /** Specifies which column the timeline is sorted on */ -export interface Sort { - columnId: ColumnId; - columnType: string; - sortDirection: SortDirection; -} +export type Sort = SortColumnTimeline; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx index 6af29793f9373d..3e610abe790508 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx @@ -11,8 +11,8 @@ import React from 'react'; import * as i18n from '../translations'; import { SortNumber } from './sort_number'; -import { SortDirection } from '.'; import { Direction } from '../../../../../../common/search_strategy'; +import { SortDirection } from '../../../../../../common/types/timeline'; enum SortDirectionIndicatorEnum { SORT_UP = 'sortUp', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx index 5ac1dcf8805cf6..06d8133a24f6e6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx @@ -17,6 +17,8 @@ import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock'; import { DefaultCellRenderer } from './default_cell_renderer'; +jest.mock('../../../../common/lib/kibana'); + jest.mock('../body/renderers/get_column_renderer'); const getColumnRendererMock = getColumnRenderer as jest.Mock; const mockImplementation = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/index.tsx index 03e444e3a9afda..2848a850a52271 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/index.tsx @@ -5,16 +5,4 @@ * 2.0. */ -import { EuiDataGridCellValueElementProps } from '@elastic/eui'; - -import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; -import { ColumnHeaderOptions } from '../../../store/timeline/model'; - -/** The following props are provided to the function called by `renderCellValue` */ -export type CellValueElementProps = EuiDataGridCellValueElementProps & { - data: TimelineNonEcsData[]; - eventId: string; // _id - header: ColumnHeaderOptions; - linkValues: string[] | undefined; - timelineId: string; -}; +export { CellValueElementProps } from '../../../../../common/types/timeline'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.test.tsx index 35595de6461262..ef04c1177dcd6f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.test.tsx @@ -11,11 +11,6 @@ import { TestProviders } from '../../../../common/mock/test_providers'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { DataProviders } from '.'; -import { ManageGlobalTimeline, getTimelineDefaults } from '../../manage_timeline'; -import { FilterManager } from '../../../../../../../../src/plugins/data/public/query/filter_manager'; -import { coreMock } from '../../../../../../../../src/core/public/mocks'; - -const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; jest.mock('../../../../common/hooks/use_selector', () => { const actual = jest.requireActual('../../../../common/hooks/use_selector'); @@ -25,7 +20,6 @@ jest.mock('../../../../common/hooks/use_selector', () => { }; }); -const filterManager = new FilterManager(mockUiSettingsForFilterManager); describe('DataProviders', () => { const mount = useMountAppended(); @@ -33,17 +27,9 @@ describe('DataProviders', () => { const dropMessage = ['Drop', 'query', 'build', 'here']; test('renders correctly against snapshot', () => { - const manageTimelineForTesting = { - foo: { - ...getTimelineDefaults('foo'), - filterManager, - }, - }; const wrapper = mount( - - - + ); expect(wrapper.find(`[data-test-subj="dataProviders-container"]`)).toBeTruthy(); @@ -73,19 +59,10 @@ describe('DataProviders', () => { }); describe('resizable drop target', () => { - const manageTimelineForTesting = { - foo: { - ...getTimelineDefaults('test'), - filterManager, - }, - }; - test('it may be resized vertically via a resize handle', () => { const wrapper = mount( - - - + ); @@ -98,9 +75,7 @@ describe('DataProviders', () => { test('it never grows taller than one third (33%) of the view height', () => { const wrapper = mount( - - - + ); @@ -113,9 +88,7 @@ describe('DataProviders', () => { test('it automatically displays scroll bars when the width or height of the data providers exceeds the drop target', () => { const wrapper = mount( - - - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx index c4233dc761b5f2..f642ec35d43068 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx @@ -9,16 +9,16 @@ import { rgba } from 'polished'; import React, { useMemo } from 'react'; import styled from 'styled-components'; import uuid from 'uuid'; +import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { DroppableWrapper } from '../../../../common/components/drag_and_drop/droppable_wrapper'; -import { IS_DRAGGING_CLASS_NAME } from '../../../../common/components/drag_and_drop/drag_classnames'; import { droppableTimelineProvidersPrefix } from '../../../../common/components/drag_and_drop/helpers'; + import { Empty } from './empty'; import { Providers } from './providers'; -import { useManageTimeline } from '../../manage_timeline'; import { timelineSelectors } from '../../../store/timeline'; import { timelineDefaults } from '../../../store/timeline/defaults'; @@ -86,11 +86,8 @@ const getDroppableId = (id: string): string => */ export const DataProviders = React.memo(({ timelineId }) => { const { browserFields } = useSourcererScope(SourcererScopeName.timeline); - const { getManageTimelineById } = useManageTimeline(); - const isLoading = useMemo(() => getManageTimelineById(timelineId).isLoading, [ - getManageTimelineById, - timelineId, - ]); + const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); + const { isLoading } = useDeepEqualSelector((state) => getManageTimeline(state, timelineId)); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const dataProviders = useDeepEqualSelector( (state) => (getTimeline(state, timelineId) ?? timelineDefaults).dataProviders diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx index a3693d5ba20018..e5e5ad5f010fca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx @@ -11,7 +11,10 @@ import { useDispatch } from 'react-redux'; import { TimelineType } from '../../../../../common/types/timeline'; import { BrowserFields } from '../../../../common/containers/source'; -import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { + useDeepEqualSelector, + useShallowEqualSelector, +} from '../../../../common/hooks/use_selector'; import { timelineSelectors } from '../../../store/timeline'; import { OnDataProviderEdited } from '../events'; @@ -19,7 +22,6 @@ import { ProviderBadge } from './provider_badge'; import { ProviderItemActions } from './provider_item_actions'; import { DataProvidersAnd, DataProviderType, QueryOperator } from './data_provider'; import { dragAndDropActions } from '../../../../common/store/drag_and_drop'; -import { useManageTimeline } from '../../manage_timeline'; interface ProviderItemBadgeProps { andProviderId?: string; @@ -75,11 +77,10 @@ export const ProviderItemBadge = React.memo( return getTimeline(state, timelineId)?.timelineType ?? TimelineType.default; }); - const { getManageTimelineById } = useManageTimeline(); - const isLoading = useMemo(() => getManageTimelineById(timelineId ?? '').isLoading, [ - getManageTimelineById, - timelineId, - ]); + const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); + const { isLoading } = useDeepEqualSelector((state) => + getManageTimeline(state, timelineId ?? '') + ); const togglePopover = useCallback(() => { setIsPopoverOpen(!isPopoverOpen); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx index 7f2133aca73484..a2a91c206521a2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx @@ -8,36 +8,30 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { TestProviders } from '../../../../common/mock/test_providers'; import { DroppableWrapper } from '../../../../common/components/drag_and_drop/droppable_wrapper'; -import { FilterManager } from '../../../../../../../../src/plugins/data/public'; import { timelineActions } from '../../../store/timeline'; import { mockDataProviders } from './mock/mock_data_providers'; import { Providers } from './providers'; import { DELETE_CLASS_NAME, ENABLE_CLASS_NAME, EXCLUDE_CLASS_NAME } from './provider_item_actions'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; -import { ManageGlobalTimeline, getTimelineDefaults } from '../../manage_timeline'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; +jest.mock('../../../../common/lib/kibana'); + +jest.mock('../../../../common/hooks/use_selector', () => ({ + useShallowEqualSelector: jest.fn(), + useDeepEqualSelector: jest.fn(), +})); describe('Providers', () => { - const isLoading: boolean = true; const mount = useMountAppended(); - const filterManager = new FilterManager(mockUiSettingsForFilterManager); const mockOnDataProviderRemoved = jest.spyOn(timelineActions, 'removeProvider'); - const manageTimelineForTesting = { - test: { - ...getTimelineDefaults('test'), - filterManager, - isLoading, - }, - }; - beforeEach(() => { jest.clearAllMocks(); + (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: false }); }); describe('rendering', () => { @@ -82,13 +76,12 @@ describe('Providers', () => { }); test('while loading data, it does NOT invoke the onDataProviderRemoved callback when the close button is clicked', () => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: true }); const wrapper = mount( - - - - - + + + ); @@ -120,13 +113,12 @@ describe('Providers', () => { }); test('while loading data, it does NOT invoke the onDataProviderRemoved callback when you click on the option "Delete" in the provider menu', () => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: true }); const wrapper = mount( - - - - - + + + ); wrapper.find('button[data-test-subj="providerBadge"]').first().simulate('click'); @@ -172,17 +164,16 @@ describe('Providers', () => { }); test('while loading data, it does NOT invoke the onToggleDataProviderEnabled callback when you click on the option "Temporary disable" in the provider menu', () => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: true }); const mockOnToggleDataProviderEnabled = jest.spyOn( timelineActions, 'updateDataProviderEnabled' ); const wrapper = mount( - - - - - + + + ); @@ -231,6 +222,7 @@ describe('Providers', () => { }); test('while loading data, it does NOT invoke the onToggleDataProviderExcluded callback when you click on the option "Exclude results" in the provider menu', () => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: true }); const mockOnToggleDataProviderExcluded = jest.spyOn( timelineActions, 'updateDataProviderExcluded' @@ -238,11 +230,9 @@ describe('Providers', () => { const wrapper = mount( - - - - - + + + ); @@ -311,16 +301,15 @@ describe('Providers', () => { }); test('while loading data, it does NOT invoke the onDataProviderRemoved callback when you click on the close button is clicked', () => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: true }); const dataProviders = mockDataProviders.slice(0, 1); dataProviders[0].and = mockDataProviders.slice(1, 3); const wrapper = mount( - - - - - + + + ); @@ -375,6 +364,7 @@ describe('Providers', () => { }); test('while loading data, it does NOT invoke the onToggleDataProviderEnabled callback when you click on the option "Temporary disable" in the provider menu', () => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: true }); const dataProviders = mockDataProviders.slice(0, 1); dataProviders[0].and = mockDataProviders.slice(1, 3); const mockOnToggleDataProviderEnabled = jest.spyOn( @@ -384,11 +374,9 @@ describe('Providers', () => { const wrapper = mount( - - - - - + + + ); @@ -448,6 +436,7 @@ describe('Providers', () => { }); test('while loading data, it does NOT invoke the onToggleDataProviderExcluded callback when you click on the option "Exclude results" in the provider menu', () => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ isLoading: true }); const dataProviders = mockDataProviders.slice(0, 1); dataProviders[0].and = mockDataProviders.slice(1, 3); const mockOnToggleDataProviderExcluded = jest.spyOn( @@ -457,11 +446,9 @@ describe('Providers', () => { const wrapper = mount( - - - - - + + + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index 117482f27674e1..e2ff038d7dc3fa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -13,14 +13,16 @@ import styled from 'styled-components'; import { useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; +import { + DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, + IS_DRAGGING_CLASS_NAME, +} from '@kbn/securitysolution-t-grid'; import { timelineActions } from '../../../store/timeline'; import { AndOrBadge } from '../../../../common/components/and_or_badge'; -import { useDraggableKeyboardWrapper } from '../../../../common/components/drag_and_drop/draggable_keyboard_wrapper_hook'; import { AddDataProviderPopover } from './add_data_provider_popover'; import { BrowserFields } from '../../../../common/containers/source'; import { - DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, getTimelineProviderDraggableId, getTimelineProviderDroppableId, } from '../../../../common/components/drag_and_drop/helpers'; @@ -30,6 +32,7 @@ import { EMPTY_GROUP, flattenIntoAndGroups } from './helpers'; import { ProviderItemBadge } from './provider_item_badge'; import * as i18n from './translations'; +import { useKibana } from '../../../../common/lib/kibana'; export const EMPTY_PROVIDERS_GROUP_CLASS_NAME = 'empty-providers-group'; @@ -158,6 +161,7 @@ export const DataProvidersGroupItem = React.memo( const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [, setClosePopOverTrigger] = useState(false); const dispatch = useDispatch(); + const { timelines } = useKibana().services; const handleClosePopOverTrigger = useCallback(() => { setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger); @@ -243,7 +247,7 @@ export const DataProvidersGroupItem = React.memo( setIsPopoverOpen(true); }, []); - const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({ + const { onBlur, onKeyDown } = timelines.getUseDraggableKeyboardWrapper()({ closePopover: handleClosePopOverTrigger, draggableId, fieldName: dataProvider.queryMatch.field, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx index e13bed1e2eff65..5f08bf5a016f59 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx @@ -22,6 +22,7 @@ import { useTimelineEvents } from '../../../containers/index'; import { useTimelineEventsDetails } from '../../../containers/details/index'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks'; +import { useDraggableKeyboardWrapper as mockUseDraggableKeyboardWrapper } from '../../../../../../timelines/public/components'; jest.mock('../../../containers/index', () => ({ useTimelineEvents: jest.fn(), @@ -57,6 +58,10 @@ jest.mock('../../../../common/lib/kibana', () => { savedObjects: { client: {}, }, + timelines: { + getLastUpdated: jest.fn(), + getUseDraggableKeyboardWrapper: () => mockUseDraggableKeyboardWrapper, + }, }, }), useGetUserSavedObjectPermissions: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx index bb2a995ff9faeb..b67b9348f51aab 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx @@ -17,7 +17,7 @@ import { isEmpty } from 'lodash/fp'; import React, { useEffect, useCallback } from 'react'; import styled from 'styled-components'; import { Dispatch } from 'redux'; -import { connect, ConnectedProps } from 'react-redux'; +import { connect, ConnectedProps, useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import { InPortal } from 'react-reverse-portal'; @@ -27,12 +27,17 @@ import { TimelineItem } from '../../../../../common/search_strategy'; import { useTimelineEvents } from '../../../containers/index'; import { defaultHeaders } from '../body/column_headers/default_headers'; import { StatefulBody } from '../body'; -import { RowRenderer } from '../body/renderers/row_renderer'; import { Footer, footerHeight } from '../footer'; import { calculateTotalPages } from '../helpers'; import { TimelineRefetch } from '../refetch_timeline'; -import { useManageTimeline } from '../../manage_timeline'; -import { TimelineEventsType, TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +import { + ControlColumnProps, + RowRenderer, + TimelineEventsType, + TimelineId, + TimelineTabs, + ToggleDetailPanel, +} from '../../../../../common/types/timeline'; import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config'; import { ExitFullScreen } from '../../../../common/components/exit_full_screen'; import { SuperDatePicker } from '../../../../common/components/super_date_picker'; @@ -48,10 +53,9 @@ import { TimelineModel } from '../../../../timelines/store/timeline/model'; import { TimelineDatePickerLock } from '../date_picker_lock'; import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { activeTimeline } from '../../../containers/active_timeline_context'; -import { ToggleDetailPanel } from '../../../store/timeline/actions'; import { DetailsPanel } from '../../side_panel'; import { EqlQueryBarTimeline } from '../query_bar/eql'; -import { defaultControlColumn, ControlColumnProps } from '../body/control_columns'; +import { defaultControlColumn } from '../body/control_columns'; import { Sort } from '../body/sort'; const TimelineHeaderContainer = styled.div` @@ -166,6 +170,7 @@ export const EqlTabContentComponent: React.FC = ({ timerangeKind, updateEventTypeAndIndexesName, }) => { + const dispatch = useDispatch(); const { query: eqlQuery = '', ...restEqlOption } = eqlOptions; const { portalNode: eqlEventsCountPortalNode } = useEqlEventsCountPortal(); const { setTimelineFullScreen, timelineFullScreen } = useTimelineFullScreen(); @@ -192,12 +197,13 @@ export const EqlTabContentComponent: React.FC = ({ return [...columnFields, ...requiredFieldsForActions]; }; - const { initializeTimeline, setIsTimelineLoading } = useManageTimeline(); useEffect(() => { - initializeTimeline({ - id: timelineId, - }); - }, [initializeTimeline, timelineId]); + dispatch( + timelineActions.initializeTGridSettings({ + id: timelineId, + }) + ); + }, [dispatch, timelineId]); const [ isQueryLoading, @@ -230,8 +236,13 @@ export const EqlTabContentComponent: React.FC = ({ }, [onEventClosed, timelineId, expandedDetail, showExpandedDetails]); useEffect(() => { - setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer }); - }, [loadingSourcerer, timelineId, isQueryLoading, setIsTimelineLoading]); + dispatch( + timelineActions.updateIsLoading({ + id: timelineId, + isLoading: isQueryLoading || loadingSourcerer, + }) + ); + }, [loadingSourcerer, timelineId, isQueryLoading, dispatch]); const leadingControlColumns: ControlColumnProps[] = [defaultControlColumn]; const trailingControlColumns: ControlColumnProps[] = []; @@ -385,7 +396,6 @@ const makeMapStateToProps = () => { }; return mapStateToProps; }; - const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ updateEventTypeAndIndexesName: (newEventType: TimelineEventsType, newIndexNames: string[]) => { dispatch(timelineActions.updateEventType({ id: timelineId, eventType: newEventType })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts index 21e213b7995357..ca7c3596d13bb8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts @@ -5,10 +5,20 @@ * 2.0. */ -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { ColumnId } from './body/column_id'; -import { SortDirection } from './body/sort'; import { DataProvider, QueryOperator } from './data_providers/data_provider'; +export { + OnColumnSorted, + OnColumnsSorted, + OnColumnRemoved, + OnColumnResized, + OnChangePage, + OnPinEvent, + OnRowSelected, + OnSelectAll, + OnUnPinEvent, + OnUpdateColumns, +} from '../../../../common/types/timeline'; export type OnDataProviderEdited = ({ andProviderId, @@ -35,38 +45,3 @@ export type OnRangeSelected = (range: string) => void; /** Invoked when a user updates a column's filter */ export type OnFilterChange = (filter: { columnId: ColumnId; filter: string }) => void; - -/** Invoked when a column is sorted */ -export type OnColumnSorted = (sorted: { columnId: ColumnId; sortDirection: SortDirection }) => void; - -export type OnColumnsSorted = ( - sorted: Array<{ columnId: ColumnId; sortDirection: SortDirection }> -) => void; - -export type OnColumnRemoved = (columnId: ColumnId) => void; - -export type OnColumnResized = ({ columnId, delta }: { columnId: ColumnId; delta: number }) => void; - -/** Invoked when a user clicks to load more item */ -export type OnChangePage = (nextPage: number) => void; - -/** Invoked when a user pins an event */ -export type OnPinEvent = (eventId: string) => void; - -/** Invoked when a user checks/un-checks a row */ -export type OnRowSelected = ({ - eventIds, - isSelected, -}: { - eventIds: string[]; - isSelected: boolean; -}) => void; - -/** Invoked when a user checks/un-checks the select all checkbox */ -export type OnSelectAll = ({ isSelected }: { isSelected: boolean }) => void; - -/** Invoked when columns are updated */ -export type OnUpdateColumns = (columns: ColumnHeaderOptions[]) => void; - -/** Invoked when a user unpins an event */ -export type OnUnPinEvent = (eventId: string) => void; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.test.tsx index f0a14e990e1cc9..cf8d51546a899e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.test.tsx @@ -12,6 +12,8 @@ import { TestProviders } from '../../../../common/mock/test_providers'; import { FooterComponent, PagingControlComponent } from './index'; +jest.mock('../../../../common/lib/kibana'); + describe('Footer Timeline Component', () => { const loadMore = jest.fn(); const updatedAt = 1546878704036; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index 4c5432f686c93e..ac6f6e52db1e22 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -24,15 +24,14 @@ import React, { FC, useCallback, useEffect, useState, useMemo } from 'react'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; -import { LoadingPanel } from '../../loading'; import { OnChangePage } from '../events'; import { EVENTS_COUNT_BUTTON_CLASS_NAME } from '../helpers'; import * as i18n from './translations'; import { useEventDetailsWidthContext } from '../../../../common/components/events_viewer/event_details_width_context'; -import { useManageTimeline } from '../../manage_timeline'; -import { LastUpdatedAt } from '../../../../common/components/last_updated'; -import { timelineActions } from '../../../store/timeline'; +import { timelineActions, timelineSelectors } from '../../../store/timeline'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { useKibana } from '../../../../common/lib/kibana'; export const isCompactFooter = (width: number): boolean => width < 600; @@ -42,12 +41,13 @@ interface FixedWidthLastUpdatedContainerProps { const FixedWidthLastUpdatedContainer = React.memo( ({ updatedAt }) => { + const { timelines } = useKibana().services; const width = useEventDetailsWidthContext(); const compact = useMemo(() => isCompactFooter(width), [width]); return ( - + {timelines.getLastUpdated({ updatedAt, compact })} ); } @@ -259,14 +259,16 @@ export const FooterComponent = ({ totalCount, }: FooterProps) => { const dispatch = useDispatch(); + const { timelines } = useKibana().services; const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [paginationLoading, setPaginationLoading] = useState(false); - const { getManageTimelineById } = useManageTimeline(); - const { documentType, loadingText, footerText } = useMemo(() => getManageTimelineById(id), [ - getManageTimelineById, - id, - ]); + const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); + const { + documentType = i18n.TOTAL_COUNT_OF_EVENTS, + loadingText = i18n.LOADING_EVENTS, + footerText = i18n.TOTAL_COUNT_OF_EVENTS, + } = useDeepEqualSelector((state) => getManageTimeline(state, id)); const handleChangePageClick = useCallback( (nextPage: number) => { @@ -322,13 +324,13 @@ export const FooterComponent = ({ if (isLoading && !paginationLoading) { return ( - + {timelines.getLoadingPanel({ + dataTestSubj: 'LoadingPanelTimeline', + height: '35px', + showBorder: false, + text: loadingText, + width: '100%', + })} ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts index fa8a8b743646d9..6736573cac2930 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts @@ -43,3 +43,10 @@ export const AUTO_REFRESH_ACTIVE = i18n.translate( defaultMessage: 'Auto-Refresh Active', } ); + +export const LOADING_EVENTS = i18n.translate( + 'xpack.securitySolution.footer.loadingEventsDataLabel', + { + defaultMessage: 'Loading Events', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index 0093ce2f95bdd5..f2a40711116027 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -14,7 +14,7 @@ import { getFocusedAriaColindexCell, getTableSkipFocus, stopPropagationAndPreventDefault, -} from '../../../common/components/accessibility/helpers'; +} from '../../../../../timelines/public'; import { escapeQueryValue, convertToBuildEsQuery } from '../../../common/lib/keury'; import { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 5e86bf8d753855..e95efdf7544180 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -11,16 +11,15 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { isTab } from '../../../../../timelines/public'; import { timelineActions, timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { defaultHeaders } from './body/column_headers/default_headers'; -import { RowRenderer } from './body/renderers/row_renderer'; import { CellValueElementProps } from './cell_rendering'; -import { isTab } from '../../../common/components/accessibility/helpers'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header'; -import { TimelineType, TimelineId } from '../../../../common/types/timeline'; +import { TimelineType, TimelineId, RowRenderer } from '../../../../common/types/timeline'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { activeTimeline } from '../../containers/active_timeline_context'; import { EVENTS_COUNT_BUTTON_CLASS_NAME, onTimelineTabKeyPressed } from './helpers'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx index 0f781b0958d02c..f4d5570ce40d3f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx @@ -23,6 +23,7 @@ import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks'; import { PinnedTabContentComponent, Props as PinnedTabContentComponentProps } from '.'; import { Direction } from '../../../../../common/search_strategy'; +import { useDraggableKeyboardWrapper as mockUseDraggableKeyboardWrapper } from '../../../../../../timelines/public/components'; jest.mock('../../../containers/index', () => ({ useTimelineEvents: jest.fn(), @@ -57,6 +58,10 @@ jest.mock('../../../../common/lib/kibana', () => { savedObjects: { client: {}, }, + timelines: { + getLastUpdated: jest.fn(), + getUseDraggableKeyboardWrapper: () => mockUseDraggableKeyboardWrapper, + }, }, }), useGetUserSavedObjectPermissions: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx index c01cf5c8aa0f05..b5e3d853bc81c5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx @@ -19,7 +19,6 @@ import { Direction } from '../../../../../common/search_strategy'; import { useTimelineEvents } from '../../../containers/index'; import { defaultHeaders } from '../body/column_headers/default_headers'; import { StatefulBody } from '../body'; -import { RowRenderer } from '../body/renderers/row_renderer'; import { Footer, footerHeight } from '../footer'; import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; @@ -29,14 +28,18 @@ import { timelineDefaults } from '../../../store/timeline/defaults'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { TimelineModel } from '../../../store/timeline/model'; -import { ToggleDetailPanel } from '../../../store/timeline/actions'; import { State } from '../../../../common/store'; import { calculateTotalPages } from '../helpers'; -import { TimelineTabs } from '../../../../../common/types/timeline'; +import { + ControlColumnProps, + RowRenderer, + TimelineTabs, + ToggleDetailPanel, +} from '../../../../../common/types/timeline'; import { DetailsPanel } from '../../side_panel'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { ExitFullScreen } from '../../../../common/components/exit_full_screen'; -import { defaultControlColumn, ControlColumnProps } from '../body/control_columns'; +import { defaultControlColumn } from '../body/control_columns'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` overflow-y: hidden; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx index 8790d8c98c161e..b2b304e16c4a09 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx @@ -22,7 +22,6 @@ import { SavedQueryTimeFilter, } from '../../../../../../../../src/plugins/data/public'; import { convertKueryToElasticSearchQuery } from '../../../../common/lib/keury'; -import { KueryFilterQuery, KueryFilterQueryKind } from '../../../../common/store'; import { KqlMode } from '../../../../timelines/store/timeline/model'; import { useSavedQueryServices } from '../../../../common/utils/saved_query_services'; import { DispatchUpdateReduxTime } from '../../../../common/components/super_date_picker'; @@ -30,6 +29,7 @@ import { QueryBar } from '../../../../common/components/query_bar'; import { DataProvider } from '../data_providers/data_provider'; import { buildGlobalQuery } from '../helpers'; import { timelineActions } from '../../../store/timeline'; +import { KueryFilterQuery, KueryFilterQueryKind } from '../../../../../common/types/timeline'; export interface QueryBarTimelineComponentProps { dataProviders: DataProvider[]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index acae8c8c53cd02..9bf7ee28f39341 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -59,6 +59,15 @@ jest.mock('../../../../common/lib/kibana', () => { savedObjects: { client: {}, }, + timelines: { + getLastUpdated: jest.fn(), + getLoadingPanel: jest.fn(), + getUseDraggableKeyboardWrapper: () => + jest.fn().mockReturnValue({ + onBlur: jest.fn(), + onKeyDown: jest.fn(), + }), + }, }, }), useGetUserSavedObjectPermissions: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 4298f2ff745170..6f0bbd026cd7bf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -17,12 +17,11 @@ import { isEmpty } from 'lodash/fp'; import React, { useState, useMemo, useEffect, useCallback } from 'react'; import styled from 'styled-components'; import { Dispatch } from 'redux'; -import { connect, ConnectedProps } from 'react-redux'; +import { connect, ConnectedProps, useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import { InPortal } from 'react-reverse-portal'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; -import { RowRenderer } from '../body/renderers/row_renderer'; import { CellValueElementProps } from '../cell_rendering'; import { Direction, TimelineItem } from '../../../../../common/search_strategy'; import { useTimelineEvents } from '../../../containers/index'; @@ -34,18 +33,20 @@ import { TimelineHeader } from '../header'; import { calculateTotalPages, combineQueries } from '../helpers'; import { TimelineRefetch } from '../refetch_timeline'; import { esQuery, FilterManager } from '../../../../../../../../src/plugins/data/public'; -import { useManageTimeline } from '../../manage_timeline'; -import { TimelineEventsType, TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +import { + ControlColumnProps, + KueryFilterQueryKind, + RowRenderer, + TimelineEventsType, + TimelineId, + TimelineTabs, + ToggleDetailPanel, +} from '../../../../../common/types/timeline'; import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config'; import { SuperDatePicker } from '../../../../common/components/super_date_picker'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; import { PickEventType } from '../search_or_filter/pick_events'; -import { - inputsModel, - inputsSelectors, - KueryFilterQueryKind, - State, -} from '../../../../common/store'; +import { inputsModel, inputsSelectors, State } from '../../../../common/store'; import { sourcererActions } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; @@ -55,10 +56,9 @@ import { TimelineModel } from '../../../../timelines/store/timeline/model'; import { TimelineDatePickerLock } from '../date_picker_lock'; import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { activeTimeline } from '../../../containers/active_timeline_context'; -import { ToggleDetailPanel } from '../../../store/timeline/actions'; import { DetailsPanel } from '../../side_panel'; import { ExitFullScreen } from '../../../../common/components/exit_full_screen'; -import { defaultControlColumn, ControlColumnProps } from '../body/control_columns'; +import { defaultControlColumn } from '../body/control_columns'; const TimelineHeaderContainer = styled.div` margin-top: 6px; @@ -180,6 +180,7 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, updateEventTypeAndIndexesName, }) => { + const dispatch = useDispatch(); const { portalNode: timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); const { setTimelineFullScreen, timelineFullScreen } = useTimelineFullScreen(); const { @@ -231,13 +232,14 @@ export const QueryTabContentComponent: React.FC = ({ type: columnType, })); - const { initializeTimeline, setIsTimelineLoading } = useManageTimeline(); useEffect(() => { - initializeTimeline({ - filterManager, - id: timelineId, - }); - }, [initializeTimeline, filterManager, timelineId]); + dispatch( + timelineActions.initializeTGridSettings({ + filterManager, + id: timelineId, + }) + ); + }, [filterManager, timelineId, dispatch]); const [ isQueryLoading, @@ -270,8 +272,13 @@ export const QueryTabContentComponent: React.FC = ({ }, [onEventClosed, timelineId, expandedDetail, showExpandedDetails]); useEffect(() => { - setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer }); - }, [loadingSourcerer, timelineId, isQueryLoading, setIsTimelineLoading]); + dispatch( + timelineActions.updateIsLoading({ + id: timelineId, + isLoading: isQueryLoading || loadingSourcerer, + }) + ); + }, [loadingSourcerer, timelineId, isQueryLoading, dispatch]); const leadingControlColumns: ControlColumnProps[] = [defaultControlColumn]; const trailingControlColumns: ControlColumnProps[] = []; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/index.tsx index 4ea4f94abff634..33ab2e0049828a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/index.tsx @@ -12,17 +12,13 @@ import { Dispatch } from 'redux'; import deepEqual from 'fast-deep-equal'; import { Filter, FilterManager } from '../../../../../../../../src/plugins/data/public'; -import { - SerializedFilterQuery, - State, - inputsModel, - inputsSelectors, -} from '../../../../common/store'; +import { State, inputsModel, inputsSelectors } from '../../../../common/store'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { KqlMode, TimelineModel } from '../../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; import { dispatchUpdateReduxTime } from '../../../../common/components/super_date_picker'; import { SearchOrFilter } from './search_or_filter'; +import { SerializedFilterQuery } from '../../../../../common/types/timeline'; interface OwnProps { filterManager: FilterManager; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx index 262709ed98e5ab..f1c4b7c3ef0890 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx @@ -10,9 +10,9 @@ import React, { useCallback } from 'react'; import styled, { createGlobalStyle } from 'styled-components'; import { Filter, FilterManager } from '../../../../../../../../src/plugins/data/public'; -import { KueryFilterQuery } from '../../../../common/store'; import { KqlMode } from '../../../../timelines/store/timeline/model'; import { DispatchUpdateReduxTime } from '../../../../common/components/super_date_picker'; +import { KueryFilterQuery } from '../../../../../common/types/timeline'; import { DataProvider } from '../data_providers/data_provider'; import { QueryBarTimeline } from '../query_bar'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index adaa5f98c88c4c..8cdd7722d7fbd3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -10,7 +10,12 @@ import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 're import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { TimelineTabs, TimelineId, TimelineType } from '../../../../../common/types/timeline'; +import { + RowRenderer, + TimelineTabs, + TimelineId, + TimelineType, +} from '../../../../../common/types/timeline'; import { useShallowEqualSelector, useDeepEqualSelector, @@ -20,7 +25,6 @@ import { TimelineEventsCountBadge, } from '../../../../common/hooks/use_timeline_events_count'; import { timelineActions } from '../../../store/timeline'; -import { RowRenderer } from '../body/renderers/row_renderer'; import { CellValueElementProps } from '../cell_rendering'; import { getActiveTabSelector, diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index 37fdd5a444b2b3..86624ba161a836 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -69,7 +69,7 @@ export const useTimelineEventsDetails = ({ .search( request, { - strategy: 'securitySolutionTimelineSearchStrategy', + strategy: 'timelineSearchStrategy', abortSignal: abortCtrl.current.signal, } ) diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 17c107899d85ab..00df0146e06d52 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -14,7 +14,7 @@ import { Subscription } from 'rxjs'; import { ESQuery } from '../../../common/typed_json'; import { isCompleteResponse, isErrorResponse } from '../../../../../../src/plugins/data/public'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; -import { inputsModel, KueryFilterQueryKind } from '../../common/store'; +import { inputsModel } from '../../common/store'; import { useKibana } from '../../common/lib/kibana'; import { createFilter } from '../../common/containers/helpers'; import { timelineActions } from '../../timelines/store/timeline'; @@ -33,7 +33,7 @@ import { } from '../../../common/search_strategy'; import { InspectResponse } from '../../types'; import * as i18n from './translations'; -import { TimelineId } from '../../../common/types/timeline'; +import { KueryFilterQueryKind, TimelineId } from '../../../common/types/timeline'; import { useRouteSpy } from '../../common/utils/route/use_route_spy'; import { activeTimeline } from './active_timeline_context'; import { @@ -214,9 +214,7 @@ export const useTimelineEvents = ({ searchSubscription$.current = data.search .search, TimelineResponse>(request, { strategy: - request.language === 'eql' - ? 'securitySolutionTimelineEqlSearchStrategy' - : 'securitySolutionTimelineSearchStrategy', + request.language === 'eql' ? 'timelineEqlSearchStrategy' : 'timelineSearchStrategy', abortSignal: abortCtrl.current.signal, }) .subscribe({ diff --git a/x-pack/plugins/security_solution/public/timelines/containers/kpis/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/kpis/index.tsx index 4a6eab13ba4f1a..be93a13ab1c6ae 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/kpis/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/kpis/index.tsx @@ -64,7 +64,7 @@ export const useTimelineKpis = ({ searchSubscription$.current = data.search .search(request, { - strategy: 'securitySolutionTimelineSearchStrategy', + strategy: 'timelineSearchStrategy', abortSignal: abortCtrl.current.signal, }) .subscribe({ diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx index 38eb6d3d222f84..99f45c7d9a4b45 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx @@ -9,8 +9,8 @@ import { isEmpty } from 'lodash/fp'; import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { TimelinesStorage } from './types'; import { useKibana } from '../../../common/lib/kibana'; -import { ColumnHeaderOptions, TimelineModel } from '../../store/timeline/model'; -import { TimelineIdLiteral } from '../../../../common/types/timeline'; +import { TimelineModel } from '../../store/timeline/model'; +import { ColumnHeaderOptions, TimelineIdLiteral } from '../../../../common/types/timeline'; export const LOCAL_STORAGE_TIMELINE_KEY = 'timelines'; const EMPTY_TIMELINE = {} as { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index 11e9a625d05d04..a3429c9247ffdf 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -8,25 +8,42 @@ import actionCreatorFactory from 'typescript-fsa'; import { Filter } from '../../../../../../../src/plugins/data/public'; -import { Sort } from '../../../timelines/components/timeline/body/sort'; import { DataProvider, DataProviderType, QueryOperator, } from '../../../timelines/components/timeline/data_providers/data_provider'; -import { SerializedFilterQuery } from '../../../common/store/types'; -import { KqlMode, TimelineModel, ColumnHeaderOptions } from './model'; -import { FieldsEqlOptions, TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; +import { KqlMode, TimelineModel } from './model'; +import { FieldsEqlOptions } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, - TimelineExpandedDetail, - TimelineExpandedDetailType, - TimelineTypeLiteral, RowRendererId, TimelineTabs, + TimelinePersistInput, + SerializedFilterQuery, } from '../../../../common/types/timeline'; import { InsertTimeline } from './types'; +import { tGridActions } from '../../../../../timelines/public'; +export const { + applyDeltaToColumnWidth, + clearEventsDeleted, + clearEventsLoading, + clearSelected, + initializeTGridSettings, + removeColumn, + setEventsDeleted, + setEventsLoading, + setSelected, + setTGridSelectAll, + toggleDetailPanel, + updateColumns, + updateIsLoading, + updateItemsPerPage, + updateItemsPerPageOptions, + updateSort, + upsertColumn, +} = tGridActions; const actionCreator = actionCreatorFactory('x-pack/security_solution/local/timeline'); @@ -38,62 +55,14 @@ export const addNoteToEvent = actionCreator<{ id: string; noteId: string; eventI 'ADD_NOTE_TO_EVENT' ); -export type ToggleDetailPanel = TimelineExpandedDetailType & { - tabType?: TimelineTabs; - timelineId: string; -}; - -export const toggleDetailPanel = actionCreator('TOGGLE_DETAIL_PANEL'); - -export const upsertColumn = actionCreator<{ - column: ColumnHeaderOptions; - id: string; - index: number; -}>('UPSERT_COLUMN'); - export const addProvider = actionCreator<{ id: string; provider: DataProvider }>('ADD_PROVIDER'); -export const applyDeltaToColumnWidth = actionCreator<{ - id: string; - columnId: string; - delta: number; -}>('APPLY_DELTA_TO_COLUMN_WIDTH'); - -export interface TimelineInput { - id: string; - dataProviders?: DataProvider[]; - dateRange?: { - start: string; - end: string; - }; - excludedRowRendererIds?: RowRendererId[]; - expandedDetail?: TimelineExpandedDetail; - filters?: Filter[]; - columns: ColumnHeaderOptions[]; - itemsPerPage?: number; - indexNames: string[]; - kqlQuery?: { - filterQuery: SerializedFilterQuery | null; - }; - show?: boolean; - sort?: Sort[]; - showCheckboxes?: boolean; - timelineType?: TimelineTypeLiteral; - templateTimelineId?: string | null; - templateTimelineVersion?: number | null; -} - -export const saveTimeline = actionCreator('SAVE_TIMELINE'); +export const saveTimeline = actionCreator('SAVE_TIMELINE'); -export const createTimeline = actionCreator('CREATE_TIMELINE'); +export const createTimeline = actionCreator('CREATE_TIMELINE'); export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT'); -export const removeColumn = actionCreator<{ - id: string; - columnId: string; -}>('REMOVE_COLUMN'); - export const removeProvider = actionCreator<{ id: string; providerId: string; @@ -129,16 +98,6 @@ export const endTimelineSaving = actionCreator<{ id: string; }>('END_TIMELINE_SAVING'); -export const updateIsLoading = actionCreator<{ - id: string; - isLoading: boolean; -}>('UPDATE_LOADING'); - -export const updateColumns = actionCreator<{ - id: string; - columns: ColumnHeaderOptions[]; -}>('UPDATE_COLUMNS'); - export const updateDataProviderEnabled = actionCreator<{ id: string; enabled: boolean; @@ -189,15 +148,6 @@ export const updateIsFavorite = actionCreator<{ id: string; isFavorite: boolean export const updateIsLive = actionCreator<{ id: string; isLive: boolean }>('UPDATE_IS_LIVE'); -export const updateItemsPerPage = actionCreator<{ id: string; itemsPerPage: number }>( - 'UPDATE_ITEMS_PER_PAGE' -); - -export const updateItemsPerPageOptions = actionCreator<{ - id: string; - itemsPerPageOptions: number[]; -}>('UPDATE_ITEMS_PER_PAGE_OPTIONS'); - export const updateTitleAndDescription = actionCreator<{ description: string; id: string; @@ -216,8 +166,6 @@ export const updateRange = actionCreator<{ id: string; start: string; end: strin 'UPDATE_RANGE' ); -export const updateSort = actionCreator<{ id: string; sort: Sort[] }>('UPDATE_SORT'); - export const updateAutoSaveMsg = actionCreator<{ timelineId: string | null; newTimelineModel: TimelineModel | null; @@ -235,37 +183,6 @@ export const setFilters = actionCreator<{ filters: Filter[]; }>('SET_TIMELINE_FILTERS'); -export const setSelected = actionCreator<{ - id: string; - eventIds: Readonly>; - isSelected: boolean; - isSelectAllChecked: boolean; -}>('SET_TIMELINE_SELECTED'); - -export const clearSelected = actionCreator<{ - id: string; -}>('CLEAR_TIMELINE_SELECTED'); - -export const setEventsLoading = actionCreator<{ - id: string; - eventIds: string[]; - isLoading: boolean; -}>('SET_TIMELINE_EVENTS_LOADING'); - -export const clearEventsLoading = actionCreator<{ - id: string; -}>('CLEAR_TIMELINE_EVENTS_LOADING'); - -export const setEventsDeleted = actionCreator<{ - id: string; - eventIds: string[]; - isDeleted: boolean; -}>('SET_TIMELINE_EVENTS_DELETED'); - -export const clearEventsDeleted = actionCreator<{ - id: string; -}>('CLEAR_TIMELINE_EVENTS_DELETED'); - export const updateEventType = actionCreator<{ id: string; eventType: TimelineEventsType }>( 'UPDATE_EVENT_TYPE' ); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index 7e76f6035f8b53..d8fd82005dfbed 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -10,7 +10,6 @@ import { TimelineType, TimelineStatus, TimelineTabs } from '../../../../common/t import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; import { SubsetTimelineModel, TimelineModel } from './model'; -import { Direction } from '../../../../common/search_strategy'; // normalizeTimeRange uses getTimeRangeSettings which cannot be used outside Kibana context if the uiSettings is not false const { from: start, to: end } = normalizeTimeRange({ from: '', to: '' }, false); @@ -66,7 +65,7 @@ export const timelineDefaults: SubsetTimelineModel & { columnId: '@timestamp', columnType: 'number', - sortDirection: Direction.desc, + sortDirection: 'desc', }, ], status: TimelineStatus.draft, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index 5f5d76990b5ffb..8f2631dac6769a 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -41,6 +41,7 @@ import { TimelineType, ResponseTimeline, TimelineResult, + ColumnHeaderOptions, } from '../../../../common/types/timeline'; import { inputsModel } from '../../../common/store/inputs'; import { addError } from '../../../common/store/app/actions'; @@ -81,7 +82,7 @@ import { showCallOutUnauthorizedMsg, saveTimeline, } from './actions'; -import { ColumnHeaderOptions, TimelineModel } from './model'; +import { TimelineModel } from './model'; import { epicPersistNote, timelineNoteActionsType } from './epic_note'; import { epicPersistPinnedEvent, timelinePinnedEventActionsType } from './epic_pinned_event'; import { epicPersistTimelineFavorite, timelineFavoriteActionsType } from './epic_favorite'; @@ -96,13 +97,11 @@ const timelineActionsType = [ addProvider.type, addTimeline.type, dataProviderEdited.type, - removeColumn.type, removeProvider.type, saveTimeline.type, setExcludedRowRendererIds.type, setFilters.type, setSavedQueryId.type, - updateColumns.type, updateDataProviderEnabled.type, updateDataProviderExcluded.type, updateDataProviderKqlQuery.type, @@ -110,10 +109,13 @@ const timelineActionsType = [ updateEqlOptions.type, updateEventType.type, updateKqlMode.type, - updateIndexNames.type, updateProviders.type, - updateSort.type, updateTitleAndDescription.type, + + updateIndexNames.type, + removeColumn.type, + updateColumns.type, + updateSort.type, updateRange.type, upsertColumn.type, ]; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 2172cf8562c97a..610c394614c32e 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -8,7 +8,6 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import uuid from 'uuid'; -import { ToggleDetailPanel } from './actions'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { Sort } from '../../../timelines/components/timeline/body/sort'; @@ -20,22 +19,24 @@ import { IS_OPERATOR, EXISTS_OPERATOR, } from '../../../timelines/components/timeline/data_providers/data_provider'; -import { SerializedFilterQuery } from '../../../common/store/model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { + ColumnHeaderOptions, TimelineEventsType, - TimelineExpandedDetail, TimelineTypeLiteral, TimelineType, RowRendererId, TimelineStatus, TimelineId, TimelineTabs, + SerializedFilterQuery, + ToggleDetailPanel, + TimelinePersistInput, } from '../../../../common/types/timeline'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; import { timelineDefaults } from './defaults'; -import { ColumnHeaderOptions, KqlMode, TimelineModel } from './model'; +import { KqlMode, TimelineModel } from './model'; import { TimelineById } from './types'; import { DEFAULT_FROM_MOMENT, @@ -168,47 +169,20 @@ export const addTimelineToStore = ({ }; }; -interface AddNewTimelineParams { - columns: ColumnHeaderOptions[]; - dataProviders?: DataProvider[]; - dateRange?: { - start: string; - end: string; - }; - excludedRowRendererIds?: RowRendererId[]; - expandedDetail?: TimelineExpandedDetail; - filters?: Filter[]; - id: string; - itemsPerPage?: number; - indexNames: string[]; - kqlQuery?: { - filterQuery: SerializedFilterQuery | null; - }; - show?: boolean; - sort?: Sort[]; - showCheckboxes?: boolean; +interface AddNewTimelineParams extends TimelinePersistInput { timelineById: TimelineById; timelineType: TimelineTypeLiteral; } /** Adds a new `Timeline` to the provided collection of `TimelineById` */ export const addNewTimeline = ({ - columns, - dataProviders = [], - dateRange: maybeDateRange, - excludedRowRendererIds = [], - expandedDetail = {}, - filters = timelineDefaults.filters, id, - itemsPerPage = timelineDefaults.itemsPerPage, - indexNames, - kqlQuery = { filterQuery: null }, - sort = timelineDefaults.sort, - show = false, - showCheckboxes = false, timelineById, timelineType, + dateRange: maybeDateRange, + ...timelineProps }: AddNewTimelineParams): TimelineById => { + const timeline = timelineById[id]; const { from: startDateRange, to: endDateRange } = normalizeTimeRange({ from: '', to: '' }); const dateRange = maybeDateRange ?? { start: startDateRange, end: endDateRange }; const templateTimelineInfo = @@ -222,23 +196,14 @@ export const addNewTimeline = ({ ...timelineById, [id]: { id, + ...(timeline ? timeline : {}), ...timelineDefaults, - columns, - dataProviders, + ...timelineProps, dateRange, - expandedDetail, - excludedRowRendererIds, - filters, - itemsPerPage, - indexNames, - kqlQuery, - sort, - show, savedObjectId: null, version: null, isSaving: false, isLoading: false, - showCheckboxes, timelineType, ...templateTimelineInfo, }, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index 559cec57dd55c1..a68617536c6afe 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -5,63 +5,29 @@ * 2.0. */ -import { EuiDataGridColumn } from '@elastic/eui'; - -import { Filter, IFieldSubType } from '../../../../../../../src/plugins/data/public'; - import { DataProvider } from '../../components/timeline/data_providers/data_provider'; -import { Sort } from '../../components/timeline/body/sort'; -import { - EqlOptionsSelected, - TimelineNonEcsData, -} from '../../../../common/search_strategy/timeline'; -import { SerializedFilterQuery } from '../../../common/store/types'; +import { EqlOptionsSelected } from '../../../../common/search_strategy/timeline'; import type { TimelineEventsType, - TimelineExpandedDetail, TimelineType, TimelineStatus, - RowRendererId, TimelineTabs, } from '../../../../common/types/timeline'; import { PinnedEvent } from '../../../../common/types/timeline/pinned_event'; +import type { TGridModelForTimeline } from '../../../../../timelines/public'; export const DEFAULT_PAGE_COUNT = 2; // Eui Pager will not render unless this is a minimum of 2 pages export type KqlMode = 'filter' | 'search'; export type ColumnHeaderType = 'not-filtered' | 'text-filter'; -/** Uniquely identifies a column */ -export type ColumnId = string; - -/** The specification of a column header */ -export type ColumnHeaderOptions = Pick< - EuiDataGridColumn, - 'display' | 'displayAsText' | 'id' | 'initialWidth' -> & { - aggregatable?: boolean; - category?: string; - columnHeaderType: ColumnHeaderType; - description?: string; - example?: string; - format?: string; - linkField?: string; - placeholder?: string; - subType?: IFieldSubType; - type?: string; -}; - -export interface TimelineModel { +export type TimelineModel = TGridModelForTimeline & { /** The selected tab to displayed in the timeline */ activeTab: TimelineTabs; prevActiveTab: TimelineTabs; - /** The columns displayed in the timeline */ - columns: ColumnHeaderOptions[]; /** Timeline saved object owner */ createdBy?: string; /** The sources of the event data shown in the timeline */ dataProviders: DataProvider[]; - /** Events to not be rendered **/ - deletedEventIds: string[]; /** A summary of the events and notes in this timeline */ description: string; eqlOptions: EqlOptionsSelected; @@ -69,40 +35,16 @@ export interface TimelineModel { eventType?: TimelineEventsType; /** A map of events in this timeline to the chronologically ordered notes (in this timeline) associated with the event */ eventIdToNoteIds: Record; - /** A list of Ids of excluded Row Renderers */ - excludedRowRendererIds: RowRendererId[]; - /** This holds the view information for the flyout when viewing timeline in a consuming view (i.e. hosts page) or the side panel in the primary timeline view */ - expandedDetail: TimelineExpandedDetail; - filters?: Filter[]; - /** When non-empty, display a graph view for this event */ - graphEventId?: string; /** The chronological history of actions related to this timeline */ historyIds: string[]; /** The chronological history of actions related to this timeline */ highlightedDropAndProviderId: string; - /** Uniquely identifies the timeline */ - id: string; - /** TO DO sourcerer @X define this */ - indexNames: string[]; - /** If selectAll checkbox in header is checked **/ - isSelectAllChecked: boolean; - /** Events to be rendered as loading **/ - loadingEventIds: string[]; - savedObjectId: string | null; /** When true, this timeline was marked as "favorite" by the user */ isFavorite: boolean; /** When true, the timeline will update as new data arrives */ isLive: boolean; - /** The number of items to show in a single page of results */ - itemsPerPage: number; - /** Displays a series of choices that when selected, become the value of `itemsPerPage` */ - itemsPerPageOptions: number[]; /** determines the behavior of the KQL bar */ kqlMode: KqlMode; - /** the KQL query in the KQL bar */ - kqlQuery: { - filterQuery: SerializedFilterQuery | null; - }; /** Title */ title: string; /** timelineType: default | template */ @@ -116,30 +58,18 @@ export interface TimelineModel { /** Events pinned to this timeline */ pinnedEventIds: Record; pinnedEventsSaveObject: Record; - /** Specifies the granularity of the date range (e.g. 1 Day / Week / Month) applicable to the mini-map */ - dateRange: { - start: string; - end: string; - }; showSaveModal?: boolean; savedQueryId?: string | null; - /** Events selected on this timeline -- eventId to TimelineNonEcsData[] mapping of data required for batch actions **/ - selectedEventIds: Record; /** When true, show the timeline flyover */ show: boolean; - /** When true, shows checkboxes enabling selection. Selected events store in selectedEventIds **/ - showCheckboxes: boolean; - /** Specifies which column the timeline is sorted on, and the direction (ascending / descending) */ - sort: Sort[]; /** status: active | draft */ status: TimelineStatus; /** updated saved object timestamp */ updated?: number; /** timeline is saving */ isSaving: boolean; - isLoading: boolean; version: string | null; -} +}; export type SubsetTimelineModel = Readonly< Pick< diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index 1c65c01a0bdfc8..8a5c8546d3834b 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -7,6 +7,7 @@ import { cloneDeep } from 'lodash/fp'; import { + ColumnHeaderOptions, TimelineType, TimelineStatus, TimelineTabs, @@ -47,7 +48,7 @@ import { upsertTimelineColumn, updateGraphEventId, } from './helpers'; -import { ColumnHeaderOptions, TimelineModel } from './model'; +import { TimelineModel } from './model'; import { timelineDefaults } from './defaults'; import { TimelineById } from './types'; import { Direction } from '../../../../common/search_strategy'; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 80c6d830757190..656784c330e45b 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -13,32 +13,22 @@ import { addNoteToEvent, addProvider, addTimeline, - applyDeltaToColumnWidth, applyKqlFilterQuery, - clearEventsDeleted, - clearEventsLoading, - clearSelected, createTimeline, dataProviderEdited, endTimelineSaving, pinEvent, - removeColumn, removeProvider, - setEventsDeleted, setActiveTabTimeline, - setEventsLoading, setExcludedRowRendererIds, setFilters, setInsertTimeline, setSavedQueryId, - setSelected, showCallOutUnauthorizedMsg, showTimeline, startTimelineSaving, - toggleDetailPanel, unPinEvent, updateAutoSaveMsg, - updateColumns, updateDataProviderEnabled, updateDataProviderExcluded, updateDataProviderKqlQuery, @@ -47,18 +37,13 @@ import { updateIndexNames, updateIsFavorite, updateIsLive, - updateIsLoading, - updateItemsPerPage, - updateItemsPerPageOptions, updateKqlMode, updatePageIndex, updateProviders, updateRange, - updateSort, updateTimeline, updateTimelineGraphEventId, updateTitleAndDescription, - upsertColumn, toggleModalSaveTimeline, updateEqlOptions, } from './actions'; @@ -69,23 +54,15 @@ import { addTimelineNoteToEvent, addTimelineProvider, addTimelineToStore, - applyDeltaToTimelineColumnWidth, applyKqlFilterQueryDraft, pinTimelineEvent, - removeTimelineColumn, removeTimelineProvider, - setDeletedTimelineEvents, - setLoadingTimelineEvents, - setSelectedTimelineEvents, unPinTimelineEvent, updateExcludedRowRenderersIds, - updateTimelineColumns, updateTimelineIsFavorite, updateTimelineIsLive, - updateTimelineItemsPerPage, updateTimelineKqlMode, updateTimelinePageIndex, - updateTimelinePerPageOptions, updateTimelineProviderEnabled, updateTimelineProviderExcluded, updateTimelineProviderProperties, @@ -94,13 +71,10 @@ import { updateTimelineProviders, updateTimelineRange, updateTimelineShowTimeline, - updateTimelineSort, updateTimelineTitleAndDescription, - upsertTimelineColumn, updateSavedQuery, updateGraphEventId, updateFilters, - updateTimelineDetailsPanel, updateTimelineEventType, } from './helpers'; @@ -123,53 +97,17 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: addTimelineToStore({ id, timeline, timelineById: state.timelineById }), })) - .case( - createTimeline, - ( - state, - { + .case(createTimeline, (state, { id, timelineType = TimelineType.default, ...timelineProps }) => { + return { + ...state, + timelineById: addNewTimeline({ id, - dataProviders, - dateRange, - excludedRowRendererIds, - expandedDetail = {}, - show, - columns, - itemsPerPage, - indexNames, - kqlQuery, - sort, - showCheckboxes, - timelineType = TimelineType.default, - filters, - } - ) => { - return { - ...state, - timelineById: addNewTimeline({ - columns, - dataProviders, - dateRange, - excludedRowRendererIds, - expandedDetail, - filters, - id, - itemsPerPage, - indexNames, - kqlQuery, - sort, - show, - showCheckboxes, - timelineById: state.timelineById, - timelineType, - }), - }; - } - ) - .case(upsertColumn, (state, { column, id, index }) => ({ - ...state, - timelineById: upsertTimelineColumn({ column, id, index, timelineById: state.timelineById }), - })) + timelineById: state.timelineById, + timelineType, + ...timelineProps, + }), + }; + }) .case(addHistory, (state, { id, historyId }) => ({ ...state, timelineById: addTimelineHistory({ id, historyId, timelineById: state.timelineById }), @@ -182,19 +120,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: addTimelineNoteToEvent({ id, noteId, eventId, timelineById: state.timelineById }), })) - .case(toggleDetailPanel, (state, action) => ({ - ...state, - timelineById: { - ...state.timelineById, - [action.timelineId]: { - ...state.timelineById[action.timelineId], - expandedDetail: { - ...state.timelineById[action.timelineId].expandedDetail, - ...updateTimelineDetailsPanel(action), - }, - }, - }, - })) .case(addProvider, (state, { id, provider }) => ({ ...state, timelineById: addTimelineProvider({ id, provider, timelineById: state.timelineById }), @@ -215,27 +140,10 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: updateGraphEventId({ id, graphEventId, timelineById: state.timelineById }), })) - .case(applyDeltaToColumnWidth, (state, { id, columnId, delta }) => ({ - ...state, - timelineById: applyDeltaToTimelineColumnWidth({ - id, - columnId, - delta, - timelineById: state.timelineById, - }), - })) .case(pinEvent, (state, { id, eventId }) => ({ ...state, timelineById: pinTimelineEvent({ id, eventId, timelineById: state.timelineById }), })) - .case(removeColumn, (state, { id, columnId }) => ({ - ...state, - timelineById: removeTimelineColumn({ - id, - columnId, - timelineById: state.timelineById, - }), - })) .case(removeProvider, (state, { id, providerId, andProviderId }) => ({ ...state, timelineById: removeTimelineProvider({ @@ -265,44 +173,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }, }, })) - .case(setEventsDeleted, (state, { id, eventIds, isDeleted }) => ({ - ...state, - timelineById: setDeletedTimelineEvents({ - id, - eventIds, - timelineById: state.timelineById, - isDeleted, - }), - })) - .case(clearEventsDeleted, (state, { id }) => ({ - ...state, - timelineById: { - ...state.timelineById, - [id]: { - ...state.timelineById[id], - deletedEventIds: [], - }, - }, - })) - .case(setEventsLoading, (state, { id, eventIds, isLoading }) => ({ - ...state, - timelineById: setLoadingTimelineEvents({ - id, - eventIds, - timelineById: state.timelineById, - isLoading, - }), - })) - .case(clearEventsLoading, (state, { id }) => ({ - ...state, - timelineById: { - ...state.timelineById, - [id]: { - ...state.timelineById[id], - loadingEventIds: [], - }, - }, - })) .case(setExcludedRowRendererIds, (state, { id, excludedRowRendererIds }) => ({ ...state, timelineById: updateExcludedRowRenderersIds({ @@ -311,37 +181,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineById: state.timelineById, }), })) - .case(setSelected, (state, { id, eventIds, isSelected, isSelectAllChecked }) => ({ - ...state, - timelineById: setSelectedTimelineEvents({ - id, - eventIds, - timelineById: state.timelineById, - isSelected, - isSelectAllChecked, - }), - })) - .case(clearSelected, (state, { id }) => ({ - ...state, - timelineById: { - ...state.timelineById, - [id]: { - ...state.timelineById[id], - selectedEventIds: {}, - isSelectAllChecked: false, - }, - }, - })) - .case(updateIsLoading, (state, { id, isLoading }) => ({ - ...state, - timelineById: { - ...state.timelineById, - [id]: { - ...state.timelineById[id], - isLoading, - }, - }, - })) .case(updateTimeline, (state, { id, timeline }) => ({ ...state, timelineById: { @@ -353,14 +192,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: unPinTimelineEvent({ id, eventId, timelineById: state.timelineById }), })) - .case(updateColumns, (state, { id, columns }) => ({ - ...state, - timelineById: updateTimelineColumns({ - id, - columns, - timelineById: state.timelineById, - }), - })) .case(updateEventType, (state, { id, eventType }) => ({ ...state, timelineById: updateTimelineEventType({ id, eventType, timelineById: state.timelineById }), @@ -394,10 +225,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: updateTimelineRange({ id, start, end, timelineById: state.timelineById }), })) - .case(updateSort, (state, { id, sort }) => ({ - ...state, - timelineById: updateTimelineSort({ id, sort, timelineById: state.timelineById }), - })) .case(updateDataProviderEnabled, (state, { id, enabled, providerId, andProviderId }) => ({ ...state, timelineById: updateTimelineProviderEnabled({ @@ -454,14 +281,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineById: state.timelineById, }), })) - .case(updateItemsPerPage, (state, { id, itemsPerPage }) => ({ - ...state, - timelineById: updateTimelineItemsPerPage({ - id, - itemsPerPage, - timelineById: state.timelineById, - }), - })) .case(updatePageIndex, (state, { id, activePage }) => ({ ...state, timelineById: updateTimelinePageIndex({ @@ -470,14 +289,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineById: state.timelineById, }), })) - .case(updateItemsPerPageOptions, (state, { id, itemsPerPageOptions }) => ({ - ...state, - timelineById: updateTimelinePerPageOptions({ - id, - itemsPerPageOptions, - timelineById: state.timelineById, - }), - })) .case(updateAutoSaveMsg, (state, { timelineId, newTimelineModel }) => ({ ...state, autoSavedWarningMsg: { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts index b05e6568be6c3f..f46b55bcd33455 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts @@ -7,11 +7,14 @@ import { createSelector } from 'reselect'; +import { tGridSelectors } from '../../../../../timelines/public'; import { State } from '../../../common/store/types'; import { TimelineModel } from './model'; import { AutoSavedWarningMsg, InsertTimeline, TimelineById } from './types'; +export const { getManageTimelineById } = tGridSelectors; + const selectTimelineById = (state: State): TimelineById => state.timeline.timelineById; const selectAutoSaveMsg = (state: State): AutoSavedWarningMsg => state.timeline.autoSavedWarningMsg; diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index d4e26015541870..aad685f9fb1036 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -23,6 +23,7 @@ import { } from '../../triggers_actions_ui/public'; import { CasesUiStart } from '../../cases/public'; import { SecurityPluginSetup } from '../../security/public'; +import { TimelinesUIStart } from '../../timelines/public'; import { ResolverPluginSetup } from './resolver/types'; import { Inspect } from '../common/search_strategy'; import { MlPluginSetup, MlPluginStart } from '../../ml/public'; @@ -56,6 +57,7 @@ export interface StartPlugins { licensing: LicensingPluginStart; newsfeed?: NewsfeedPublicPluginStart; triggersActionsUi: TriggersActionsStart; + timelines: TimelinesUIStart; uiActions: UiActionsStart; ml?: MlPluginStart; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/service.ts index 20b29694a1df12..1a8b17bf19e18f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/service.ts @@ -56,7 +56,6 @@ export const getAuditLogResponse = async ({ context: SecuritySolutionRequestHandlerContext; logger: Logger; }): Promise<{ - total: number; page: number; pageSize: number; data: Array<{ @@ -96,10 +95,6 @@ export const getAuditLogResponse = async ({ } return { - total: - typeof result.body.hits.total === 'number' - ? result.body.hits.total - : result.body.hits.total.value, page, pageSize, data: result.body.hits.hits.map((e) => ({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/__mocks__/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/__mocks__/eql.ts new file mode 100644 index 00000000000000..76389d7376fc89 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/__mocks__/eql.ts @@ -0,0 +1,791 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EqlSearchStrategyResponse } from '../../../../../../../../src/plugins/data/common'; +import { EqlSearchResponse } from '../../../../../common/detection_engine/types'; + +export const sequenceResponse = ({ + rawResponse: { + body: { + is_partial: false, + is_running: false, + took: 527, + timed_out: false, + hits: { + total: { + value: 10, + relation: 'eq', + }, + sequences: [ + { + join_keys: ['win2019-endpoint-mr-pedro'], + events: [ + { + _index: '.ds-logs-endpoint.events.security-default-2021.02.05-000005', + _id: 'qhymg3cBX5UUcOOYP3Ec', + _source: { + agent: { + id: '1d15cf9e-3dc7-5b97-f586-743f7c2518b2', + type: 'endpoint', + version: '7.10.0', + }, + process: { + Ext: { + ancestry: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTIzODAtMTMyNTUwNzg2ODkuOTY1Nzg1NTAw', + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU2OC0xMzI1NTA3ODY2Ny4zMjk3MDY2MDA=', + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQ2OC0xMzI1NTA3ODY2NS42Mzg5MzY1MDA=', + ], + }, + name: 'C:\\Program Files\\OpenSSH-Win64\\sshd.exe', + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTUyODQtMTMyNTcyOTQ2MjMuOTk2NTkxMDAw', + executable: 'C:\\Program Files\\OpenSSH-Win64\\sshd.exe', + }, + message: 'Endpoint security event', + '@timestamp': '2021-02-08T21:50:28.3377092Z', + ecs: { + version: '1.5.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.security', + }, + elastic: { + agent: { + id: 'f5dec71e-438c-424e-ac9b-0281f10412b9', + }, + }, + host: { + hostname: 'win2019-endpoint-mr-pedro', + os: { + Ext: { + variant: 'Windows Server 2019 Datacenter', + }, + kernel: '1809 (10.0.17763.1697)', + name: 'Windows', + family: 'windows', + version: '1809 (10.0.17763.1697)', + platform: 'windows', + full: 'Windows Server 2019 Datacenter 1809 (10.0.17763.1697)', + }, + ip: ['10.128.0.57', 'fe80::9ced:8f1c:880b:3e1f', '127.0.0.1', '::1'], + name: 'win2019-endpoint-mr-pedro', + id: 'd8ad572e-d224-4044-a57d-f5a84c0dfe5d', + mac: ['42:01:0a:80:00:39'], + architecture: 'x86_64', + }, + event: { + sequence: 3293866, + ingested: '2021-02-08T21:57:26.417559711Z', + created: '2021-02-08T21:50:28.3377092Z', + kind: 'event', + module: 'endpoint', + action: 'log_on', + id: 'LzzWB9jjGmCwGMvk++++FG/O', + category: ['authentication', 'session'], + type: ['start'], + dataset: 'endpoint.events.security', + outcome: 'success', + }, + user: { + domain: 'NT AUTHORITY', + name: 'SYSTEM', + }, + }, + }, + { + _index: '.ds-logs-endpoint.events.security-default-2021.02.05-000005', + _id: 'qxymg3cBX5UUcOOYP3Ec', + _source: { + agent: { + id: '1d15cf9e-3dc7-5b97-f586-743f7c2518b2', + type: 'endpoint', + version: '7.10.0', + }, + process: { + Ext: { + ancestry: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQ2OC0xMzI1NTA3ODY2NS42Mzg5MzY1MDA=', + ], + }, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU4MC0xMzI1NTA3ODY2Ny45MTg5Njc1MDA=', + executable: 'C:\\Windows\\System32\\lsass.exe', + }, + message: 'Endpoint security event', + '@timestamp': '2021-02-08T21:50:28.3377142Z', + ecs: { + version: '1.5.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.security', + }, + elastic: { + agent: { + id: 'f5dec71e-438c-424e-ac9b-0281f10412b9', + }, + }, + host: { + hostname: 'win2019-endpoint-mr-pedro', + os: { + Ext: { + variant: 'Windows Server 2019 Datacenter', + }, + kernel: '1809 (10.0.17763.1697)', + name: 'Windows', + family: 'windows', + version: '1809 (10.0.17763.1697)', + platform: 'windows', + full: 'Windows Server 2019 Datacenter 1809 (10.0.17763.1697)', + }, + ip: ['10.128.0.57', 'fe80::9ced:8f1c:880b:3e1f', '127.0.0.1', '::1'], + name: 'win2019-endpoint-mr-pedro', + id: 'd8ad572e-d224-4044-a57d-f5a84c0dfe5d', + mac: ['42:01:0a:80:00:39'], + architecture: 'x86_64', + }, + event: { + sequence: 3293867, + ingested: '2021-02-08T21:57:26.417596906Z', + created: '2021-02-08T21:50:28.3377142Z', + kind: 'event', + module: 'endpoint', + action: 'log_on', + id: 'LzzWB9jjGmCwGMvk++++FG/P', + category: ['authentication', 'session'], + type: ['start'], + dataset: 'endpoint.events.security', + outcome: 'success', + }, + user: { + domain: 'NT AUTHORITY', + name: 'SYSTEM', + }, + }, + }, + { + _index: '.ds-logs-endpoint.events.security-default-2021.02.05-000005', + _id: 'rBymg3cBX5UUcOOYP3Ec', + _source: { + agent: { + id: '1d15cf9e-3dc7-5b97-f586-743f7c2518b2', + type: 'endpoint', + version: '7.10.0', + }, + process: { + Ext: { + ancestry: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQ2OC0xMzI1NTA3ODY2NS42Mzg5MzY1MDA=', + ], + }, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU4MC0xMzI1NTA3ODY2Ny45MTg5Njc1MDA=', + executable: 'C:\\Windows\\System32\\lsass.exe', + }, + message: 'Endpoint security event', + '@timestamp': '2021-02-08T21:50:28.3381013Z', + ecs: { + version: '1.5.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.security', + }, + elastic: { + agent: { + id: 'f5dec71e-438c-424e-ac9b-0281f10412b9', + }, + }, + host: { + hostname: 'win2019-endpoint-mr-pedro', + os: { + Ext: { + variant: 'Windows Server 2019 Datacenter', + }, + kernel: '1809 (10.0.17763.1697)', + name: 'Windows', + family: 'windows', + version: '1809 (10.0.17763.1697)', + platform: 'windows', + full: 'Windows Server 2019 Datacenter 1809 (10.0.17763.1697)', + }, + ip: ['10.128.0.57', 'fe80::9ced:8f1c:880b:3e1f', '127.0.0.1', '::1'], + name: 'win2019-endpoint-mr-pedro', + id: 'd8ad572e-d224-4044-a57d-f5a84c0dfe5d', + mac: ['42:01:0a:80:00:39'], + architecture: 'x86_64', + }, + event: { + sequence: 3293868, + ingested: '2021-02-08T21:57:26.417632166Z', + created: '2021-02-08T21:50:28.3381013Z', + kind: 'event', + module: 'endpoint', + id: 'LzzWB9jjGmCwGMvk++++FG/Q', + category: [], + type: [], + dataset: 'endpoint.events.security', + }, + user: { + domain: 'NT AUTHORITY', + name: 'SYSTEM', + }, + }, + }, + ], + }, + { + join_keys: ['win2019-endpoint-mr-pedro'], + events: [ + { + _index: '.ds-logs-endpoint.events.security-default-2021.02.05-000005', + _id: 'qxymg3cBX5UUcOOYP3Ec', + _source: { + agent: { + id: '1d15cf9e-3dc7-5b97-f586-743f7c2518b2', + type: 'endpoint', + version: '7.10.0', + }, + process: { + Ext: { + ancestry: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQ2OC0xMzI1NTA3ODY2NS42Mzg5MzY1MDA=', + ], + }, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU4MC0xMzI1NTA3ODY2Ny45MTg5Njc1MDA=', + executable: 'C:\\Windows\\System32\\lsass.exe', + }, + message: 'Endpoint security event', + '@timestamp': '2021-02-08T21:50:28.3377142Z', + ecs: { + version: '1.5.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.security', + }, + elastic: { + agent: { + id: 'f5dec71e-438c-424e-ac9b-0281f10412b9', + }, + }, + host: { + hostname: 'win2019-endpoint-mr-pedro', + os: { + Ext: { + variant: 'Windows Server 2019 Datacenter', + }, + kernel: '1809 (10.0.17763.1697)', + name: 'Windows', + family: 'windows', + version: '1809 (10.0.17763.1697)', + platform: 'windows', + full: 'Windows Server 2019 Datacenter 1809 (10.0.17763.1697)', + }, + ip: ['10.128.0.57', 'fe80::9ced:8f1c:880b:3e1f', '127.0.0.1', '::1'], + name: 'win2019-endpoint-mr-pedro', + id: 'd8ad572e-d224-4044-a57d-f5a84c0dfe5d', + mac: ['42:01:0a:80:00:39'], + architecture: 'x86_64', + }, + event: { + sequence: 3293867, + ingested: '2021-02-08T21:57:26.417596906Z', + created: '2021-02-08T21:50:28.3377142Z', + kind: 'event', + module: 'endpoint', + action: 'log_on', + id: 'LzzWB9jjGmCwGMvk++++FG/P', + category: ['authentication', 'session'], + type: ['start'], + dataset: 'endpoint.events.security', + outcome: 'success', + }, + user: { + domain: 'NT AUTHORITY', + name: 'SYSTEM', + }, + }, + }, + { + _index: '.ds-logs-endpoint.events.security-default-2021.02.05-000005', + _id: 'rBymg3cBX5UUcOOYP3Ec', + _source: { + agent: { + id: '1d15cf9e-3dc7-5b97-f586-743f7c2518b2', + type: 'endpoint', + version: '7.10.0', + }, + process: { + Ext: { + ancestry: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQ2OC0xMzI1NTA3ODY2NS42Mzg5MzY1MDA=', + ], + }, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU4MC0xMzI1NTA3ODY2Ny45MTg5Njc1MDA=', + executable: 'C:\\Windows\\System32\\lsass.exe', + }, + message: 'Endpoint security event', + '@timestamp': '2021-02-08T21:50:28.3381013Z', + ecs: { + version: '1.5.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.security', + }, + elastic: { + agent: { + id: 'f5dec71e-438c-424e-ac9b-0281f10412b9', + }, + }, + host: { + hostname: 'win2019-endpoint-mr-pedro', + os: { + Ext: { + variant: 'Windows Server 2019 Datacenter', + }, + kernel: '1809 (10.0.17763.1697)', + name: 'Windows', + family: 'windows', + version: '1809 (10.0.17763.1697)', + platform: 'windows', + full: 'Windows Server 2019 Datacenter 1809 (10.0.17763.1697)', + }, + ip: ['10.128.0.57', 'fe80::9ced:8f1c:880b:3e1f', '127.0.0.1', '::1'], + name: 'win2019-endpoint-mr-pedro', + id: 'd8ad572e-d224-4044-a57d-f5a84c0dfe5d', + mac: ['42:01:0a:80:00:39'], + architecture: 'x86_64', + }, + event: { + sequence: 3293868, + ingested: '2021-02-08T21:57:26.417632166Z', + created: '2021-02-08T21:50:28.3381013Z', + kind: 'event', + module: 'endpoint', + id: 'LzzWB9jjGmCwGMvk++++FG/Q', + category: [], + type: [], + dataset: 'endpoint.events.security', + }, + user: { + domain: 'NT AUTHORITY', + name: 'SYSTEM', + }, + }, + }, + { + _index: '.ds-logs-endpoint.events.process-default-2021.02.02-000005', + _id: 'pxymg3cBX5UUcOOYP3Ec', + _source: { + agent: { + id: '1d15cf9e-3dc7-5b97-f586-743f7c2518b2', + type: 'endpoint', + version: '7.10.0', + }, + process: { + Ext: { + ancestry: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTUyODQtMTMyNTcyOTQ2MjMuOTk2NTkxMDAw', + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTIzODAtMTMyNTUwNzg2ODkuOTY1Nzg1NTAw', + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU2OC0xMzI1NTA3ODY2Ny4zMjk3MDY2MDA=', + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQ2OC0xMzI1NTA3ODY2NS42Mzg5MzY1MDA=', + ], + code_signature: [ + { + trusted: true, + subject_name: 'Microsoft Corporation', + exists: true, + status: 'trusted', + }, + ], + token: { + integrity_level_name: 'high', + elevation_level: 'default', + }, + }, + args: ['C:\\Program Files\\OpenSSH-Win64\\sshd.exe', '-y'], + parent: { + args: ['C:\\Program Files\\OpenSSH-Win64\\sshd.exe', '-R'], + name: 'sshd.exe', + pid: 5284, + args_count: 2, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTUyODQtMTMyNTcyOTQ2MjMuOTk2NTkxMDAw', + command_line: '"C:\\Program Files\\OpenSSH-Win64\\sshd.exe" -R', + executable: 'C:\\Program Files\\OpenSSH-Win64\\sshd.exe', + }, + code_signature: { + trusted: true, + subject_name: 'Microsoft Corporation', + exists: true, + status: 'trusted', + }, + name: 'sshd.exe', + pid: 6368, + args_count: 2, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTYzNjgtMTMyNTcyOTQ2MjguMzQ0NjM1NTAw', + command_line: '"C:\\Program Files\\OpenSSH-Win64\\sshd.exe" -y', + executable: 'C:\\Program Files\\OpenSSH-Win64\\sshd.exe', + hash: { + sha1: '631244d731f406394c17c7dfd85203e317c74814', + sha256: 'e6a972f9db27de18be225095b3b3141b945be8aadc4014c8704ae5acafe3e8e0', + md5: '331ba0e529810ef718dd3efbd1242302', + }, + }, + message: 'Endpoint process event', + '@timestamp': '2021-02-08T21:50:28.3446355Z', + ecs: { + version: '1.5.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.process', + }, + elastic: { + agent: { + id: 'f5dec71e-438c-424e-ac9b-0281f10412b9', + }, + }, + host: { + hostname: 'win2019-endpoint-mr-pedro', + os: { + Ext: { + variant: 'Windows Server 2019 Datacenter', + }, + kernel: '1809 (10.0.17763.1697)', + name: 'Windows', + family: 'windows', + version: '1809 (10.0.17763.1697)', + platform: 'windows', + full: 'Windows Server 2019 Datacenter 1809 (10.0.17763.1697)', + }, + ip: ['10.128.0.57', 'fe80::9ced:8f1c:880b:3e1f', '127.0.0.1', '::1'], + name: 'win2019-endpoint-mr-pedro', + id: 'd8ad572e-d224-4044-a57d-f5a84c0dfe5d', + mac: ['42:01:0a:80:00:39'], + architecture: 'x86_64', + }, + event: { + sequence: 3293863, + ingested: '2021-02-08T21:57:26.417387865Z', + created: '2021-02-08T21:50:28.3446355Z', + kind: 'event', + module: 'endpoint', + action: 'start', + id: 'LzzWB9jjGmCwGMvk++++FG/K', + category: ['process'], + type: ['start'], + dataset: 'endpoint.events.process', + }, + user: { + domain: '', + name: '', + }, + }, + }, + ], + }, + { + join_keys: ['win2019-endpoint-mr-pedro'], + events: [ + { + _index: '.ds-logs-endpoint.events.security-default-2021.02.05-000005', + _id: 'rBymg3cBX5UUcOOYP3Ec', + _source: { + agent: { + id: '1d15cf9e-3dc7-5b97-f586-743f7c2518b2', + type: 'endpoint', + version: '7.10.0', + }, + process: { + Ext: { + ancestry: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQ2OC0xMzI1NTA3ODY2NS42Mzg5MzY1MDA=', + ], + }, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU4MC0xMzI1NTA3ODY2Ny45MTg5Njc1MDA=', + executable: 'C:\\Windows\\System32\\lsass.exe', + }, + message: 'Endpoint security event', + '@timestamp': '2021-02-08T21:50:28.3381013Z', + ecs: { + version: '1.5.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.security', + }, + elastic: { + agent: { + id: 'f5dec71e-438c-424e-ac9b-0281f10412b9', + }, + }, + host: { + hostname: 'win2019-endpoint-mr-pedro', + os: { + Ext: { + variant: 'Windows Server 2019 Datacenter', + }, + kernel: '1809 (10.0.17763.1697)', + name: 'Windows', + family: 'windows', + version: '1809 (10.0.17763.1697)', + platform: 'windows', + full: 'Windows Server 2019 Datacenter 1809 (10.0.17763.1697)', + }, + ip: ['10.128.0.57', 'fe80::9ced:8f1c:880b:3e1f', '127.0.0.1', '::1'], + name: 'win2019-endpoint-mr-pedro', + id: 'd8ad572e-d224-4044-a57d-f5a84c0dfe5d', + mac: ['42:01:0a:80:00:39'], + architecture: 'x86_64', + }, + event: { + sequence: 3293868, + ingested: '2021-02-08T21:57:26.417632166Z', + created: '2021-02-08T21:50:28.3381013Z', + kind: 'event', + module: 'endpoint', + id: 'LzzWB9jjGmCwGMvk++++FG/Q', + category: [], + type: [], + dataset: 'endpoint.events.security', + }, + user: { + domain: 'NT AUTHORITY', + name: 'SYSTEM', + }, + }, + }, + { + _index: '.ds-logs-endpoint.events.process-default-2021.02.02-000005', + _id: 'pxymg3cBX5UUcOOYP3Ec', + _source: { + agent: { + id: '1d15cf9e-3dc7-5b97-f586-743f7c2518b2', + type: 'endpoint', + version: '7.10.0', + }, + process: { + Ext: { + ancestry: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTUyODQtMTMyNTcyOTQ2MjMuOTk2NTkxMDAw', + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTIzODAtMTMyNTUwNzg2ODkuOTY1Nzg1NTAw', + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU2OC0xMzI1NTA3ODY2Ny4zMjk3MDY2MDA=', + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQ2OC0xMzI1NTA3ODY2NS42Mzg5MzY1MDA=', + ], + code_signature: [ + { + trusted: true, + subject_name: 'Microsoft Corporation', + exists: true, + status: 'trusted', + }, + ], + token: { + integrity_level_name: 'high', + elevation_level: 'default', + }, + }, + args: ['C:\\Program Files\\OpenSSH-Win64\\sshd.exe', '-y'], + parent: { + args: ['C:\\Program Files\\OpenSSH-Win64\\sshd.exe', '-R'], + name: 'sshd.exe', + pid: 5284, + args_count: 2, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTUyODQtMTMyNTcyOTQ2MjMuOTk2NTkxMDAw', + command_line: '"C:\\Program Files\\OpenSSH-Win64\\sshd.exe" -R', + executable: 'C:\\Program Files\\OpenSSH-Win64\\sshd.exe', + }, + code_signature: { + trusted: true, + subject_name: 'Microsoft Corporation', + exists: true, + status: 'trusted', + }, + name: 'sshd.exe', + pid: 6368, + args_count: 2, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTYzNjgtMTMyNTcyOTQ2MjguMzQ0NjM1NTAw', + command_line: '"C:\\Program Files\\OpenSSH-Win64\\sshd.exe" -y', + executable: 'C:\\Program Files\\OpenSSH-Win64\\sshd.exe', + hash: { + sha1: '631244d731f406394c17c7dfd85203e317c74814', + sha256: 'e6a972f9db27de18be225095b3b3141b945be8aadc4014c8704ae5acafe3e8e0', + md5: '331ba0e529810ef718dd3efbd1242302', + }, + }, + message: 'Endpoint process event', + '@timestamp': '2021-02-08T21:50:28.3446355Z', + ecs: { + version: '1.5.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.process', + }, + elastic: { + agent: { + id: 'f5dec71e-438c-424e-ac9b-0281f10412b9', + }, + }, + host: { + hostname: 'win2019-endpoint-mr-pedro', + os: { + Ext: { + variant: 'Windows Server 2019 Datacenter', + }, + kernel: '1809 (10.0.17763.1697)', + name: 'Windows', + family: 'windows', + version: '1809 (10.0.17763.1697)', + platform: 'windows', + full: 'Windows Server 2019 Datacenter 1809 (10.0.17763.1697)', + }, + ip: ['10.128.0.57', 'fe80::9ced:8f1c:880b:3e1f', '127.0.0.1', '::1'], + name: 'win2019-endpoint-mr-pedro', + id: 'd8ad572e-d224-4044-a57d-f5a84c0dfe5d', + mac: ['42:01:0a:80:00:39'], + architecture: 'x86_64', + }, + event: { + sequence: 3293863, + ingested: '2021-02-08T21:57:26.417387865Z', + created: '2021-02-08T21:50:28.3446355Z', + kind: 'event', + module: 'endpoint', + action: 'start', + id: 'LzzWB9jjGmCwGMvk++++FG/K', + category: ['process'], + type: ['start'], + dataset: 'endpoint.events.process', + }, + user: { + domain: '', + name: '', + }, + }, + }, + { + _index: '.ds-logs-endpoint.events.network-default-2021.02.02-000005', + _id: 'qBymg3cBX5UUcOOYP3Ec', + _source: { + agent: { + id: '1d15cf9e-3dc7-5b97-f586-743f7c2518b2', + type: 'endpoint', + version: '7.10.0', + }, + process: { + Ext: { + ancestry: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTU2OC0xMzI1NTA3ODY2Ny4zMjk3MDY2MDA=', + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTQ2OC0xMzI1NTA3ODY2NS42Mzg5MzY1MDA=', + ], + }, + name: 'svchost.exe', + pid: 968, + entity_id: + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTk2OC0xMzI1NTA3ODY3My4yNjQyNDcyMDA=', + executable: 'C:\\Windows\\System32\\svchost.exe', + }, + destination: { + address: '10.128.0.57', + port: 3389, + bytes: 1681, + ip: '10.128.0.57', + }, + source: { + address: '142.202.189.139', + port: 16151, + bytes: 1224, + ip: '142.202.189.139', + }, + message: 'Endpoint network event', + network: { + transport: 'tcp', + type: 'ipv4', + direction: 'incoming', + }, + '@timestamp': '2021-02-08T21:50:28.5553532Z', + ecs: { + version: '1.5.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.network', + }, + elastic: { + agent: { + id: 'f5dec71e-438c-424e-ac9b-0281f10412b9', + }, + }, + host: { + hostname: 'win2019-endpoint-mr-pedro', + os: { + Ext: { + variant: 'Windows Server 2019 Datacenter', + }, + kernel: '1809 (10.0.17763.1697)', + name: 'Windows', + family: 'windows', + version: '1809 (10.0.17763.1697)', + platform: 'windows', + full: 'Windows Server 2019 Datacenter 1809 (10.0.17763.1697)', + }, + ip: ['10.128.0.57', 'fe80::9ced:8f1c:880b:3e1f', '127.0.0.1', '::1'], + name: 'win2019-endpoint-mr-pedro', + id: 'd8ad572e-d224-4044-a57d-f5a84c0dfe5d', + mac: ['42:01:0a:80:00:39'], + architecture: 'x86_64', + }, + event: { + sequence: 3293864, + ingested: '2021-02-08T21:57:26.417451347Z', + created: '2021-02-08T21:50:28.5553532Z', + kind: 'event', + module: 'endpoint', + action: 'disconnect_received', + id: 'LzzWB9jjGmCwGMvk++++FG/L', + category: ['network'], + type: ['end'], + dataset: 'endpoint.events.network', + }, + user: { + domain: 'NT AUTHORITY', + name: 'NETWORK SERVICE', + }, + }, + }, + ], + }, + ], + }, + }, + statusCode: 200, + headers: {}, + meta: {}, + hits: {}, + }, +} as unknown) as EqlSearchStrategyResponse>; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/eql.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/eql.test.ts index 6529c594dd5a51..da5c89a3102a1c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/eql.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/eql.test.ts @@ -7,10 +7,8 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; - -import { sequenceResponse } from '../../../search_strategy/timeline/eql/__mocks__'; - import { createEqlAlertType } from './eql'; +import { sequenceResponse } from './__mocks__/eql'; import { createRuleTypeMocks } from './__mocks__/rule_type'; describe('EQL alerts', () => { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/pick_saved_timeline.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/pick_saved_timeline.test.ts index 94e70e4eb001bb..3a37a49d03dcd2 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/pick_saved_timeline.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/pick_saved_timeline.test.ts @@ -7,13 +7,20 @@ import { AuthenticatedUser } from '../../../../../../security/common/model'; -import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType, SavedTimeline } from '../../../../../common/types/timeline'; +import { NoteSavedObject } from '../../../../../common/types/timeline/note'; import { pickSavedTimeline } from './pick_saved_timeline'; describe('pickSavedTimeline', () => { const mockDateNow = new Date('2020-04-03T23:00:00.000Z').valueOf(); - const getMockSavedTimeline = () => ({ + const getMockSavedTimeline = (): SavedTimeline & { + savedObjectId?: string | null; + version?: string; + eventNotes?: NoteSavedObject[]; + globalNotes?: NoteSavedObject[]; + pinnedEventIds?: []; + } => ({ savedObjectId: '7af80430-03f4-11eb-9d9d-ffba20fabba8', version: 'WzQ0ODgsMV0=', created: 1601563413330, @@ -91,7 +98,7 @@ describe('pickSavedTimeline', () => { test('Updating a timeline', () => { const savedTimeline = getMockSavedTimeline(); - const timelineId = savedTimeline.savedObjectId; + const timelineId = savedTimeline.savedObjectId ?? null; const userInfo = { username: 'elastic' } as AuthenticatedUser; const result = pickSavedTimeline(timelineId, savedTimeline, userInfo); @@ -113,7 +120,7 @@ describe('pickSavedTimeline', () => { test('Updating a timeline', () => { const savedTimeline = getMockSavedTimeline(); - const timelineId = savedTimeline.savedObjectId; + const timelineId = savedTimeline.savedObjectId ?? null; const userInfo = { username: 'elastic' } as AuthenticatedUser; const result = pickSavedTimeline(timelineId, savedTimeline, userInfo); @@ -143,7 +150,7 @@ describe('pickSavedTimeline', () => { test('Updating a timeline with a new title', () => { const savedTimeline = getMockSavedTimeline(); - const timelineId = savedTimeline.savedObjectId; + const timelineId = savedTimeline.savedObjectId ?? null; const userInfo = { username: 'elastic' } as AuthenticatedUser; const result = pickSavedTimeline(timelineId, savedTimeline, userInfo); @@ -152,7 +159,7 @@ describe('pickSavedTimeline', () => { test('Updating a timeline without title', () => { const savedTimeline = getMockSavedTimeline(); - const timelineId = savedTimeline.savedObjectId; + const timelineId = savedTimeline.savedObjectId ?? null; const userInfo = { username: 'elastic' } as AuthenticatedUser; const result = pickSavedTimeline(timelineId, savedTimeline, userInfo); @@ -161,7 +168,7 @@ describe('pickSavedTimeline', () => { test('Updating an immutable timeline with a new title', () => { const savedTimeline = { ...getMockSavedTimeline(), status: TimelineStatus.immutable }; - const timelineId = savedTimeline.savedObjectId; + const timelineId = savedTimeline.savedObjectId ?? null; const userInfo = { username: 'elastic' } as AuthenticatedUser; const result = pickSavedTimeline(timelineId, savedTimeline, userInfo); @@ -192,7 +199,7 @@ describe('pickSavedTimeline', () => { test('Updating an untitled draft timeline with a title', () => { const savedTimeline = { ...getMockSavedTimeline(), status: TimelineStatus.draft }; - const timelineId = savedTimeline.savedObjectId; + const timelineId = savedTimeline.savedObjectId ?? null; const userInfo = { username: 'elastic' } as AuthenticatedUser; const result = pickSavedTimeline(timelineId, savedTimeline, userInfo); @@ -201,7 +208,7 @@ describe('pickSavedTimeline', () => { test('Updating a draft timeline with a new title', () => { const savedTimeline = { ...getMockSavedTimeline(), status: TimelineStatus.draft }; - const timelineId = savedTimeline.savedObjectId; + const timelineId = savedTimeline.savedObjectId ?? null; const userInfo = { username: 'elastic' } as AuthenticatedUser; const result = pickSavedTimeline(timelineId, savedTimeline, userInfo); @@ -210,7 +217,7 @@ describe('pickSavedTimeline', () => { test('Updating a draft timeline without title', () => { const savedTimeline = { ...getMockSavedTimeline(), status: TimelineStatus.draft }; - const timelineId = savedTimeline.savedObjectId; + const timelineId = savedTimeline.savedObjectId ?? null; const userInfo = { username: 'elastic' } as AuthenticatedUser; const result = pickSavedTimeline(timelineId, savedTimeline, userInfo); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/pick_saved_timeline.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/pick_saved_timeline.ts index a28084cd78154e..3e00a33966f179 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/pick_saved_timeline.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/pick_saved_timeline.ts @@ -12,10 +12,9 @@ import { SavedTimeline, TimelineType, TimelineStatus } from '../../../../../comm export const pickSavedTimeline = ( timelineId: string | null, - savedTimeline: SavedTimeline, + savedTimeline: SavedTimeline & { savedObjectId?: string | null }, userInfo: AuthenticatedUser | null - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): any => { +): SavedTimeline & { savedObjectId?: string | null } => { const dateNow = new Date().valueOf(); if (timelineId == null) { diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index ac9d854f18211f..4bcbcb71d048c4 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -83,8 +83,6 @@ import { initUsageCollectors } from './usage'; import type { SecuritySolutionRequestHandlerContext } from './types'; import { registerTrustedAppsRoutes } from './endpoint/routes/trusted_apps'; import { securitySolutionSearchStrategyProvider } from './search_strategy/security_solution'; -import { securitySolutionIndexFieldsProvider } from './search_strategy/index_fields'; -import { securitySolutionTimelineSearchStrategyProvider } from './search_strategy/timeline'; import { TelemetryEventsSender } from './lib/telemetry/sender'; import { TelemetryPluginStart, @@ -92,7 +90,6 @@ import { } from '../../../../src/plugins/telemetry/server'; import { licenseService } from './lib/license'; import { PolicyWatcher } from './endpoint/lib/policy/license_watch'; -import { securitySolutionTimelineEqlSearchStrategyProvider } from './search_strategy/timeline/eql'; import { parseExperimentalConfigValue } from '../common/experimental_features'; import { migrateArtifactsToFleet } from './endpoint/lib/artifacts/migrate_artifacts_to_fleet'; @@ -451,30 +448,10 @@ export class Plugin implements IPlugin { - describe('#formatTimelineData', () => { - it('happy path', async () => { - const res = await formatTimelineData( - [ - '@timestamp', - 'host.name', - 'destination.ip', - 'source.ip', - 'source.geo.location', - 'threat.indicator.matched.field', - ], - TIMELINE_EVENTS_FIELDS, - eventHit - ); - expect(res).toEqual({ - cursor: { - tiebreaker: 'beats-ci-immutable-ubuntu-1804-1605624279743236239', - value: '1605624488922', - }, - node: { - _id: 'tkCt1nUBaEgqnrVSZ8R_', - _index: 'auditbeat-7.8.0-2020.11.05-000003', - data: [ - { - field: '@timestamp', - value: ['2020-11-17T14:48:08.922Z'], - }, - { - field: 'host.name', - value: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], - }, - { - field: 'threat.indicator.matched.field', - value: ['matched_field', 'other_matched_field', 'matched_field_2'], - }, - { - field: 'source.geo.location', - value: [`{"lon":118.7778,"lat":32.0617}`], - }, - ], - ecs: { - '@timestamp': ['2020-11-17T14:48:08.922Z'], - _id: 'tkCt1nUBaEgqnrVSZ8R_', - _index: 'auditbeat-7.8.0-2020.11.05-000003', - agent: { - type: ['auditbeat'], - }, - event: { - action: ['process_started'], - category: ['process'], - dataset: ['process'], - kind: ['event'], - module: ['system'], - type: ['start'], - }, - host: { - id: ['e59991e835905c65ed3e455b33e13bd6'], - ip: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], - name: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], - os: { - family: ['debian'], - }, - }, - message: ['Process go (PID: 4313) by user jenkins STARTED'], - process: { - args: ['go', 'vet', './...'], - entity_id: ['Z59cIkAAIw8ZoK0H'], - executable: [ - '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', - ], - hash: { - sha1: ['1eac22336a41e0660fb302add9d97daa2bcc7040'], - }, - name: ['go'], - pid: ['4313'], - ppid: ['3977'], - working_directory: [ - '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', - ], - }, - timestamp: '2020-11-17T14:48:08.922Z', - user: { - name: ['jenkins'], - }, - threat: { - indicator: [ - { - event: { - dataset: [], - reference: [], - }, - matched: { - atomic: ['matched_atomic'], - field: ['matched_field', 'other_matched_field'], - type: [], - }, - provider: ['yourself'], - }, - { - event: { - dataset: [], - reference: [], - }, - matched: { - atomic: ['matched_atomic_2'], - field: ['matched_field_2'], - type: [], - }, - provider: ['other_you'], - }, - ], - }, - }, - }, - }); - }); - - it('rule signal results', async () => { - const response: EventHit = { - _index: '.siem-signals-patrykkopycinski-default-000007', - _id: 'a77040f198355793c35bf22b900902371309be615381f0a2ec92c208b6132562', - _score: 0, - _source: { - signal: { - threshold_result: { - count: 10000, - value: '2a990c11-f61b-4c8e-b210-da2574e9f9db', - }, - parent: { - depth: 0, - index: - 'apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,winlogbeat-*', - id: '0268af90-d8da-576a-9747-2a191519416a', - type: 'event', - }, - depth: 1, - _meta: { - version: 14, - }, - rule: { - note: null, - throttle: null, - references: [], - severity_mapping: [], - description: 'asdasd', - created_at: '2021-01-09T11:25:45.046Z', - language: 'kuery', - threshold: { - field: '', - value: 200, - }, - building_block_type: null, - output_index: '.siem-signals-patrykkopycinski-default', - type: 'threshold', - rule_name_override: null, - enabled: true, - exceptions_list: [], - updated_at: '2021-01-09T13:36:39.204Z', - timestamp_override: null, - from: 'now-360s', - id: '696c24e0-526d-11eb-836c-e1620268b945', - timeline_id: null, - max_signals: 100, - severity: 'low', - risk_score: 21, - risk_score_mapping: [], - author: [], - query: '_id :*', - index: [ - 'apm-*-transaction*', - 'auditbeat-*', - 'endgame-*', - 'filebeat-*', - 'logs-*', - 'packetbeat-*', - 'winlogbeat-*', - ], - filters: [ - { - $state: { - store: 'appState', - }, - meta: { - negate: false, - alias: null, - disabled: false, - type: 'exists', - value: 'exists', - key: '_index', - }, - exists: { - field: '_index', - }, - }, - { - $state: { - store: 'appState', - }, - meta: { - negate: false, - alias: 'id_exists', - disabled: false, - type: 'exists', - value: 'exists', - key: '_id', - }, - exists: { - field: '_id', - }, - }, - ], - created_by: 'patryk_test_user', - version: 1, - saved_id: null, - tags: [], - rule_id: '2a990c11-f61b-4c8e-b210-da2574e9f9db', - license: '', - immutable: false, - timeline_title: null, - meta: { - from: '1m', - kibana_siem_app_url: 'http://localhost:5601/app/security', - }, - name: 'Threshold test', - updated_by: 'patryk_test_user', - interval: '5m', - false_positives: [], - to: 'now', - threat: [], - actions: [], - }, - original_time: '2021-01-09T13:39:32.595Z', - ancestors: [ - { - depth: 0, - index: - 'apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,winlogbeat-*', - id: '0268af90-d8da-576a-9747-2a191519416a', - type: 'event', - }, - ], - parents: [ - { - depth: 0, - index: - 'apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,winlogbeat-*', - id: '0268af90-d8da-576a-9747-2a191519416a', - type: 'event', - }, - ], - status: 'open', - }, - }, - fields: { - 'signal.rule.output_index': ['.siem-signals-patrykkopycinski-default'], - 'signal.rule.from': ['now-360s'], - 'signal.rule.language': ['kuery'], - '@timestamp': ['2021-01-09T13:41:40.517Z'], - 'signal.rule.query': ['_id :*'], - 'signal.rule.type': ['threshold'], - 'signal.rule.id': ['696c24e0-526d-11eb-836c-e1620268b945'], - 'signal.rule.risk_score': [21], - 'signal.status': ['open'], - 'event.kind': ['signal'], - 'signal.original_time': ['2021-01-09T13:39:32.595Z'], - 'signal.rule.severity': ['low'], - 'signal.rule.version': ['1'], - 'signal.rule.index': [ - 'apm-*-transaction*', - 'auditbeat-*', - 'endgame-*', - 'filebeat-*', - 'logs-*', - 'packetbeat-*', - 'winlogbeat-*', - ], - 'signal.rule.name': ['Threshold test'], - 'signal.rule.to': ['now'], - }, - _type: '', - sort: ['1610199700517'], - aggregations: {}, - }; - - expect( - await formatTimelineData( - ['@timestamp', 'host.name', 'destination.ip', 'source.ip'], - TIMELINE_EVENTS_FIELDS, - response - ) - ).toEqual({ - cursor: { - tiebreaker: null, - value: '', - }, - node: { - _id: 'a77040f198355793c35bf22b900902371309be615381f0a2ec92c208b6132562', - _index: '.siem-signals-patrykkopycinski-default-000007', - data: [ - { - field: '@timestamp', - value: ['2021-01-09T13:41:40.517Z'], - }, - ], - ecs: { - '@timestamp': ['2021-01-09T13:41:40.517Z'], - timestamp: '2021-01-09T13:41:40.517Z', - _id: 'a77040f198355793c35bf22b900902371309be615381f0a2ec92c208b6132562', - _index: '.siem-signals-patrykkopycinski-default-000007', - event: { - kind: ['signal'], - }, - signal: { - original_time: ['2021-01-09T13:39:32.595Z'], - status: ['open'], - threshold_result: ['{"count":10000,"value":"2a990c11-f61b-4c8e-b210-da2574e9f9db"}'], - rule: { - building_block_type: [], - exceptions_list: [], - from: ['now-360s'], - id: ['696c24e0-526d-11eb-836c-e1620268b945'], - index: [ - 'apm-*-transaction*', - 'auditbeat-*', - 'endgame-*', - 'filebeat-*', - 'logs-*', - 'packetbeat-*', - 'winlogbeat-*', - ], - language: ['kuery'], - name: ['Threshold test'], - output_index: ['.siem-signals-patrykkopycinski-default'], - risk_score: ['21'], - query: ['_id :*'], - severity: ['low'], - to: ['now'], - type: ['threshold'], - version: ['1'], - timeline_id: [], - timeline_title: [], - saved_id: [], - note: [], - threshold: [ - JSON.stringify({ - field: '', - value: 200, - }), - ], - filters: [ - JSON.stringify({ - $state: { - store: 'appState', - }, - meta: { - negate: false, - alias: null, - disabled: false, - type: 'exists', - value: 'exists', - key: '_index', - }, - exists: { - field: '_index', - }, - }), - JSON.stringify({ - $state: { - store: 'appState', - }, - meta: { - negate: false, - alias: 'id_exists', - disabled: false, - type: 'exists', - value: 'exists', - key: '_id', - }, - exists: { - field: '_id', - }, - }), - ], - }, - }, - }, - }, - }); - }); - }); - - describe('#buildObjectForFieldPath', () => { - it('builds an object from a single non-nested field', () => { - expect(buildObjectForFieldPath('@timestamp', eventHit)).toEqual({ - '@timestamp': ['2020-11-17T14:48:08.922Z'], - }); - }); - - it('builds an object with no fields response', () => { - const { fields, ...fieldLessHit } = eventHit; - // @ts-expect-error fieldLessHit is intentionally missing fields - expect(buildObjectForFieldPath('@timestamp', fieldLessHit)).toEqual({ - '@timestamp': [], - }); - }); - - it('does not misinterpret non-nested fields with a common prefix', () => { - // @ts-expect-error hit is minimal - const hit: EventHit = { - fields: { - 'foo.bar': ['baz'], - 'foo.barBaz': ['foo'], - }, - }; - - expect(buildObjectForFieldPath('foo.barBaz', hit)).toEqual({ - foo: { barBaz: ['foo'] }, - }); - }); - - it('builds an array of objects from a nested field', () => { - // @ts-expect-error hit is minimal - const hit: EventHit = { - fields: { - foo: [{ bar: ['baz'] }], - }, - }; - expect(buildObjectForFieldPath('foo.bar', hit)).toEqual({ - foo: [{ bar: ['baz'] }], - }); - }); - - it('builds intermediate objects for nested fields', () => { - // @ts-expect-error nestedHit is minimal - const nestedHit: EventHit = { - fields: { - 'foo.bar': [ - { - baz: ['host.name'], - }, - ], - }, - }; - expect(buildObjectForFieldPath('foo.bar.baz', nestedHit)).toEqual({ - foo: { - bar: [ - { - baz: ['host.name'], - }, - ], - }, - }); - }); - - it('builds intermediate objects at multiple levels', () => { - expect(buildObjectForFieldPath('threat.indicator.matched.atomic', eventHit)).toEqual({ - threat: { - indicator: [ - { - matched: { - atomic: ['matched_atomic'], - }, - }, - { - matched: { - atomic: ['matched_atomic_2'], - }, - }, - ], - }, - }); - }); - - it('preserves multiple values for a single leaf', () => { - expect(buildObjectForFieldPath('threat.indicator.matched.field', eventHit)).toEqual({ - threat: { - indicator: [ - { - matched: { - field: ['matched_field', 'other_matched_field'], - }, - }, - { - matched: { - field: ['matched_field_2'], - }, - }, - ], - }, - }); - }); - - describe('multiple levels of nested fields', () => { - let nestedHit: EventHit; - - beforeEach(() => { - // @ts-expect-error nestedHit is minimal - nestedHit = { - fields: { - 'nested_1.foo': [ - { - 'nested_2.bar': [ - { leaf: ['leaf_value'], leaf_2: ['leaf_2_value'] }, - { leaf_2: ['leaf_2_value_2', 'leaf_2_value_3'] }, - ], - }, - { - 'nested_2.bar': [ - { leaf: ['leaf_value_2'], leaf_2: ['leaf_2_value_4'] }, - { leaf: ['leaf_value_3'], leaf_2: ['leaf_2_value_5'] }, - ], - }, - ], - }, - }; - }); - - it('includes objects without the field', () => { - expect(buildObjectForFieldPath('nested_1.foo.nested_2.bar.leaf', nestedHit)).toEqual({ - nested_1: { - foo: [ - { - nested_2: { - bar: [{ leaf: ['leaf_value'] }, { leaf: [] }], - }, - }, - { - nested_2: { - bar: [{ leaf: ['leaf_value_2'] }, { leaf: ['leaf_value_3'] }], - }, - }, - ], - }, - }); - }); - - it('groups multiple leaf values', () => { - expect(buildObjectForFieldPath('nested_1.foo.nested_2.bar.leaf_2', nestedHit)).toEqual({ - nested_1: { - foo: [ - { - nested_2: { - bar: [ - { leaf_2: ['leaf_2_value'] }, - { leaf_2: ['leaf_2_value_2', 'leaf_2_value_3'] }, - ], - }, - }, - { - nested_2: { - bar: [{ leaf_2: ['leaf_2_value_4'] }, { leaf_2: ['leaf_2_value_5'] }], - }, - }, - ], - }, - }); - }); - }); - }); - - describe('#buildFieldsRequest', () => { - it('happy path', async () => { - const res = await buildFieldsRequest([ - '@timestamp', - 'host.name', - 'destination.ip', - 'source.ip', - 'source.geo.location', - 'threat.indicator.matched.field', - ]); - expect(res).toEqual([ - { - field: '@timestamp', - include_unmapped: true, - }, - { - field: 'host.name', - include_unmapped: true, - }, - { - field: 'destination.ip', - include_unmapped: true, - }, - { - field: 'source.ip', - include_unmapped: true, - }, - { - field: 'source.geo.location', - include_unmapped: true, - }, - { - field: 'threat.indicator.matched.field', - include_unmapped: true, - }, - { - field: 'signal.status', - include_unmapped: true, - }, - { - field: 'signal.group.id', - include_unmapped: true, - }, - { - field: 'signal.original_time', - include_unmapped: true, - }, - { - field: 'signal.rule.filters', - include_unmapped: true, - }, - { - field: 'signal.rule.from', - include_unmapped: true, - }, - { - field: 'signal.rule.language', - include_unmapped: true, - }, - { - field: 'signal.rule.query', - include_unmapped: true, - }, - { - field: 'signal.rule.name', - include_unmapped: true, - }, - { - field: 'signal.rule.to', - include_unmapped: true, - }, - { - field: 'signal.rule.id', - include_unmapped: true, - }, - { - field: 'signal.rule.index', - include_unmapped: true, - }, - { - field: 'signal.rule.type', - include_unmapped: true, - }, - { - field: 'signal.original_event.kind', - include_unmapped: true, - }, - { - field: 'signal.original_event.module', - include_unmapped: true, - }, - { - field: 'signal.rule.version', - include_unmapped: true, - }, - { - field: 'signal.rule.severity', - include_unmapped: true, - }, - { - field: 'signal.rule.risk_score', - include_unmapped: true, - }, - { - field: 'signal.threshold_result', - include_unmapped: true, - }, - { - field: 'event.code', - include_unmapped: true, - }, - { - field: 'event.module', - include_unmapped: true, - }, - { - field: 'event.action', - include_unmapped: true, - }, - { - field: 'event.category', - include_unmapped: true, - }, - { - field: 'user.name', - include_unmapped: true, - }, - { - field: 'message', - include_unmapped: true, - }, - { - field: 'system.auth.ssh.signature', - include_unmapped: true, - }, - { - field: 'system.auth.ssh.method', - include_unmapped: true, - }, - { - field: 'system.audit.package.arch', - include_unmapped: true, - }, - { - field: 'system.audit.package.entity_id', - include_unmapped: true, - }, - { - field: 'system.audit.package.name', - include_unmapped: true, - }, - { - field: 'system.audit.package.size', - include_unmapped: true, - }, - { - field: 'system.audit.package.summary', - include_unmapped: true, - }, - { - field: 'system.audit.package.version', - include_unmapped: true, - }, - { - field: 'event.created', - include_unmapped: true, - }, - { - field: 'event.dataset', - include_unmapped: true, - }, - { - field: 'event.duration', - include_unmapped: true, - }, - { - field: 'event.end', - include_unmapped: true, - }, - { - field: 'event.hash', - include_unmapped: true, - }, - { - field: 'event.id', - include_unmapped: true, - }, - { - field: 'event.kind', - include_unmapped: true, - }, - { - field: 'event.original', - include_unmapped: true, - }, - { - field: 'event.outcome', - include_unmapped: true, - }, - { - field: 'event.risk_score', - include_unmapped: true, - }, - { - field: 'event.risk_score_norm', - include_unmapped: true, - }, - { - field: 'event.severity', - include_unmapped: true, - }, - { - field: 'event.start', - include_unmapped: true, - }, - { - field: 'event.timezone', - include_unmapped: true, - }, - { - field: 'event.type', - include_unmapped: true, - }, - { - field: 'agent.type', - include_unmapped: true, - }, - { - field: 'auditd.result', - include_unmapped: true, - }, - { - field: 'auditd.session', - include_unmapped: true, - }, - { - field: 'auditd.data.acct', - include_unmapped: true, - }, - { - field: 'auditd.data.terminal', - include_unmapped: true, - }, - { - field: 'auditd.data.op', - include_unmapped: true, - }, - { - field: 'auditd.summary.actor.primary', - include_unmapped: true, - }, - { - field: 'auditd.summary.actor.secondary', - include_unmapped: true, - }, - { - field: 'auditd.summary.object.primary', - include_unmapped: true, - }, - { - field: 'auditd.summary.object.secondary', - include_unmapped: true, - }, - { - field: 'auditd.summary.object.type', - include_unmapped: true, - }, - { - field: 'auditd.summary.how', - include_unmapped: true, - }, - { - field: 'auditd.summary.message_type', - include_unmapped: true, - }, - { - field: 'auditd.summary.sequence', - include_unmapped: true, - }, - { - field: 'file.Ext.original.path', - include_unmapped: true, - }, - { - field: 'file.name', - include_unmapped: true, - }, - { - field: 'file.target_path', - include_unmapped: true, - }, - { - field: 'file.extension', - include_unmapped: true, - }, - { - field: 'file.type', - include_unmapped: true, - }, - { - field: 'file.device', - include_unmapped: true, - }, - { - field: 'file.inode', - include_unmapped: true, - }, - { - field: 'file.uid', - include_unmapped: true, - }, - { - field: 'file.owner', - include_unmapped: true, - }, - { - field: 'file.gid', - include_unmapped: true, - }, - { - field: 'file.group', - include_unmapped: true, - }, - { - field: 'file.mode', - include_unmapped: true, - }, - { - field: 'file.size', - include_unmapped: true, - }, - { - field: 'file.mtime', - include_unmapped: true, - }, - { - field: 'file.ctime', - include_unmapped: true, - }, - { - field: 'file.path', - include_unmapped: true, - }, - { - field: 'file.Ext.code_signature', - include_unmapped: true, - }, - { - field: 'file.Ext.code_signature.subject_name', - include_unmapped: true, - }, - { - field: 'file.Ext.code_signature.trusted', - include_unmapped: true, - }, - { - field: 'file.hash.sha256', - include_unmapped: true, - }, - { - field: 'host.os.family', - include_unmapped: true, - }, - { - field: 'host.id', - include_unmapped: true, - }, - { - field: 'host.ip', - include_unmapped: true, - }, - { - field: 'registry.key', - include_unmapped: true, - }, - { - field: 'registry.path', - include_unmapped: true, - }, - { - field: 'rule.reference', - include_unmapped: true, - }, - { - field: 'source.bytes', - include_unmapped: true, - }, - { - field: 'source.packets', - include_unmapped: true, - }, - { - field: 'source.port', - include_unmapped: true, - }, - { - field: 'source.geo.continent_name', - include_unmapped: true, - }, - { - field: 'source.geo.country_name', - include_unmapped: true, - }, - { - field: 'source.geo.country_iso_code', - include_unmapped: true, - }, - { - field: 'source.geo.city_name', - include_unmapped: true, - }, - { - field: 'source.geo.region_iso_code', - include_unmapped: true, - }, - { - field: 'source.geo.region_name', - include_unmapped: true, - }, - { - field: 'destination.bytes', - include_unmapped: true, - }, - { - field: 'destination.packets', - include_unmapped: true, - }, - { - field: 'destination.port', - include_unmapped: true, - }, - { - field: 'destination.geo.continent_name', - include_unmapped: true, - }, - { - field: 'destination.geo.country_name', - include_unmapped: true, - }, - { - field: 'destination.geo.country_iso_code', - include_unmapped: true, - }, - { - field: 'destination.geo.city_name', - include_unmapped: true, - }, - { - field: 'destination.geo.region_iso_code', - include_unmapped: true, - }, - { - field: 'destination.geo.region_name', - include_unmapped: true, - }, - { - field: 'dns.question.name', - include_unmapped: true, - }, - { - field: 'dns.question.type', - include_unmapped: true, - }, - { - field: 'dns.resolved_ip', - include_unmapped: true, - }, - { - field: 'dns.response_code', - include_unmapped: true, - }, - { - field: 'endgame.exit_code', - include_unmapped: true, - }, - { - field: 'endgame.file_name', - include_unmapped: true, - }, - { - field: 'endgame.file_path', - include_unmapped: true, - }, - { - field: 'endgame.logon_type', - include_unmapped: true, - }, - { - field: 'endgame.parent_process_name', - include_unmapped: true, - }, - { - field: 'endgame.pid', - include_unmapped: true, - }, - { - field: 'endgame.process_name', - include_unmapped: true, - }, - { - field: 'endgame.subject_domain_name', - include_unmapped: true, - }, - { - field: 'endgame.subject_logon_id', - include_unmapped: true, - }, - { - field: 'endgame.subject_user_name', - include_unmapped: true, - }, - { - field: 'endgame.target_domain_name', - include_unmapped: true, - }, - { - field: 'endgame.target_logon_id', - include_unmapped: true, - }, - { - field: 'endgame.target_user_name', - include_unmapped: true, - }, - { - field: 'signal.rule.saved_id', - include_unmapped: true, - }, - { - field: 'signal.rule.timeline_id', - include_unmapped: true, - }, - { - field: 'signal.rule.timeline_title', - include_unmapped: true, - }, - { - field: 'signal.rule.output_index', - include_unmapped: true, - }, - { - field: 'signal.rule.note', - include_unmapped: true, - }, - { - field: 'signal.rule.threshold', - include_unmapped: true, - }, - { - field: 'signal.rule.exceptions_list', - include_unmapped: true, - }, - { - field: 'signal.rule.building_block_type', - include_unmapped: true, - }, - { - field: 'suricata.eve.proto', - include_unmapped: true, - }, - { - field: 'suricata.eve.flow_id', - include_unmapped: true, - }, - { - field: 'suricata.eve.alert.signature', - include_unmapped: true, - }, - { - field: 'suricata.eve.alert.signature_id', - include_unmapped: true, - }, - { - field: 'network.bytes', - include_unmapped: true, - }, - { - field: 'network.community_id', - include_unmapped: true, - }, - { - field: 'network.direction', - include_unmapped: true, - }, - { - field: 'network.packets', - include_unmapped: true, - }, - { - field: 'network.protocol', - include_unmapped: true, - }, - { - field: 'network.transport', - include_unmapped: true, - }, - { - field: 'http.version', - include_unmapped: true, - }, - { - field: 'http.request.method', - include_unmapped: true, - }, - { - field: 'http.request.body.bytes', - include_unmapped: true, - }, - { - field: 'http.request.body.content', - include_unmapped: true, - }, - { - field: 'http.request.referrer', - include_unmapped: true, - }, - { - field: 'http.response.status_code', - include_unmapped: true, - }, - { - field: 'http.response.body.bytes', - include_unmapped: true, - }, - { - field: 'http.response.body.content', - include_unmapped: true, - }, - { - field: 'tls.client_certificate.fingerprint.sha1', - include_unmapped: true, - }, - { - field: 'tls.fingerprints.ja3.hash', - include_unmapped: true, - }, - { - field: 'tls.server_certificate.fingerprint.sha1', - include_unmapped: true, - }, - { - field: 'user.domain', - include_unmapped: true, - }, - { - field: 'winlog.event_id', - include_unmapped: true, - }, - { - field: 'process.exit_code', - include_unmapped: true, - }, - { - field: 'process.hash.md5', - include_unmapped: true, - }, - { - field: 'process.hash.sha1', - include_unmapped: true, - }, - { - field: 'process.hash.sha256', - include_unmapped: true, - }, - { - field: 'process.parent.name', - include_unmapped: true, - }, - { - field: 'process.parent.pid', - include_unmapped: true, - }, - { - field: 'process.pid', - include_unmapped: true, - }, - { - field: 'process.name', - include_unmapped: true, - }, - { - field: 'process.ppid', - include_unmapped: true, - }, - { - field: 'process.args', - include_unmapped: true, - }, - { - field: 'process.entity_id', - include_unmapped: true, - }, - { - field: 'process.executable', - include_unmapped: true, - }, - { - field: 'process.title', - include_unmapped: true, - }, - { - field: 'process.working_directory', - include_unmapped: true, - }, - { - field: 'zeek.session_id', - include_unmapped: true, - }, - { - field: 'zeek.connection.local_resp', - include_unmapped: true, - }, - { - field: 'zeek.connection.local_orig', - include_unmapped: true, - }, - { - field: 'zeek.connection.missed_bytes', - include_unmapped: true, - }, - { - field: 'zeek.connection.state', - include_unmapped: true, - }, - { - field: 'zeek.connection.history', - include_unmapped: true, - }, - { - field: 'zeek.notice.suppress_for', - include_unmapped: true, - }, - { - field: 'zeek.notice.msg', - include_unmapped: true, - }, - { - field: 'zeek.notice.note', - include_unmapped: true, - }, - { - field: 'zeek.notice.sub', - include_unmapped: true, - }, - { - field: 'zeek.notice.dst', - include_unmapped: true, - }, - { - field: 'zeek.notice.dropped', - include_unmapped: true, - }, - { - field: 'zeek.notice.peer_descr', - include_unmapped: true, - }, - { - field: 'zeek.dns.AA', - include_unmapped: true, - }, - { - field: 'zeek.dns.qclass_name', - include_unmapped: true, - }, - { - field: 'zeek.dns.RD', - include_unmapped: true, - }, - { - field: 'zeek.dns.qtype_name', - include_unmapped: true, - }, - { - field: 'zeek.dns.qtype', - include_unmapped: true, - }, - { - field: 'zeek.dns.query', - include_unmapped: true, - }, - { - field: 'zeek.dns.trans_id', - include_unmapped: true, - }, - { - field: 'zeek.dns.qclass', - include_unmapped: true, - }, - { - field: 'zeek.dns.RA', - include_unmapped: true, - }, - { - field: 'zeek.dns.TC', - include_unmapped: true, - }, - { - field: 'zeek.http.resp_mime_types', - include_unmapped: true, - }, - { - field: 'zeek.http.trans_depth', - include_unmapped: true, - }, - { - field: 'zeek.http.status_msg', - include_unmapped: true, - }, - { - field: 'zeek.http.resp_fuids', - include_unmapped: true, - }, - { - field: 'zeek.http.tags', - include_unmapped: true, - }, - { - field: 'zeek.files.session_ids', - include_unmapped: true, - }, - { - field: 'zeek.files.timedout', - include_unmapped: true, - }, - { - field: 'zeek.files.local_orig', - include_unmapped: true, - }, - { - field: 'zeek.files.tx_host', - include_unmapped: true, - }, - { - field: 'zeek.files.source', - include_unmapped: true, - }, - { - field: 'zeek.files.is_orig', - include_unmapped: true, - }, - { - field: 'zeek.files.overflow_bytes', - include_unmapped: true, - }, - { - field: 'zeek.files.sha1', - include_unmapped: true, - }, - { - field: 'zeek.files.duration', - include_unmapped: true, - }, - { - field: 'zeek.files.depth', - include_unmapped: true, - }, - { - field: 'zeek.files.analyzers', - include_unmapped: true, - }, - { - field: 'zeek.files.mime_type', - include_unmapped: true, - }, - { - field: 'zeek.files.rx_host', - include_unmapped: true, - }, - { - field: 'zeek.files.total_bytes', - include_unmapped: true, - }, - { - field: 'zeek.files.fuid', - include_unmapped: true, - }, - { - field: 'zeek.files.seen_bytes', - include_unmapped: true, - }, - { - field: 'zeek.files.missing_bytes', - include_unmapped: true, - }, - { - field: 'zeek.files.md5', - include_unmapped: true, - }, - { - field: 'zeek.ssl.cipher', - include_unmapped: true, - }, - { - field: 'zeek.ssl.established', - include_unmapped: true, - }, - { - field: 'zeek.ssl.resumed', - include_unmapped: true, - }, - { - field: 'zeek.ssl.version', - include_unmapped: true, - }, - { - field: 'threat.indicator.matched.atomic', - include_unmapped: true, - }, - { - field: 'threat.indicator.matched.type', - include_unmapped: true, - }, - { - field: 'threat.indicator.event.dataset', - include_unmapped: true, - }, - { - field: 'threat.indicator.event.reference', - include_unmapped: true, - }, - { - field: 'threat.indicator.provider', - include_unmapped: true, - }, - ]); - }); - - it('remove internal attributes starting with _', async () => { - const res = await buildFieldsRequest([ - '@timestamp', - '_id', - 'host.name', - 'destination.ip', - 'source.ip', - 'source.geo.location', - '_type', - 'threat.indicator.matched.field', - ]); - expect(res.some((f) => f.field === '_id')).toEqual(false); - expect(res.some((f) => f.field === '_type')).toEqual(false); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index bebfd9ca88c234..0df41b9f988b72 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -42,5 +42,6 @@ { "path": "../ml/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, { "path": "../security/tsconfig.json"}, + { "path": "../timelines/tsconfig.json"}, ] } diff --git a/x-pack/plugins/timelines/README.md b/x-pack/plugins/timelines/README.md index 441a505903698b..0c14953837d026 100644 --- a/x-pack/plugins/timelines/README.md +++ b/x-pack/plugins/timelines/README.md @@ -3,9 +3,9 @@ Timelines is a plugin that provides a grid component with accompanying server si ## Using timelines in another plugin -- Add `TimelinesPluginSetup` to Kibana plugin `SetupServices` dependencies: +- Add `TimelinesPluginUI` to Kibana plugin `SetupServices` dependencies: ```ts -timelines: TimelinesPluginSetup; +timelines: TimelinesPluginUI; ``` - Once `timelines` is added as a required plugin in the consuming plugin's kibana.json, timeline functionality will be available as any other kibana plugin, ie PluginSetupDependencies.timelines.getTimeline() diff --git a/x-pack/plugins/timelines/common/constants.ts b/x-pack/plugins/timelines/common/constants.ts new file mode 100644 index 00000000000000..86ff9d501f1488 --- /dev/null +++ b/x-pack/plugins/timelines/common/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DEFAULT_MAX_TABLE_QUERY_SIZE = 10000; diff --git a/x-pack/plugins/timelines/common/ecs/agent/index.ts b/x-pack/plugins/timelines/common/ecs/agent/index.ts new file mode 100644 index 00000000000000..2332b60f1a3cad --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/agent/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface AgentEcs { + type?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/auditd/index.ts b/x-pack/plugins/timelines/common/ecs/auditd/index.ts new file mode 100644 index 00000000000000..f210f8862dc444 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/auditd/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface AuditdEcs { + result?: string[]; + + session?: string[]; + + data?: AuditdDataEcs; + + summary?: SummaryEcs; + + sequence?: string[]; +} + +export interface AuditdDataEcs { + acct?: string[]; + + terminal?: string[]; + + op?: string[]; +} + +export interface SummaryEcs { + actor?: PrimarySecondaryEcs; + + object?: PrimarySecondaryEcs; + + how?: string[]; + + message_type?: string[]; + + sequence?: string[]; +} + +export interface PrimarySecondaryEcs { + primary?: string[]; + + secondary?: string[]; + + type?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/cloud/index.ts b/x-pack/plugins/timelines/common/ecs/cloud/index.ts new file mode 100644 index 00000000000000..a169e5561c6b67 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/cloud/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface CloudEcs { + instance?: CloudInstanceEcs; + machine?: CloudMachineEcs; + provider?: string[]; + region?: string[]; +} + +export interface CloudMachineEcs { + type?: string[]; +} + +export interface CloudInstanceEcs { + id?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/destination/index.ts b/x-pack/plugins/timelines/common/ecs/destination/index.ts new file mode 100644 index 00000000000000..2d3b6154276b94 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/destination/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GeoEcs } from '../geo'; + +export interface DestinationEcs { + bytes?: number[]; + + ip?: string[]; + + port?: number[]; + + domain?: string[]; + + geo?: GeoEcs; + + packets?: number[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/dns/index.ts b/x-pack/plugins/timelines/common/ecs/dns/index.ts new file mode 100644 index 00000000000000..e0f142d9cf57a8 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/dns/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface DnsEcs { + question?: DnsQuestionEcs; + + resolved_ip?: string[]; + + response_code?: string[]; +} + +export interface DnsQuestionEcs { + name?: string[]; + + type?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/ecs_fields/extend_map.test.ts b/x-pack/plugins/timelines/common/ecs/ecs_fields/extend_map.test.ts new file mode 100644 index 00000000000000..e27b15f021257c --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/ecs_fields/extend_map.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { extendMap } from './extend_map'; + +describe('ecs_fields test', () => { + describe('extendMap', () => { + test('it should extend a record', () => { + const osFieldsMap: Readonly> = { + 'os.platform': 'os.platform', + 'os.full': 'os.full', + 'os.family': 'os.family', + 'os.version': 'os.version', + 'os.kernel': 'os.kernel', + }; + const expected: Record = { + 'host.os.family': 'host.os.family', + 'host.os.full': 'host.os.full', + 'host.os.kernel': 'host.os.kernel', + 'host.os.platform': 'host.os.platform', + 'host.os.version': 'host.os.version', + }; + expect(extendMap('host', osFieldsMap)).toEqual(expected); + }); + + test('it should extend a sample hosts record', () => { + const hostMap: Record = { + 'host.id': 'host.id', + 'host.ip': 'host.ip', + 'host.name': 'host.name', + }; + const osFieldsMap: Readonly> = { + 'os.platform': 'os.platform', + 'os.full': 'os.full', + 'os.family': 'os.family', + 'os.version': 'os.version', + 'os.kernel': 'os.kernel', + }; + const expected: Record = { + 'host.id': 'host.id', + 'host.ip': 'host.ip', + 'host.name': 'host.name', + 'host.os.family': 'host.os.family', + 'host.os.full': 'host.os.full', + 'host.os.kernel': 'host.os.kernel', + 'host.os.platform': 'host.os.platform', + 'host.os.version': 'host.os.version', + }; + const output = { ...hostMap, ...extendMap('host', osFieldsMap) }; + expect(output).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/timelines/common/ecs/ecs_fields/extend_map.ts b/x-pack/plugins/timelines/common/ecs/ecs_fields/extend_map.ts new file mode 100644 index 00000000000000..184e6b4f325665 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/ecs_fields/extend_map.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const extendMap = ( + path: string, + map: Readonly> +): Readonly> => + Object.entries(map).reduce>((accum, [key, value]) => { + accum[`${path}.${key}`] = `${path}.${value}`; + return accum; + }, {}); diff --git a/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts b/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts new file mode 100644 index 00000000000000..292822019fc9ca --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts @@ -0,0 +1,359 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { extendMap } from './extend_map'; + +export const auditdMap: Readonly> = { + 'auditd.result': 'auditd.result', + 'auditd.session': 'auditd.session', + 'auditd.data.acct': 'auditd.data.acct', + 'auditd.data.terminal': 'auditd.data.terminal', + 'auditd.data.op': 'auditd.data.op', + 'auditd.summary.actor.primary': 'auditd.summary.actor.primary', + 'auditd.summary.actor.secondary': 'auditd.summary.actor.secondary', + 'auditd.summary.object.primary': 'auditd.summary.object.primary', + 'auditd.summary.object.secondary': 'auditd.summary.object.secondary', + 'auditd.summary.object.type': 'auditd.summary.object.type', + 'auditd.summary.how': 'auditd.summary.how', + 'auditd.summary.message_type': 'auditd.summary.message_type', + 'auditd.summary.sequence': 'auditd.summary.sequence', +}; + +export const cloudFieldsMap: Readonly> = { + 'cloud.account.id': 'cloud.account.id', + 'cloud.availability_zone': 'cloud.availability_zone', + 'cloud.instance.id': 'cloud.instance.id', + 'cloud.instance.name': 'cloud.instance.name', + 'cloud.machine.type': 'cloud.machine.type', + 'cloud.provider': 'cloud.provider', + 'cloud.region': 'cloud.region', +}; + +export const fileMap: Readonly> = { + 'file.name': 'file.name', + 'file.path': 'file.path', + 'file.target_path': 'file.target_path', + 'file.extension': 'file.extension', + 'file.type': 'file.type', + 'file.device': 'file.device', + 'file.inode': 'file.inode', + 'file.uid': 'file.uid', + 'file.owner': 'file.owner', + 'file.gid': 'file.gid', + 'file.group': 'file.group', + 'file.mode': 'file.mode', + 'file.size': 'file.size', + 'file.mtime': 'file.mtime', + 'file.ctime': 'file.ctime', +}; + +export const osFieldsMap: Readonly> = { + 'os.platform': 'os.platform', + 'os.name': 'os.name', + 'os.full': 'os.full', + 'os.family': 'os.family', + 'os.version': 'os.version', + 'os.kernel': 'os.kernel', +}; + +export const hostFieldsMap: Readonly> = { + 'host.architecture': 'host.architecture', + 'host.id': 'host.id', + 'host.ip': 'host.ip', + 'host.mac': 'host.mac', + 'host.name': 'host.name', + ...extendMap('host', osFieldsMap), +}; + +export const processFieldsMap: Readonly> = { + 'process.hash.md5': 'process.hash.md5', + 'process.hash.sha1': 'process.hash.sha1', + 'process.hash.sha256': 'process.hash.sha256', + 'process.pid': 'process.pid', + 'process.name': 'process.name', + 'process.ppid': 'process.ppid', + 'process.args': 'process.args', + 'process.entity_id': 'process.entity_id', + 'process.executable': 'process.executable', + 'process.title': 'process.title', + 'process.thread': 'process.thread', + 'process.working_directory': 'process.working_directory', +}; + +export const agentFieldsMap: Readonly> = { + 'agent.type': 'agent.type', +}; + +export const userFieldsMap: Readonly> = { + 'user.domain': 'user.domain', + 'user.id': 'user.id', + 'user.name': 'user.name', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.full_name': 'user.full_name', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.email': 'user.email', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.hash': 'user.hash', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.group': 'user.group', +}; + +export const winlogFieldsMap: Readonly> = { + 'winlog.event_id': 'winlog.event_id', +}; + +export const suricataFieldsMap: Readonly> = { + 'suricata.eve.flow_id': 'suricata.eve.flow_id', + 'suricata.eve.proto': 'suricata.eve.proto', + 'suricata.eve.alert.signature': 'suricata.eve.alert.signature', + 'suricata.eve.alert.signature_id': 'suricata.eve.alert.signature_id', +}; + +export const tlsFieldsMap: Readonly> = { + 'tls.client_certificate.fingerprint.sha1': 'tls.client_certificate.fingerprint.sha1', + 'tls.fingerprints.ja3.hash': 'tls.fingerprints.ja3.hash', + 'tls.server_certificate.fingerprint.sha1': 'tls.server_certificate.fingerprint.sha1', +}; + +export const urlFieldsMap: Readonly> = { + 'url.original': 'url.original', + 'url.domain': 'url.domain', + 'user.username': 'user.username', + 'user.password': 'user.password', +}; + +export const httpFieldsMap: Readonly> = { + 'http.version': 'http.version', + 'http.request': 'http.request', + 'http.request.method': 'http.request.method', + 'http.request.body.bytes': 'http.request.body.bytes', + 'http.request.body.content': 'http.request.body.content', + 'http.request.referrer': 'http.request.referrer', + 'http.response.status_code': 'http.response.status_code', + 'http.response.body': 'http.response.body', + 'http.response.body.bytes': 'http.response.body.bytes', + 'http.response.body.content': 'http.response.body.content', +}; + +export const zeekFieldsMap: Readonly> = { + 'zeek.session_id': 'zeek.session_id', + 'zeek.connection.local_resp': 'zeek.connection.local_resp', + 'zeek.connection.local_orig': 'zeek.connection.local_orig', + 'zeek.connection.missed_bytes': 'zeek.connection.missed_bytes', + 'zeek.connection.state': 'zeek.connection.state', + 'zeek.connection.history': 'zeek.connection.history', + 'zeek.notice.suppress_for': 'zeek.notice.suppress_for', + 'zeek.notice.msg': 'zeek.notice.msg', + 'zeek.notice.note': 'zeek.notice.note', + 'zeek.notice.sub': 'zeek.notice.sub', + 'zeek.notice.dst': 'zeek.notice.dst', + 'zeek.notice.dropped': 'zeek.notice.dropped', + 'zeek.notice.peer_descr': 'zeek.notice.peer_descr', + 'zeek.dns.AA': 'zeek.dns.AA', + 'zeek.dns.qclass_name': 'zeek.dns.qclass_name', + 'zeek.dns.RD': 'zeek.dns.RD', + 'zeek.dns.qtype_name': 'zeek.dns.qtype_name', + 'zeek.dns.qtype': 'zeek.dns.qtype', + 'zeek.dns.query': 'zeek.dns.query', + 'zeek.dns.trans_id': 'zeek.dns.trans_id', + 'zeek.dns.qclass': 'zeek.dns.qclass', + 'zeek.dns.RA': 'zeek.dns.RA', + 'zeek.dns.TC': 'zeek.dns.TC', + 'zeek.http.resp_mime_types': 'zeek.http.resp_mime_types', + 'zeek.http.trans_depth': 'zeek.http.trans_depth', + 'zeek.http.status_msg': 'zeek.http.status_msg', + 'zeek.http.resp_fuids': 'zeek.http.resp_fuids', + 'zeek.http.tags': 'zeek.http.tags', + 'zeek.files.session_ids': 'zeek.files.session_ids', + 'zeek.files.timedout': 'zeek.files.timedout', + 'zeek.files.local_orig': 'zeek.files.local_orig', + 'zeek.files.tx_host': 'zeek.files.tx_host', + 'zeek.files.source': 'zeek.files.source', + 'zeek.files.is_orig': 'zeek.files.is_orig', + 'zeek.files.overflow_bytes': 'zeek.files.overflow_bytes', + 'zeek.files.sha1': 'zeek.files.sha1', + 'zeek.files.duration': 'zeek.files.duration', + 'zeek.files.depth': 'zeek.files.depth', + 'zeek.files.analyzers': 'zeek.files.analyzers', + 'zeek.files.mime_type': 'zeek.files.mime_type', + 'zeek.files.rx_host': 'zeek.files.rx_host', + 'zeek.files.total_bytes': 'zeek.files.total_bytes', + 'zeek.files.fuid': 'zeek.files.fuid', + 'zeek.files.seen_bytes': 'zeek.files.seen_bytes', + 'zeek.files.missing_bytes': 'zeek.files.missing_bytes', + 'zeek.files.md5': 'zeek.files.md5', + 'zeek.ssl.cipher': 'zeek.ssl.cipher', + 'zeek.ssl.established': 'zeek.ssl.established', + 'zeek.ssl.resumed': 'zeek.ssl.resumed', + 'zeek.ssl.version': 'zeek.ssl.version', +}; + +export const sourceFieldsMap: Readonly> = { + 'source.bytes': 'source.bytes', + 'source.ip': 'source.ip', + 'source.packets': 'source.packets', + 'source.port': 'source.port', + 'source.domain': 'source.domain', + 'source.geo.continent_name': 'source.geo.continent_name', + 'source.geo.country_name': 'source.geo.country_name', + 'source.geo.country_iso_code': 'source.geo.country_iso_code', + 'source.geo.city_name': 'source.geo.city_name', + 'source.geo.region_iso_code': 'source.geo.region_iso_code', + 'source.geo.region_name': 'source.geo.region_name', +}; + +export const destinationFieldsMap: Readonly> = { + 'destination.bytes': 'destination.bytes', + 'destination.ip': 'destination.ip', + 'destination.packets': 'destination.packets', + 'destination.port': 'destination.port', + 'destination.domain': 'destination.domain', + 'destination.geo.continent_name': 'destination.geo.continent_name', + 'destination.geo.country_name': 'destination.geo.country_name', + 'destination.geo.country_iso_code': 'destination.geo.country_iso_code', + 'destination.geo.city_name': 'destination.geo.city_name', + 'destination.geo.region_iso_code': 'destination.geo.region_iso_code', + 'destination.geo.region_name': 'destination.geo.region_name', +}; + +export const networkFieldsMap: Readonly> = { + 'network.bytes': 'network.bytes', + 'network.community_id': 'network.community_id', + 'network.direction': 'network.direction', + 'network.packets': 'network.packets', + 'network.protocol': 'network.protocol', + 'network.transport': 'network.transport', +}; + +export const geoFieldsMap: Readonly> = { + 'geo.region_name': 'destination.geo.region_name', + 'geo.country_iso_code': 'destination.geo.country_iso_code', +}; + +export const dnsFieldsMap: Readonly> = { + 'dns.question.name': 'dns.question.name', + 'dns.question.type': 'dns.question.type', + 'dns.resolved_ip': 'dns.resolved_ip', + 'dns.response_code': 'dns.response_code', +}; + +export const endgameFieldsMap: Readonly> = { + 'endgame.exit_code': 'endgame.exit_code', + 'endgame.file_name': 'endgame.file_name', + 'endgame.file_path': 'endgame.file_path', + 'endgame.logon_type': 'endgame.logon_type', + 'endgame.parent_process_name': 'endgame.parent_process_name', + 'endgame.pid': 'endgame.pid', + 'endgame.process_name': 'endgame.process_name', + 'endgame.subject_domain_name': 'endgame.subject_domain_name', + 'endgame.subject_logon_id': 'endgame.subject_logon_id', + 'endgame.subject_user_name': 'endgame.subject_user_name', + 'endgame.target_domain_name': 'endgame.target_domain_name', + 'endgame.target_logon_id': 'endgame.target_logon_id', + 'endgame.target_user_name': 'endgame.target_user_name', +}; + +export const eventBaseFieldsMap: Readonly> = { + 'event.action': 'event.action', + 'event.category': 'event.category', + 'event.code': 'event.code', + 'event.created': 'event.created', + 'event.dataset': 'event.dataset', + 'event.duration': 'event.duration', + 'event.end': 'event.end', + 'event.hash': 'event.hash', + 'event.id': 'event.id', + 'event.kind': 'event.kind', + 'event.module': 'event.module', + 'event.original': 'event.original', + 'event.outcome': 'event.outcome', + 'event.risk_score': 'event.risk_score', + 'event.risk_score_norm': 'event.risk_score_norm', + 'event.severity': 'event.severity', + 'event.start': 'event.start', + 'event.timezone': 'event.timezone', + 'event.type': 'event.type', +}; + +export const systemFieldsMap: Readonly> = { + 'system.audit.package.arch': 'system.audit.package.arch', + 'system.audit.package.entity_id': 'system.audit.package.entity_id', + 'system.audit.package.name': 'system.audit.package.name', + 'system.audit.package.size': 'system.audit.package.size', + 'system.audit.package.summary': 'system.audit.package.summary', + 'system.audit.package.version': 'system.audit.package.version', + 'system.auth.ssh.signature': 'system.auth.ssh.signature', + 'system.auth.ssh.method': 'system.auth.ssh.method', +}; + +export const signalFieldsMap: Readonly> = { + 'signal.original_time': 'signal.original_time', + 'signal.rule.id': 'signal.rule.id', + 'signal.rule.saved_id': 'signal.rule.saved_id', + 'signal.rule.timeline_id': 'signal.rule.timeline_id', + 'signal.rule.timeline_title': 'signal.rule.timeline_title', + 'signal.rule.output_index': 'signal.rule.output_index', + 'signal.rule.from': 'signal.rule.from', + 'signal.rule.index': 'signal.rule.index', + 'signal.rule.language': 'signal.rule.language', + 'signal.rule.query': 'signal.rule.query', + 'signal.rule.to': 'signal.rule.to', + 'signal.rule.filters': 'signal.rule.filters', + 'signal.rule.rule_id': 'signal.rule.rule_id', + 'signal.rule.false_positives': 'signal.rule.false_positives', + 'signal.rule.max_signals': 'signal.rule.max_signals', + 'signal.rule.risk_score': 'signal.rule.risk_score', + 'signal.rule.description': 'signal.rule.description', + 'signal.rule.name': 'signal.rule.name', + 'signal.rule.immutable': 'signal.rule.immutable', + 'signal.rule.references': 'signal.rule.references', + 'signal.rule.severity': 'signal.rule.severity', + 'signal.rule.tags': 'signal.rule.tags', + 'signal.rule.threat': 'signal.rule.threat', + 'signal.rule.type': 'signal.rule.type', + 'signal.rule.size': 'signal.rule.size', + 'signal.rule.enabled': 'signal.rule.enabled', + 'signal.rule.created_at': 'signal.rule.created_at', + 'signal.rule.updated_at': 'signal.rule.updated_at', + 'signal.rule.created_by': 'signal.rule.created_by', + 'signal.rule.updated_by': 'signal.rule.updated_by', + 'signal.rule.version': 'signal.rule.version', + 'signal.rule.note': 'signal.rule.note', + 'signal.rule.threshold': 'signal.rule.threshold', + 'signal.rule.exceptions_list': 'signal.rule.exceptions_list', +}; + +export const ruleFieldsMap: Readonly> = { + 'rule.reference': 'rule.reference', +}; + +export const eventFieldsMap: Readonly> = { + timestamp: '@timestamp', + '@timestamp': '@timestamp', + message: 'message', + ...{ ...agentFieldsMap }, + ...{ ...auditdMap }, + ...{ ...destinationFieldsMap }, + ...{ ...dnsFieldsMap }, + ...{ ...endgameFieldsMap }, + ...{ ...eventBaseFieldsMap }, + ...{ ...fileMap }, + ...{ ...geoFieldsMap }, + ...{ ...hostFieldsMap }, + ...{ ...networkFieldsMap }, + ...{ ...ruleFieldsMap }, + ...{ ...signalFieldsMap }, + ...{ ...sourceFieldsMap }, + ...{ ...suricataFieldsMap }, + ...{ ...systemFieldsMap }, + ...{ ...tlsFieldsMap }, + ...{ ...zeekFieldsMap }, + ...{ ...httpFieldsMap }, + ...{ ...userFieldsMap }, + ...{ ...winlogFieldsMap }, + ...{ ...processFieldsMap }, +}; diff --git a/x-pack/plugins/timelines/common/ecs/endgame/index.ts b/x-pack/plugins/timelines/common/ecs/endgame/index.ts new file mode 100644 index 00000000000000..f82a9587c75c33 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/endgame/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface EndgameEcs { + exit_code?: number[]; + file_name?: string[]; + file_path?: string[]; + logon_type?: number[]; + parent_process_name?: string[]; + pid?: number[]; + process_name?: string[]; + subject_domain_name?: string[]; + subject_logon_id?: string[]; + subject_user_name?: string[]; + target_domain_name?: string[]; + target_logon_id?: string[]; + target_user_name?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/event/index.ts b/x-pack/plugins/timelines/common/ecs/event/index.ts new file mode 100644 index 00000000000000..4e38bacefd351b --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/event/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface EventEcs { + action?: string[]; + + category?: string[]; + + code?: string[]; + + created?: string[]; + + dataset?: string[]; + + duration?: number[]; + + end?: string[]; + + hash?: string[]; + + id?: string[]; + + kind?: string[]; + + module?: string[]; + + original?: string[]; + + outcome?: string[]; + + risk_score?: number[]; + + risk_score_norm?: number[]; + + severity?: number[]; + + start?: string[]; + + timezone?: string[]; + + type?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/file/index.ts b/x-pack/plugins/timelines/common/ecs/file/index.ts new file mode 100644 index 00000000000000..5e409b1095cf59 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/file/index.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +interface Original { + name?: string[]; + path?: string[]; +} + +export interface CodeSignature { + subject_name: string[]; + trusted: string[]; +} +export interface Ext { + code_signature?: CodeSignature[] | CodeSignature; + original?: Original; +} +export interface Hash { + md5?: string[]; + sha1?: string[]; + sha256: string[]; +} + +export interface FileEcs { + name?: string[]; + + path?: string[]; + + target_path?: string[]; + + extension?: string[]; + + Ext?: Ext; + + type?: string[]; + + device?: string[]; + + inode?: string[]; + + uid?: string[]; + + owner?: string[]; + + gid?: string[]; + + group?: string[]; + + mode?: string[]; + + size?: number[]; + + mtime?: string[]; + + ctime?: string[]; + + hash?: Hash; +} diff --git a/x-pack/plugins/timelines/common/ecs/geo/index.ts b/x-pack/plugins/timelines/common/ecs/geo/index.ts new file mode 100644 index 00000000000000..b6bf0f7b8aaad8 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/geo/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface GeoEcs { + city_name?: string[]; + continent_name?: string[]; + country_iso_code?: string[]; + country_name?: string[]; + location?: Location; + region_iso_code?: string[]; + region_name?: string[]; +} + +export interface Location { + lon?: number[]; + lat?: number[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/host/index.ts b/x-pack/plugins/timelines/common/ecs/host/index.ts new file mode 100644 index 00000000000000..37032c91fc3124 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/host/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface HostEcs { + architecture?: string[]; + + id?: string[]; + + ip?: string[]; + + mac?: string[]; + + name?: string[]; + + os?: OsEcs; + + type?: string[]; +} + +export interface OsEcs { + platform?: string[]; + + name?: string[]; + + full?: string[]; + + family?: string[]; + + version?: string[]; + + kernel?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/http/index.ts b/x-pack/plugins/timelines/common/ecs/http/index.ts new file mode 100644 index 00000000000000..89ce6b678181be --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/http/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface HttpEcs { + version?: string[]; + + request?: HttpRequestData; + + response?: HttpResponseData; +} + +export interface HttpRequestData { + method?: string[]; + + body?: HttpBodyData; + + referrer?: string[]; + + bytes?: number[]; +} + +export interface HttpBodyData { + content?: string[]; + + bytes?: number[]; +} + +export interface HttpResponseData { + status_code?: number[]; + + body?: HttpBodyData; + + bytes?: number[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/index.ts b/x-pack/plugins/timelines/common/ecs/index.ts new file mode 100644 index 00000000000000..8054b3c8521db5 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/index.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AgentEcs } from './agent'; +import { AuditdEcs } from './auditd'; +import { DestinationEcs } from './destination'; +import { DnsEcs } from './dns'; +import { EndgameEcs } from './endgame'; +import { EventEcs } from './event'; +import { FileEcs } from './file'; +import { GeoEcs } from './geo'; +import { HostEcs } from './host'; +import { NetworkEcs } from './network'; +import { RegistryEcs } from './registry'; +import { RuleEcs } from './rule'; +import { SignalEcs } from './signal'; +import { SourceEcs } from './source'; +import { SuricataEcs } from './suricata'; +import { TlsEcs } from './tls'; +import { ZeekEcs } from './zeek'; +import { HttpEcs } from './http'; +import { UrlEcs } from './url'; +import { UserEcs } from './user'; +import { WinlogEcs } from './winlog'; +import { ProcessEcs } from './process'; +import { SystemEcs } from './system'; +import { ThreatEcs } from './threat'; +import { Ransomware } from './ransomware'; + +export interface Ecs { + _id: string; + _index?: string; + agent?: AgentEcs; + auditd?: AuditdEcs; + destination?: DestinationEcs; + dns?: DnsEcs; + endgame?: EndgameEcs; + event?: EventEcs; + geo?: GeoEcs; + host?: HostEcs; + network?: NetworkEcs; + registry?: RegistryEcs; + rule?: RuleEcs; + signal?: SignalEcs; + source?: SourceEcs; + suricata?: SuricataEcs; + tls?: TlsEcs; + zeek?: ZeekEcs; + http?: HttpEcs; + url?: UrlEcs; + timestamp?: string; + message?: string[]; + user?: UserEcs; + winlog?: WinlogEcs; + process?: ProcessEcs; + file?: FileEcs; + system?: SystemEcs; + threat?: ThreatEcs; + // This should be temporary + eql?: { parentId: string; sequenceNumber: string }; + Ransomware?: Ransomware; +} diff --git a/x-pack/plugins/timelines/common/ecs/network/index.ts b/x-pack/plugins/timelines/common/ecs/network/index.ts new file mode 100644 index 00000000000000..6cc5dacab1e53b --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/network/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface NetworkEcs { + bytes?: number[]; + community_id?: string[]; + direction?: string[]; + packets?: number[]; + protocol?: string[]; + transport?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/process/index.ts b/x-pack/plugins/timelines/common/ecs/process/index.ts new file mode 100644 index 00000000000000..820ecc5560e6c5 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/process/index.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Ext } from '../file'; + +export interface ProcessEcs { + Ext?: Ext; + entity_id?: string[]; + exit_code?: number[]; + hash?: ProcessHashData; + parent?: ProcessParentData; + pid?: number[]; + name?: string[]; + ppid?: number[]; + args?: string[]; + executable?: string[]; + title?: string[]; + thread?: Thread; + working_directory?: string[]; +} + +export interface ProcessHashData { + md5?: string[]; + sha1?: string[]; + sha256?: string[]; +} + +export interface ProcessParentData { + name?: string[]; + pid?: number[]; +} + +export interface Thread { + id?: number[]; + start?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/ransomware/index.ts b/x-pack/plugins/timelines/common/ecs/ransomware/index.ts new file mode 100644 index 00000000000000..1724a264f8a4ca --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/ransomware/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface Ransomware { + feature?: string[]; + score?: string[]; + version?: number[]; + child_pids?: string[]; + files?: RansomwareFiles; +} + +export interface RansomwareFiles { + operation?: string[]; + entropy?: number[]; + metrics?: string[]; + extension?: string[]; + original?: OriginalRansomwareFiles; + path?: string[]; + data?: string[]; + score?: number[]; +} + +export interface OriginalRansomwareFiles { + path?: string[]; + extension?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/registry/index.ts b/x-pack/plugins/timelines/common/ecs/registry/index.ts new file mode 100644 index 00000000000000..c756fb139199e7 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/registry/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface RegistryEcs { + hive?: string[]; + key?: string[]; + path?: string[]; + value?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/rule/index.ts b/x-pack/plugins/timelines/common/ecs/rule/index.ts new file mode 100644 index 00000000000000..ae7e5064a8eced --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/rule/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface RuleEcs { + id?: string[]; + rule_id?: string[]; + name?: string[]; + false_positives?: string[]; + saved_id?: string[]; + timeline_id?: string[]; + timeline_title?: string[]; + max_signals?: number[]; + risk_score?: string[]; + output_index?: string[]; + description?: string[]; + from?: string[]; + immutable?: boolean[]; + index?: string[]; + interval?: string[]; + language?: string[]; + query?: string[]; + references?: string[]; + severity?: string[]; + tags?: string[]; + threat?: unknown; + threshold?: unknown; + type?: string[]; + size?: string[]; + to?: string[]; + enabled?: boolean[]; + filters?: unknown; + created_at?: string[]; + updated_at?: string[]; + created_by?: string[]; + updated_by?: string[]; + version?: string[]; + note?: string[]; + building_block_type?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/signal/index.ts b/x-pack/plugins/timelines/common/ecs/signal/index.ts new file mode 100644 index 00000000000000..45e1f04d2b405f --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/signal/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RuleEcs } from '../rule'; + +export interface SignalEcs { + rule?: RuleEcs; + original_time?: string[]; + status?: string[]; + group?: { + id?: string[]; + }; + threshold_result?: unknown; +} diff --git a/x-pack/plugins/timelines/common/ecs/source/index.ts b/x-pack/plugins/timelines/common/ecs/source/index.ts new file mode 100644 index 00000000000000..10a2025eb43ec9 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/source/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GeoEcs } from '../geo'; + +export interface SourceEcs { + bytes?: number[]; + ip?: string[]; + port?: number[]; + domain?: string[]; + geo?: GeoEcs; + packets?: number[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/suricata/index.ts b/x-pack/plugins/timelines/common/ecs/suricata/index.ts new file mode 100644 index 00000000000000..5555a40188432d --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/suricata/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface SuricataEcs { + eve?: SuricataEveData; +} + +export interface SuricataEveData { + alert?: SuricataAlertData; + + flow_id?: number[]; + + proto?: string[]; +} + +export interface SuricataAlertData { + signature?: string[]; + + signature_id?: number[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/system/index.ts b/x-pack/plugins/timelines/common/ecs/system/index.ts new file mode 100644 index 00000000000000..f2313c78845118 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/system/index.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface SystemEcs { + audit?: AuditEcs; + + auth?: AuthEcs; +} + +export interface AuditEcs { + package?: PackageEcs; +} + +export interface PackageEcs { + arch?: string[]; + + entity_id?: string[]; + + name?: string[]; + + size?: number[]; + + summary?: string[]; + + version?: string[]; +} + +export interface AuthEcs { + ssh?: SshEcs; +} + +export interface SshEcs { + method?: string[]; + + signature?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/threat/index.ts b/x-pack/plugins/timelines/common/ecs/threat/index.ts new file mode 100644 index 00000000000000..19923a82dc846f --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/threat/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EventEcs } from '../event'; + +interface ThreatMatchEcs { + atomic?: string[]; + field?: string[]; + type?: string[]; +} + +export interface ThreatIndicatorEcs { + matched?: ThreatMatchEcs; + event?: EventEcs & { reference?: string[] }; + provider?: string[]; + type?: string[]; +} + +export interface ThreatEcs { + indicator: ThreatIndicatorEcs[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/tls/index.ts b/x-pack/plugins/timelines/common/ecs/tls/index.ts new file mode 100644 index 00000000000000..f2e6b3d36653dc --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/tls/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface TlsEcs { + client_certificate?: TlsClientCertificateData; + + fingerprints?: TlsFingerprintsData; + + server_certificate?: TlsServerCertificateData; +} + +export interface TlsClientCertificateData { + fingerprint?: FingerprintData; +} + +export interface FingerprintData { + sha1?: string[]; +} + +export interface TlsFingerprintsData { + ja3?: TlsJa3Data; +} + +export interface TlsJa3Data { + hash?: string[]; +} + +export interface TlsServerCertificateData { + fingerprint?: FingerprintData; +} diff --git a/x-pack/plugins/timelines/common/ecs/url/index.ts b/x-pack/plugins/timelines/common/ecs/url/index.ts new file mode 100644 index 00000000000000..ea9dc303108e38 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/url/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface UrlEcs { + domain?: string[]; + + original?: string[]; + + username?: string[]; + + password?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/user/index.ts b/x-pack/plugins/timelines/common/ecs/user/index.ts new file mode 100644 index 00000000000000..b03a8e5e96b415 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/user/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface UserEcs { + domain?: string[]; + + id?: string[]; + + name?: string[]; + + full_name?: string[]; + + email?: string[]; + + hash?: string[]; + + group?: string[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/winlog/index.ts b/x-pack/plugins/timelines/common/ecs/winlog/index.ts new file mode 100644 index 00000000000000..27757d05ba6ecd --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/winlog/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface WinlogEcs { + event_id?: number[]; +} diff --git a/x-pack/plugins/timelines/common/ecs/zeek/index.ts b/x-pack/plugins/timelines/common/ecs/zeek/index.ts new file mode 100644 index 00000000000000..b1a3786ae74aa6 --- /dev/null +++ b/x-pack/plugins/timelines/common/ecs/zeek/index.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface ZeekEcs { + session_id?: string[]; + + connection?: ZeekConnectionData; + + notice?: ZeekNoticeData; + + dns?: ZeekDnsData; + + http?: ZeekHttpData; + + files?: ZeekFileData; + + ssl?: ZeekSslData; +} + +export interface ZeekConnectionData { + local_resp?: boolean[]; + + local_orig?: boolean[]; + + missed_bytes?: number[]; + + state?: string[]; + + history?: string[]; +} + +export interface ZeekNoticeData { + suppress_for?: number[]; + + msg?: string[]; + + note?: string[]; + + sub?: string[]; + + dst?: string[]; + + dropped?: boolean[]; + + peer_descr?: string[]; +} + +export interface ZeekDnsData { + AA?: boolean[]; + + qclass_name?: string[]; + + RD?: boolean[]; + + qtype_name?: string[]; + + rejected?: boolean[]; + + qtype?: string[]; + + query?: string[]; + + trans_id?: number[]; + + qclass?: string[]; + + RA?: boolean[]; + + TC?: boolean[]; +} + +export interface ZeekHttpData { + resp_mime_types?: string[]; + + trans_depth?: string[]; + + status_msg?: string[]; + + resp_fuids?: string[]; + + tags?: string[]; +} + +export interface ZeekFileData { + session_ids?: string[]; + + timedout?: boolean[]; + + local_orig?: boolean[]; + + tx_host?: string[]; + + source?: string[]; + + is_orig?: boolean[]; + + overflow_bytes?: number[]; + + sha1?: string[]; + + duration?: number[]; + + depth?: number[]; + + analyzers?: string[]; + + mime_type?: string[]; + + rx_host?: string[]; + + total_bytes?: number[]; + + fuid?: string[]; + + seen_bytes?: number[]; + + missing_bytes?: number[]; + + md5?: string[]; +} + +export interface ZeekSslData { + cipher?: string[]; + + established?: boolean[]; + + resumed?: boolean[]; + + version?: string[]; +} diff --git a/x-pack/plugins/timelines/common/index.ts b/x-pack/plugins/timelines/common/index.ts index c095b6c89627ea..05174235c20dbf 100644 --- a/x-pack/plugins/timelines/common/index.ts +++ b/x-pack/plugins/timelines/common/index.ts @@ -5,5 +5,9 @@ * 2.0. */ +export * from './types'; +export * from './search_strategy'; +export * from './utils/accessibility'; + export const PLUGIN_ID = 'timelines'; export const PLUGIN_NAME = 'timelines'; diff --git a/x-pack/plugins/timelines/common/search_strategy/common/index.ts b/x-pack/plugins/timelines/common/search_strategy/common/index.ts new file mode 100644 index 00000000000000..62c2187e267fa0 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/common/index.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { estypes } from '@elastic/elasticsearch'; + +export type Maybe = T | null; + +export interface TotalValue { + value: number; + relation: string; +} + +export interface CursorType { + value?: Maybe; + tiebreaker?: Maybe; +} + +export interface Inspect { + dsl: string[]; +} + +export enum Direction { + asc = 'asc', + desc = 'desc', +} + +export interface SortField { + field: Field; + direction: Direction; +} + +export interface TimerangeInput { + /** The interval string to use for last bucket. The format is '{value}{unit}'. For example '5m' would return the metrics for the last 5 minutes of the timespan. */ + interval: string; + /** The end of the timerange */ + to: string; + /** The beginning of the timerange */ + from: string; +} + +export interface PaginationInputPaginated { + /** The activePage parameter defines the page of results you want to fetch */ + activePage: number; + /** The cursorStart parameter defines the start of the results to be displayed */ + cursorStart: number; + /** The fakePossibleCount parameter determines the total count in order to show 5 additional pages */ + fakePossibleCount: number; + /** The querySize parameter is the number of items to be returned */ + querySize: number; +} + +export type DocValueFields = estypes.SearchDocValueField; + +export interface TimerangeFilter { + range: { + [timestamp: string]: { + gte: string; + lte: string; + format: string; + }; + }; +} + +export interface Fields { + [x: string]: T | Array>; +} + +export interface EventSource { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [field: string]: any; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface EventHit extends estypes.SearchHit> { + sort: string[]; + fields: Fields; +} diff --git a/x-pack/plugins/timelines/common/search_strategy/eql/index.ts b/x-pack/plugins/timelines/common/search_strategy/eql/index.ts new file mode 100644 index 00000000000000..4a361bed64890c --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/eql/index.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TotalValue } from '../common'; + +export * from './validation'; + +export type SearchTypes = + | string + | string[] + | number + | number[] + | boolean + | boolean[] + | object + | object[] + | undefined; + +export interface BaseHit { + _index: string; + _id: string; + _source: T; + fields?: Record; +} + +export interface EqlSequence { + join_keys: SearchTypes[]; + events: Array>; +} + +export interface EqlSearchResponse { + is_partial: boolean; + is_running: boolean; + took: number; + timed_out: boolean; + hits: { + total: TotalValue; + sequences?: Array>; + events?: Array>; + }; +} diff --git a/x-pack/plugins/timelines/common/search_strategy/eql/validation/helpers.mock.ts b/x-pack/plugins/timelines/common/search_strategy/eql/validation/helpers.mock.ts new file mode 100644 index 00000000000000..b3a2c9c9a3f62f --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/eql/validation/helpers.mock.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ApiResponse } from '@elastic/elasticsearch'; +import { ErrorResponse } from './helpers'; + +export const getValidEqlResponse = (): ApiResponse['body'] => ({ + is_partial: false, + is_running: false, + took: 162, + timed_out: false, + hits: { + total: { + value: 1, + relation: 'eq', + }, + sequences: [], + }, +}); + +export const getEqlResponseWithValidationError = (): ErrorResponse => ({ + error: { + root_cause: [ + { + type: 'verification_exception', + reason: + 'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]', + }, + ], + type: 'verification_exception', + reason: + 'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]', + }, +}); + +export const getEqlResponseWithValidationErrors = (): ErrorResponse => ({ + error: { + root_cause: [ + { + type: 'verification_exception', + reason: + 'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]', + }, + { + type: 'parsing_exception', + reason: "line 1:4: mismatched input '' expecting 'where'", + }, + ], + type: 'verification_exception', + reason: + 'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]', + }, +}); + +export const getEqlResponseWithNonValidationError = (): ApiResponse['body'] => ({ + error: { + root_cause: [ + { + type: 'other_error', + reason: 'some other reason', + }, + ], + type: 'other_error', + reason: 'some other reason', + }, +}); diff --git a/x-pack/plugins/timelines/common/search_strategy/eql/validation/helpers.test.ts b/x-pack/plugins/timelines/common/search_strategy/eql/validation/helpers.test.ts new file mode 100644 index 00000000000000..de75cf6ac6dc71 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/eql/validation/helpers.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getValidationErrors, isErrorResponse, isValidationErrorResponse } from './helpers'; +import { + getEqlResponseWithNonValidationError, + getEqlResponseWithValidationError, + getEqlResponseWithValidationErrors, + getValidEqlResponse, +} from './helpers.mock'; + +describe('eql validation helpers', () => { + describe('isErrorResponse', () => { + it('is false for a regular response', () => { + expect(isErrorResponse(getValidEqlResponse())).toEqual(false); + }); + + it('is true for a response with non-validation errors', () => { + expect(isErrorResponse(getEqlResponseWithNonValidationError())).toEqual(true); + }); + + it('is true for a response with validation errors', () => { + expect(isErrorResponse(getEqlResponseWithValidationError())).toEqual(true); + }); + }); + + describe('isValidationErrorResponse', () => { + it('is false for a regular response', () => { + expect(isValidationErrorResponse(getValidEqlResponse())).toEqual(false); + }); + + it('is false for a response with non-validation errors', () => { + expect(isValidationErrorResponse(getEqlResponseWithNonValidationError())).toEqual(false); + }); + + it('is true for a response with validation errors', () => { + expect(isValidationErrorResponse(getEqlResponseWithValidationError())).toEqual(true); + }); + }); + + describe('getValidationErrors', () => { + it('returns a single error for a single root cause', () => { + expect(getValidationErrors(getEqlResponseWithValidationError())).toEqual([ + 'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]', + ]); + }); + + it('returns multiple errors for multiple root causes', () => { + expect(getValidationErrors(getEqlResponseWithValidationErrors())).toEqual([ + 'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]', + "line 1:4: mismatched input '' expecting 'where'", + ]); + }); + }); +}); diff --git a/x-pack/plugins/timelines/common/search_strategy/eql/validation/helpers.ts b/x-pack/plugins/timelines/common/search_strategy/eql/validation/helpers.ts new file mode 100644 index 00000000000000..63a812cad759a6 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/eql/validation/helpers.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get, has } from 'lodash'; + +const PARSING_ERROR_TYPE = 'parsing_exception'; +const VERIFICATION_ERROR_TYPE = 'verification_exception'; +const MAPPING_ERROR_TYPE = 'mapping_exception'; + +interface ErrorCause { + type: string; + reason: string; +} + +export interface ErrorResponse { + error: ErrorCause & { root_cause: ErrorCause[] }; +} + +const isValidationErrorType = (type: unknown): boolean => + type === PARSING_ERROR_TYPE || type === VERIFICATION_ERROR_TYPE || type === MAPPING_ERROR_TYPE; + +export const isErrorResponse = (response: unknown): response is ErrorResponse => + has(response, 'error.type'); + +export const isValidationErrorResponse = (response: unknown): response is ErrorResponse => + isErrorResponse(response) && isValidationErrorType(get(response, 'error.type')); + +export const getValidationErrors = (response: ErrorResponse): string[] => + response.error.root_cause + .filter((cause) => isValidationErrorType(cause.type)) + .map((cause) => cause.reason); diff --git a/x-pack/plugins/timelines/common/search_strategy/eql/validation/index.ts b/x-pack/plugins/timelines/common/search_strategy/eql/validation/index.ts new file mode 100644 index 00000000000000..6c315f929b9bbd --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/eql/validation/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './helpers'; diff --git a/x-pack/plugins/timelines/common/search_strategy/index.ts b/x-pack/plugins/timelines/common/search_strategy/index.ts new file mode 100644 index 00000000000000..155306327ee0c0 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './common'; +export * from './timeline'; +export * from './index_fields'; +export * from './eql'; diff --git a/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts b/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts new file mode 100644 index 00000000000000..76ab48a8243db3 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IIndexPattern } from 'src/plugins/data/public'; +import { + IEsSearchRequest, + IEsSearchResponse, + IFieldSubType, +} from '../../../../../../src/plugins/data/common'; +import { DocValueFields, Maybe } from '../common'; + +export type BeatFieldsFactoryQueryType = 'beatFields'; + +interface FieldInfo { + category: string; + description?: string; + example?: string | number; + format?: string; + name: string; + type?: string; +} + +export interface IndexField { + /** Where the field belong */ + category: string; + /** Example of field's value */ + example?: Maybe; + /** whether the field's belong to an alias index */ + indexes: Array>; + /** The name of the field */ + name: string; + /** The type of the field's values as recognized by Kibana */ + type: string; + /** Whether the field's values can be efficiently searched for */ + searchable: boolean; + /** Whether the field's values can be aggregated */ + aggregatable: boolean; + /** Description of the field */ + description?: Maybe; + format?: Maybe; + /** the elastic type as mapped in the index */ + esTypes?: string[]; + subType?: IFieldSubType; + readFromDocValues: boolean; +} + +export type BeatFields = Record; + +export interface IndexFieldsStrategyRequest extends IEsSearchRequest { + indices: string[]; + onlyCheckIfIndicesExist: boolean; +} + +export interface IndexFieldsStrategyResponse extends IEsSearchResponse { + indexFields: IndexField[]; + indicesExist: string[]; +} + +export interface BrowserField { + aggregatable: boolean; + category: string; + description: string | null; + example: string | number | null; + fields: Readonly>>; + format: string; + indexes: string[]; + name: string; + searchable: boolean; + type: string; + subType?: { + [key: string]: unknown; + nested?: { + path: string; + }; + }; +} + +export type BrowserFields = Readonly>>; + +export const EMPTY_BROWSER_FIELDS = {}; +export const EMPTY_DOCVALUE_FIELD: DocValueFields[] = []; +export const EMPTY_INDEX_PATTERN: IIndexPattern = { + fields: [], + title: '', +}; diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts new file mode 100644 index 00000000000000..94f7bc617e2f2f --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import type { Ecs } from '../../../../ecs'; +import type { CursorType, Inspect, Maybe, PaginationInputPaginated } from '../../../common'; +import type { TimelineRequestOptionsPaginated } from '../..'; + +export interface TimelineEdges { + node: TimelineItem; + cursor: CursorType; +} + +export interface TimelineItem { + _id: string; + _index?: Maybe; + data: TimelineNonEcsData[]; + ecs: Ecs; +} + +export interface TimelineNonEcsData { + field: string; + value?: Maybe; +} + +export interface TimelineEventsAllStrategyResponse extends IEsSearchResponse { + edges: TimelineEdges[]; + totalCount: number; + pageInfo: Pick; + inspect?: Maybe; +} + +export interface TimelineEventsAllRequestOptions extends TimelineRequestOptionsPaginated { + fields: string[] | Array<{ field: string; include_unmapped: boolean }>; + fieldRequested: string[]; + language: 'eql' | 'kuery' | 'lucene'; + excludeEcsData?: boolean; +} diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/common/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/common/index.ts new file mode 100644 index 00000000000000..4a5bd2c99a0eb6 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/common/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Ecs } from '../../../../ecs'; +import { CursorType, Maybe } from '../../../common'; + +export interface TimelineEdges { + node: TimelineItem; + cursor: CursorType; +} + +export interface TimelineItem { + _id: string; + _index?: Maybe; + data: TimelineNonEcsData[]; + ecs: Ecs; +} + +export interface TimelineNonEcsData { + field: string; + value?: Maybe; +} diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts new file mode 100644 index 00000000000000..1f9820f8e5c2b0 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../common'; +import { TimelineRequestOptionsPaginated } from '../..'; + +export interface TimelineEventsDetailsItem { + ariaRowindex?: Maybe; + category?: string; + field: string; + values?: Maybe; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + originalValue?: Maybe; + isObjectArray: boolean; +} + +export interface TimelineEventsDetailsStrategyResponse extends IEsSearchResponse { + data?: Maybe; + inspect?: Maybe; +} + +export interface TimelineEventsDetailsRequestOptions + extends Partial { + indexName: string; + eventId: string; +} diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/eql/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/eql/index.ts new file mode 100644 index 00000000000000..1e5164684bf6e6 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/eql/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { + EqlSearchStrategyRequest, + EqlSearchStrategyResponse, +} from '../../../../../../../../src/plugins/data/common'; +import { EqlSearchResponse, Inspect, Maybe, PaginationInputPaginated } from '../../..'; +import { TimelineEdges, TimelineEventsAllRequestOptions } from '../..'; + +export interface TimelineEqlRequestOptions + extends EqlSearchStrategyRequest, + Omit { + eventCategoryField?: string; + tiebreakerField?: string; + timestampField?: string; + size?: number; +} + +export interface TimelineEqlResponse extends EqlSearchStrategyResponse> { + edges: TimelineEdges[]; + totalCount: number; + pageInfo: Pick; + inspect: Maybe; +} + +export interface EqlOptionsData { + keywordFields: EuiComboBoxOptionOption[]; + dateFields: EuiComboBoxOptionOption[]; + nonDateFields: EuiComboBoxOptionOption[]; +} + +export interface EqlOptionsSelected { + eventCategoryField?: string; + tiebreakerField?: string; + timestampField?: string; + query?: string; + size?: number; +} + +export type FieldsEqlOptions = keyof EqlOptionsSelected; diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/index.ts new file mode 100644 index 00000000000000..c4d6f70a275871 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './all'; +export * from './details'; +export * from './last_event_time'; +export * from './eql'; + +export enum TimelineEventsQueries { + all = 'eventsAll', + details = 'eventsDetails', + kpi = 'eventsKpi', + lastEventTime = 'eventsLastEventTime', +} diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts new file mode 100644 index 00000000000000..f29dc4a3c74509 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../common'; +import { TimelineRequestBasicOptions } from '../..'; + +export enum LastEventIndexKey { + hostDetails = 'hostDetails', + hosts = 'hosts', + ipDetails = 'ipDetails', + network = 'network', +} + +export interface LastTimeDetails { + hostName?: Maybe; + ip?: Maybe; +} + +export interface TimelineEventsLastEventTimeStrategyResponse extends IEsSearchResponse { + lastSeen: Maybe; + inspect?: Maybe; +} + +export interface TimelineKpiStrategyResponse extends IEsSearchResponse { + destinationIpCount: number; + inspect?: Maybe; + hostCount: number; + processCount: number; + sourceIpCount: number; + userCount: number; +} + +export interface TimelineEventsLastEventTimeRequestOptions + extends Omit { + indexKey: LastEventIndexKey; + details: LastTimeDetails; +} diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/index.ts new file mode 100644 index 00000000000000..7064ef033fc5a0 --- /dev/null +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/index.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEsSearchRequest } from '../../../../../../src/plugins/data/common'; +import { ESQuery } from '../../typed_json'; +import { + TimelineEventsQueries, + TimelineEventsAllRequestOptions, + TimelineEventsAllStrategyResponse, + TimelineEventsDetailsRequestOptions, + TimelineEventsDetailsStrategyResponse, + TimelineEventsLastEventTimeRequestOptions, + TimelineEventsLastEventTimeStrategyResponse, + TimelineKpiStrategyResponse, +} from './events'; +import { + DocValueFields, + PaginationInputPaginated, + TimerangeInput, + SortField, + Maybe, +} from '../common'; +import { + DataProviderType, + TimelineType, + TimelineStatus, + RowRendererId, +} from '../../types/timeline'; + +export * from './events'; + +export type TimelineFactoryQueryTypes = TimelineEventsQueries; + +export interface TimelineRequestBasicOptions extends IEsSearchRequest { + timerange: TimerangeInput; + filterQuery: ESQuery | string | undefined; + defaultIndex: string[]; + docValueFields?: DocValueFields[]; + factoryQueryType?: TimelineFactoryQueryTypes; +} + +export interface TimelineRequestSortField extends SortField { + type: string; +} + +export interface TimelineRequestOptionsPaginated + extends TimelineRequestBasicOptions { + pagination: Pick; + sort: Array>; +} + +export type TimelineStrategyResponseType< + T extends TimelineFactoryQueryTypes +> = T extends TimelineEventsQueries.all + ? TimelineEventsAllStrategyResponse + : T extends TimelineEventsQueries.details + ? TimelineEventsDetailsStrategyResponse + : T extends TimelineEventsQueries.kpi + ? TimelineKpiStrategyResponse + : T extends TimelineEventsQueries.lastEventTime + ? TimelineEventsLastEventTimeStrategyResponse + : never; + +export type TimelineStrategyRequestType< + T extends TimelineFactoryQueryTypes +> = T extends TimelineEventsQueries.all + ? TimelineEventsAllRequestOptions + : T extends TimelineEventsQueries.details + ? TimelineEventsDetailsRequestOptions + : T extends TimelineEventsQueries.kpi + ? TimelineRequestBasicOptions + : T extends TimelineEventsQueries.lastEventTime + ? TimelineEventsLastEventTimeRequestOptions + : never; + +export interface ColumnHeaderInput { + aggregatable?: Maybe; + category?: Maybe; + columnHeaderType?: Maybe; + description?: Maybe; + example?: Maybe; + indexes?: Maybe; + id?: Maybe; + name?: Maybe; + placeholder?: Maybe; + searchable?: Maybe; + type?: Maybe; +} + +export interface QueryMatchInput { + field?: Maybe; + + displayField?: Maybe; + + value?: Maybe; + + displayValue?: Maybe; + + operator?: Maybe; +} + +export interface DataProviderInput { + id?: Maybe; + name?: Maybe; + enabled?: Maybe; + excluded?: Maybe; + kqlQuery?: Maybe; + queryMatch?: Maybe; + and?: Maybe; + type?: Maybe; +} + +export interface EqlOptionsInput { + eventCategoryField?: Maybe; + tiebreakerField?: Maybe; + timestampField?: Maybe; + query?: Maybe; + size?: Maybe; +} + +export interface FilterMetaTimelineInput { + alias?: Maybe; + controlledBy?: Maybe; + disabled?: Maybe; + field?: Maybe; + formattedValue?: Maybe; + index?: Maybe; + key?: Maybe; + negate?: Maybe; + params?: Maybe; + type?: Maybe; + value?: Maybe; +} + +export interface FilterTimelineInput { + exists?: Maybe; + meta?: Maybe; + match_all?: Maybe; + missing?: Maybe; + query?: Maybe; + range?: Maybe; + script?: Maybe; +} + +export interface SerializedFilterQueryInput { + filterQuery?: Maybe; +} + +export interface SerializedKueryQueryInput { + kuery?: Maybe; + serializedQuery?: Maybe; +} + +export interface KueryFilterQueryInput { + kind?: Maybe; + expression?: Maybe; +} + +export interface DateRangePickerInput { + start?: Maybe; + end?: Maybe; +} + +export interface SortTimelineInput { + columnId?: Maybe; + sortDirection?: Maybe; +} + +export interface TimelineInput { + columns?: Maybe; + dataProviders?: Maybe; + description?: Maybe; + eqlOptions?: Maybe; + eventType?: Maybe; + excludedRowRendererIds?: Maybe; + filters?: Maybe; + kqlMode?: Maybe; + kqlQuery?: Maybe; + indexNames?: Maybe; + title?: Maybe; + templateTimelineId?: Maybe; + templateTimelineVersion?: Maybe; + timelineType?: Maybe; + dateRange?: Maybe; + savedQueryId?: Maybe; + sort?: Maybe; + status?: Maybe; +} + +export enum FlowDirection { + uniDirectional = 'uniDirectional', + biDirectional = 'biDirectional', +} diff --git a/x-pack/plugins/timelines/common/typed_json.ts b/x-pack/plugins/timelines/common/typed_json.ts new file mode 100644 index 00000000000000..71ece547778710 --- /dev/null +++ b/x-pack/plugins/timelines/common/typed_json.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { JsonObject } from '@kbn/common-utils'; + +import { DslQuery, Filter } from 'src/plugins/data/common'; + +export type ESQuery = + | ESRangeQuery + | ESQueryStringQuery + | ESMatchQuery + | ESTermQuery + | ESBoolQuery + | JsonObject; + +export interface ESRangeQuery { + range: { + [name: string]: { + gte: number; + lte: number; + format: string; + }; + }; +} + +export interface ESMatchQuery { + match: { + [name: string]: { + query: string; + operator: string; + zero_terms_query: string; + }; + }; +} + +export interface ESQueryStringQuery { + query_string: { + query: string; + analyze_wildcard: boolean; + }; +} + +export interface ESTermQuery { + term: Record; +} + +export interface ESBoolQuery { + bool: { + must: DslQuery[]; + filter: Filter[]; + should: never[]; + must_not: Filter[]; + }; +} diff --git a/x-pack/plugins/timelines/common/types/index.ts b/x-pack/plugins/timelines/common/types/index.ts new file mode 100644 index 00000000000000..9464a33082a495 --- /dev/null +++ b/x-pack/plugins/timelines/common/types/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './timeline'; diff --git a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts new file mode 100644 index 00000000000000..8d3f212fd6bcce --- /dev/null +++ b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ComponentType, JSXElementConstructor } from 'react'; +import { EuiDataGridControlColumn, EuiDataGridCellValueElementProps } from '@elastic/eui'; + +import { OnRowSelected, SortColumnTimeline, TimelineTabs } from '..'; +import { BrowserFields } from '../../../search_strategy/index_fields'; +import { ColumnHeaderOptions } from '../columns'; +import { TimelineNonEcsData } from '../../../search_strategy'; +import { Ecs } from '../../../ecs'; + +export interface ActionProps { + ariaRowindex: number; + action?: RowCellRender; + width?: number; + columnId: string; + columnValues: string; + checked: boolean; + onRowSelected: OnRowSelected; + eventId: string; + loadingEventIds: Readonly; + onEventDetailsPanelOpened: () => void; + showCheckboxes: boolean; + data: TimelineNonEcsData[]; + ecsData: Ecs; + index: number; + eventIdToNoteIds?: Readonly>; + isEventPinned?: boolean; + isEventViewer?: boolean; + rowIndex: number; + refetch?: () => void; + onRuleChange?: () => void; + showNotes?: boolean; + tabType?: TimelineTabs; + timelineId: string; + toggleShowNotes?: () => void; +} + +export interface HeaderActionProps { + width: number; + browserFields: BrowserFields; + columnHeaders: ColumnHeaderOptions[]; + isEventViewer?: boolean; + isSelectAllChecked: boolean; + onSelectAll: ({ isSelected }: { isSelected: boolean }) => void; + showEventsSelect: boolean; + showSelectAllCheckbox: boolean; + sort: SortColumnTimeline[]; + tabType: TimelineTabs; + timelineId: string; +} + +export type GenericActionRowCellRenderProps = Pick< + EuiDataGridCellValueElementProps, + 'rowIndex' | 'columnId' +>; + +export type HeaderCellRender = ComponentType | ComponentType; +export type RowCellRender = + | JSXElementConstructor + | ((props: GenericActionRowCellRenderProps) => JSX.Element) + | JSXElementConstructor + | ((props: ActionProps) => JSX.Element); + +interface AdditionalControlColumnProps { + ariaRowindex: number; + actionsColumnWidth: number; + columnValues: string; + checked: boolean; + onRowSelected: OnRowSelected; + eventId: string; + id: string; + columnId: string; + loadingEventIds: Readonly; + onEventDetailsPanelOpened: () => void; + showCheckboxes: boolean; + // Override these type definitions to support either a generic custom component or the one used in security_solution today. + headerCellRender: HeaderCellRender; + rowCellRender: RowCellRender; + // If not provided, calculated dynamically + width?: number; +} + +export type ControlColumnProps = Omit< + EuiDataGridControlColumn, + keyof AdditionalControlColumnProps +> & + Partial; diff --git a/x-pack/plugins/timelines/common/types/timeline/cells/index.ts b/x-pack/plugins/timelines/common/types/timeline/cells/index.ts new file mode 100644 index 00000000000000..ad70d8bba82fd3 --- /dev/null +++ b/x-pack/plugins/timelines/common/types/timeline/cells/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import { TimelineNonEcsData } from '../../../search_strategy'; +import { ColumnHeaderOptions } from '../columns'; + +/** The following props are provided to the function called by `renderCellValue` */ +export type CellValueElementProps = EuiDataGridCellValueElementProps & { + data: TimelineNonEcsData[]; + eventId: string; // _id + header: ColumnHeaderOptions; + linkValues: string[] | undefined; + timelineId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setFlyoutAlert?: (data: any) => void; +}; diff --git a/x-pack/plugins/timelines/common/types/timeline/columns/index.ts b/x-pack/plugins/timelines/common/types/timeline/columns/index.ts new file mode 100644 index 00000000000000..61f0c6a0b8f23a --- /dev/null +++ b/x-pack/plugins/timelines/common/types/timeline/columns/index.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridColumn } from '@elastic/eui'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IFieldSubType } from '../../../../../../../src/plugins/data/public'; +import { TimelineNonEcsData } from '../../../search_strategy/timeline'; + +export type ColumnHeaderType = 'not-filtered' | 'text-filter'; + +/** Uniquely identifies a column */ +export type ColumnId = string; + +/** The specification of a column header */ +export type ColumnHeaderOptions = Pick< + EuiDataGridColumn, + 'display' | 'displayAsText' | 'id' | 'initialWidth' +> & { + aggregatable?: boolean; + category?: string; + columnHeaderType: ColumnHeaderType; + description?: string; + example?: string; + format?: string; + linkField?: string; + placeholder?: string; + subType?: IFieldSubType; + type?: string; +}; + +export interface ColumnRenderer { + isInstance: (columnName: string, data: TimelineNonEcsData[]) => boolean; + renderColumn: ({ + columnName, + eventId, + field, + timelineId, + truncate, + values, + linkValues, + }: { + columnName: string; + eventId: string; + field: ColumnHeaderOptions; + timelineId: string; + truncate?: boolean; + values: string[] | null | undefined; + linkValues?: string[] | null | undefined; + }) => React.ReactNode; +} diff --git a/x-pack/plugins/timelines/common/types/timeline/data_provider/index.ts b/x-pack/plugins/timelines/common/types/timeline/data_provider/index.ts new file mode 100644 index 00000000000000..d706aff6f6aa7a --- /dev/null +++ b/x-pack/plugins/timelines/common/types/timeline/data_provider/index.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** Represents the Timeline data providers */ + +/** The `is` operator in a KQL query */ +export const IS_OPERATOR = ':'; + +/** The `exists` operator in a KQL query */ +export const EXISTS_OPERATOR = ':*'; + +/** The operator applied to a field */ +export type QueryOperator = ':' | ':*'; + +export enum DataProviderType { + default = 'default', + template = 'template', +} + +export interface QueryMatch { + field: string; + displayField?: string; + value: string | number; + displayValue?: string | number; + operator: QueryOperator; +} + +export interface DataProvider { + /** Uniquely identifies a data provider */ + id: string; + /** Human readable */ + name: string; + /** + * When `false`, a data provider is temporarily disabled, but not removed from + * the timeline. default: `true` + */ + enabled: boolean; + /** + * When `true`, a data provider is excluding the match, but not removed from + * the timeline. default: `false` + */ + excluded: boolean; + /** + * Returns the KQL query who have been added by user + */ + kqlQuery: string; + /** + * Returns a query properties that, when executed, returns the data for this provider + */ + queryMatch: QueryMatch; + /** + * Additional query clauses that are ANDed with this query to narrow results + */ + and: DataProvidersAnd[]; + /** + * Returns a DataProviderType + */ + type?: DataProviderType.default | DataProviderType.template; +} + +export type DataProvidersAnd = Pick>; diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts new file mode 100644 index 00000000000000..c0bc1c305b970e --- /dev/null +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -0,0 +1,744 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as runtimeTypes from 'io-ts'; + +import { stringEnum, unionWithNullType } from '../../utility_types'; +import { NoteResult, NoteSavedObject, NoteSavedObjectToReturnRuntimeType } from './note'; +import { + PinnedEventToReturnSavedObjectRuntimeType, + PinnedEventSavedObject, + PinnedEvent, +} from './pinned_event'; +import { Direction, Maybe } from '../../search_strategy'; + +export * from './actions'; +export * from './cells'; +export * from './columns'; +export * from './data_provider'; +export * from './rows'; +export * from './store'; + +const errorSchema = runtimeTypes.exact( + runtimeTypes.type({ + error: runtimeTypes.type({ + status_code: runtimeTypes.number, + message: runtimeTypes.string, + }), + }) +); + +export type ErrorSchema = runtimeTypes.TypeOf; + +/* + * ColumnHeader Types + */ +const SavedColumnHeaderRuntimeType = runtimeTypes.partial({ + aggregatable: unionWithNullType(runtimeTypes.boolean), + category: unionWithNullType(runtimeTypes.string), + columnHeaderType: unionWithNullType(runtimeTypes.string), + description: unionWithNullType(runtimeTypes.string), + example: unionWithNullType(runtimeTypes.string), + indexes: unionWithNullType(runtimeTypes.array(runtimeTypes.string)), + id: unionWithNullType(runtimeTypes.string), + name: unionWithNullType(runtimeTypes.string), + placeholder: unionWithNullType(runtimeTypes.string), + searchable: unionWithNullType(runtimeTypes.boolean), + type: unionWithNullType(runtimeTypes.string), +}); + +/* + * DataProvider Types + */ +const SavedDataProviderQueryMatchBasicRuntimeType = runtimeTypes.partial({ + field: unionWithNullType(runtimeTypes.string), + displayField: unionWithNullType(runtimeTypes.string), + value: unionWithNullType(runtimeTypes.string), + displayValue: unionWithNullType(runtimeTypes.string), + operator: unionWithNullType(runtimeTypes.string), +}); + +const SavedDataProviderQueryMatchRuntimeType = runtimeTypes.partial({ + id: unionWithNullType(runtimeTypes.string), + name: unionWithNullType(runtimeTypes.string), + enabled: unionWithNullType(runtimeTypes.boolean), + excluded: unionWithNullType(runtimeTypes.boolean), + kqlQuery: unionWithNullType(runtimeTypes.string), + queryMatch: unionWithNullType(SavedDataProviderQueryMatchBasicRuntimeType), +}); + +export enum DataProviderType { + default = 'default', + template = 'template', +} + +export const DataProviderTypeLiteralRt = runtimeTypes.union([ + runtimeTypes.literal(DataProviderType.default), + runtimeTypes.literal(DataProviderType.template), +]); + +const SavedDataProviderRuntimeType = runtimeTypes.partial({ + id: unionWithNullType(runtimeTypes.string), + name: unionWithNullType(runtimeTypes.string), + enabled: unionWithNullType(runtimeTypes.boolean), + excluded: unionWithNullType(runtimeTypes.boolean), + kqlQuery: unionWithNullType(runtimeTypes.string), + queryMatch: unionWithNullType(SavedDataProviderQueryMatchBasicRuntimeType), + and: unionWithNullType(runtimeTypes.array(SavedDataProviderQueryMatchRuntimeType)), + type: unionWithNullType(DataProviderTypeLiteralRt), +}); + +/* + * Filters Types + */ +const SavedFilterMetaRuntimeType = runtimeTypes.partial({ + alias: unionWithNullType(runtimeTypes.string), + controlledBy: unionWithNullType(runtimeTypes.string), + disabled: unionWithNullType(runtimeTypes.boolean), + field: unionWithNullType(runtimeTypes.string), + formattedValue: unionWithNullType(runtimeTypes.string), + index: unionWithNullType(runtimeTypes.string), + key: unionWithNullType(runtimeTypes.string), + negate: unionWithNullType(runtimeTypes.boolean), + params: unionWithNullType(runtimeTypes.string), + type: unionWithNullType(runtimeTypes.string), + value: unionWithNullType(runtimeTypes.string), +}); + +const SavedFilterRuntimeType = runtimeTypes.partial({ + exists: unionWithNullType(runtimeTypes.string), + meta: unionWithNullType(SavedFilterMetaRuntimeType), + match_all: unionWithNullType(runtimeTypes.string), + missing: unionWithNullType(runtimeTypes.string), + query: unionWithNullType(runtimeTypes.string), + range: unionWithNullType(runtimeTypes.string), + script: unionWithNullType(runtimeTypes.string), +}); + +/* + * eqlOptionsQuery -> filterQuery Types + */ +const EqlOptionsRuntimeType = runtimeTypes.partial({ + eventCategoryField: unionWithNullType(runtimeTypes.string), + query: unionWithNullType(runtimeTypes.string), + tiebreakerField: unionWithNullType(runtimeTypes.string), + timestampField: unionWithNullType(runtimeTypes.string), + size: unionWithNullType(runtimeTypes.union([runtimeTypes.string, runtimeTypes.number])), +}); + +/* + * kqlQuery -> filterQuery Types + */ +const SavedKueryFilterQueryRuntimeType = runtimeTypes.partial({ + kind: unionWithNullType(runtimeTypes.string), + expression: unionWithNullType(runtimeTypes.string), +}); + +const SavedSerializedFilterQueryQueryRuntimeType = runtimeTypes.partial({ + kuery: unionWithNullType(SavedKueryFilterQueryRuntimeType), + serializedQuery: unionWithNullType(runtimeTypes.string), +}); + +const SavedFilterQueryQueryRuntimeType = runtimeTypes.partial({ + filterQuery: unionWithNullType(SavedSerializedFilterQueryQueryRuntimeType), +}); + +/* + * DatePicker Range Types + */ +const SavedDateRangePickerRuntimeType = runtimeTypes.partial({ + /* Before the change of all timestamp to ISO string the values of start and from + * attributes where a number. Specifically UNIX timestamps. + * To support old timeline's saved object we need to add the number io-ts type + */ + start: unionWithNullType(runtimeTypes.union([runtimeTypes.string, runtimeTypes.number])), + end: unionWithNullType(runtimeTypes.union([runtimeTypes.string, runtimeTypes.number])), +}); + +/* + * Favorite Types + */ +const SavedFavoriteRuntimeType = runtimeTypes.partial({ + keySearch: unionWithNullType(runtimeTypes.string), + favoriteDate: unionWithNullType(runtimeTypes.number), + fullName: unionWithNullType(runtimeTypes.string), + userName: unionWithNullType(runtimeTypes.string), +}); + +/* + * Sort Types + */ + +const SavedSortObject = runtimeTypes.partial({ + columnId: unionWithNullType(runtimeTypes.string), + columnType: unionWithNullType(runtimeTypes.string), + sortDirection: unionWithNullType(runtimeTypes.string), +}); +const SavedSortRuntimeType = runtimeTypes.union([ + runtimeTypes.array(SavedSortObject), + SavedSortObject, +]); + +export type Sort = runtimeTypes.TypeOf; + +/* + * Timeline Statuses + */ + +export enum TimelineStatus { + active = 'active', + draft = 'draft', + immutable = 'immutable', +} + +export const TimelineStatusLiteralRt = runtimeTypes.union([ + runtimeTypes.literal(TimelineStatus.active), + runtimeTypes.literal(TimelineStatus.draft), + runtimeTypes.literal(TimelineStatus.immutable), +]); + +const TimelineStatusLiteralWithNullRt = unionWithNullType(TimelineStatusLiteralRt); + +export type TimelineStatusLiteral = runtimeTypes.TypeOf; +export type TimelineStatusLiteralWithNull = runtimeTypes.TypeOf< + typeof TimelineStatusLiteralWithNullRt +>; + +export enum RowRendererId { + alerts = 'alerts', + auditd = 'auditd', + auditd_file = 'auditd_file', + library = 'library', + netflow = 'netflow', + plain = 'plain', + registry = 'registry', + suricata = 'suricata', + system = 'system', + system_dns = 'system_dns', + system_endgame_process = 'system_endgame_process', + system_file = 'system_file', + system_fim = 'system_fim', + system_security_event = 'system_security_event', + system_socket = 'system_socket', + threat_match = 'threat_match', + zeek = 'zeek', +} + +export const RowRendererIdRuntimeType = stringEnum(RowRendererId, 'RowRendererId'); + +/** + * Timeline template type + */ + +export enum TemplateTimelineType { + elastic = 'elastic', + custom = 'custom', +} + +export const TemplateTimelineTypeLiteralRt = runtimeTypes.union([ + runtimeTypes.literal(TemplateTimelineType.elastic), + runtimeTypes.literal(TemplateTimelineType.custom), +]); + +export const TemplateTimelineTypeLiteralWithNullRt = unionWithNullType( + TemplateTimelineTypeLiteralRt +); + +export type TemplateTimelineTypeLiteral = runtimeTypes.TypeOf; +export type TemplateTimelineTypeLiteralWithNull = runtimeTypes.TypeOf< + typeof TemplateTimelineTypeLiteralWithNullRt +>; + +/* + * Timeline Types + */ + +export enum TimelineType { + default = 'default', + template = 'template', + test = 'test', +} + +export const TimelineTypeLiteralRt = runtimeTypes.union([ + runtimeTypes.literal(TimelineType.template), + runtimeTypes.literal(TimelineType.default), + runtimeTypes.literal(TimelineType.test), +]); + +export const TimelineTypeLiteralWithNullRt = unionWithNullType(TimelineTypeLiteralRt); + +export type TimelineTypeLiteral = runtimeTypes.TypeOf; +export type TimelineTypeLiteralWithNull = runtimeTypes.TypeOf; + +export const SavedTimelineRuntimeType = runtimeTypes.partial({ + columns: unionWithNullType(runtimeTypes.array(SavedColumnHeaderRuntimeType)), + dataProviders: unionWithNullType(runtimeTypes.array(SavedDataProviderRuntimeType)), + description: unionWithNullType(runtimeTypes.string), + eqlOptions: unionWithNullType(EqlOptionsRuntimeType), + eventType: unionWithNullType(runtimeTypes.string), + excludedRowRendererIds: unionWithNullType(runtimeTypes.array(RowRendererIdRuntimeType)), + favorite: unionWithNullType(runtimeTypes.array(SavedFavoriteRuntimeType)), + filters: unionWithNullType(runtimeTypes.array(SavedFilterRuntimeType)), + indexNames: unionWithNullType(runtimeTypes.array(runtimeTypes.string)), + kqlMode: unionWithNullType(runtimeTypes.string), + kqlQuery: unionWithNullType(SavedFilterQueryQueryRuntimeType), + title: unionWithNullType(runtimeTypes.string), + templateTimelineId: unionWithNullType(runtimeTypes.string), + templateTimelineVersion: unionWithNullType(runtimeTypes.number), + timelineType: unionWithNullType(TimelineTypeLiteralRt), + dateRange: unionWithNullType(SavedDateRangePickerRuntimeType), + savedQueryId: unionWithNullType(runtimeTypes.string), + sort: unionWithNullType(SavedSortRuntimeType), + status: unionWithNullType(TimelineStatusLiteralRt), + created: unionWithNullType(runtimeTypes.number), + createdBy: unionWithNullType(runtimeTypes.string), + updated: unionWithNullType(runtimeTypes.number), + updatedBy: unionWithNullType(runtimeTypes.string), +}); + +export type SavedTimeline = runtimeTypes.TypeOf; + +export type SavedTimelineNote = runtimeTypes.TypeOf; + +/* + * Timeline IDs + */ + +export enum TimelineId { + hostsPageEvents = 'hosts-page-events', + hostsPageExternalAlerts = 'hosts-page-external-alerts', + detectionsRulesDetailsPage = 'detections-rules-details-page', + detectionsPage = 'detections-page', + networkPageExternalAlerts = 'network-page-external-alerts', + active = 'timeline-1', + casePage = 'timeline-case', + test = 'test', // Reserved for testing purposes + alternateTest = 'alternateTest', +} + +export const TimelineIdLiteralRt = runtimeTypes.union([ + runtimeTypes.literal(TimelineId.hostsPageEvents), + runtimeTypes.literal(TimelineId.hostsPageExternalAlerts), + runtimeTypes.literal(TimelineId.detectionsRulesDetailsPage), + runtimeTypes.literal(TimelineId.detectionsPage), + runtimeTypes.literal(TimelineId.networkPageExternalAlerts), + runtimeTypes.literal(TimelineId.active), + runtimeTypes.literal(TimelineId.test), +]); + +export type TimelineIdLiteral = runtimeTypes.TypeOf; + +/** + * Timeline Saved object type with metadata + */ + +export const TimelineSavedObjectRuntimeType = runtimeTypes.intersection([ + runtimeTypes.type({ + id: runtimeTypes.string, + attributes: SavedTimelineRuntimeType, + version: runtimeTypes.string, + }), + runtimeTypes.partial({ + savedObjectId: runtimeTypes.string, + }), +]); + +export const TimelineSavedToReturnObjectRuntimeType = runtimeTypes.intersection([ + SavedTimelineRuntimeType, + runtimeTypes.type({ + savedObjectId: runtimeTypes.string, + version: runtimeTypes.string, + }), + runtimeTypes.partial({ + eventIdToNoteIds: runtimeTypes.array(NoteSavedObjectToReturnRuntimeType), + noteIds: runtimeTypes.array(runtimeTypes.string), + notes: runtimeTypes.array(NoteSavedObjectToReturnRuntimeType), + pinnedEventIds: runtimeTypes.array(runtimeTypes.string), + pinnedEventsSaveObject: runtimeTypes.array(PinnedEventToReturnSavedObjectRuntimeType), + }), +]); + +export type TimelineSavedObject = runtimeTypes.TypeOf< + typeof TimelineSavedToReturnObjectRuntimeType +>; + +export const SingleTimelineResponseType = runtimeTypes.type({ + data: runtimeTypes.type({ + getOneTimeline: TimelineSavedToReturnObjectRuntimeType, + }), +}); + +export type SingleTimelineResponse = runtimeTypes.TypeOf; + +/** + * All Timeline Saved object type with metadata + */ +export const TimelineResponseType = runtimeTypes.type({ + data: runtimeTypes.type({ + persistTimeline: runtimeTypes.intersection([ + runtimeTypes.partial({ + code: unionWithNullType(runtimeTypes.number), + message: unionWithNullType(runtimeTypes.string), + }), + runtimeTypes.type({ + timeline: TimelineSavedToReturnObjectRuntimeType, + }), + ]), + }), +}); + +export const TimelineErrorResponseType = runtimeTypes.type({ + status_code: runtimeTypes.number, + message: runtimeTypes.string, +}); + +export type TimelineErrorResponse = runtimeTypes.TypeOf; +export type TimelineResponse = runtimeTypes.TypeOf; + +/** + * All Timeline Saved object type with metadata + */ + +export const AllTimelineSavedObjectRuntimeType = runtimeTypes.type({ + total: runtimeTypes.number, + data: TimelineSavedToReturnObjectRuntimeType, +}); + +export type AllTimelineSavedObject = runtimeTypes.TypeOf; + +/** + * Import/export timelines + */ + +export type ExportedGlobalNotes = Array>; +export type ExportedEventNotes = NoteSavedObject[]; + +export interface ExportedNotes { + eventNotes: ExportedEventNotes; + globalNotes: ExportedGlobalNotes; +} + +export type ExportedTimelines = TimelineSavedObject & + ExportedNotes & { + pinnedEventIds: string[]; + }; + +export interface ExportTimelineNotFoundError { + statusCode: number; + message: string; +} + +export interface BulkGetInput { + type: string; + id: string; +} + +export type NotesAndPinnedEventsByTimelineId = Record< + string, + { notes: NoteSavedObject[]; pinnedEvents: PinnedEventSavedObject[] } +>; + +export const importTimelineResultSchema = runtimeTypes.exact( + runtimeTypes.type({ + success: runtimeTypes.boolean, + success_count: runtimeTypes.number, + timelines_installed: runtimeTypes.number, + timelines_updated: runtimeTypes.number, + errors: runtimeTypes.array(errorSchema), + }) +); + +export type ImportTimelineResultSchema = runtimeTypes.TypeOf; + +export type TimelineEventsType = 'all' | 'raw' | 'alert' | 'signal' | 'custom' | 'eql'; + +export enum TimelineTabs { + query = 'query', + graph = 'graph', + notes = 'notes', + pinned = 'pinned', + eql = 'eql', +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EmptyObject = Record; + +export type TimelineExpandedEventType = + | { + panelView?: 'eventDetail'; + params?: { + eventId: string; + indexName: string; + }; + } + | EmptyObject; + +export type TimelineExpandedHostType = + | { + panelView?: 'hostDetail'; + params?: { + hostName: string; + }; + } + | EmptyObject; + +enum FlowTarget { + client = 'client', + destination = 'destination', + server = 'server', + source = 'source', +} + +export type TimelineExpandedNetworkType = + | { + panelView?: 'networkDetail'; + params?: { + ip: string; + flowTarget: FlowTarget; + }; + } + | EmptyObject; + +export type TimelineExpandedDetailType = + | TimelineExpandedEventType + | TimelineExpandedHostType + | TimelineExpandedNetworkType; + +export type TimelineExpandedDetail = { + [tab in TimelineTabs]?: TimelineExpandedDetailType; +}; + +export type ToggleDetailPanel = TimelineExpandedDetailType & { + tabType?: TimelineTabs; + timelineId: string; +}; + +export const pageInfoTimeline = runtimeTypes.type({ + pageIndex: runtimeTypes.number, + pageSize: runtimeTypes.number, +}); + +export enum SortFieldTimeline { + title = 'title', + description = 'description', + updated = 'updated', + created = 'created', +} + +export const sortFieldTimeline = runtimeTypes.union([ + runtimeTypes.literal(SortFieldTimeline.title), + runtimeTypes.literal(SortFieldTimeline.description), + runtimeTypes.literal(SortFieldTimeline.updated), + runtimeTypes.literal(SortFieldTimeline.created), +]); + +export const direction = runtimeTypes.union([ + runtimeTypes.literal(Direction.asc), + runtimeTypes.literal(Direction.desc), +]); + +export const sortTimeline = runtimeTypes.type({ + sortField: sortFieldTimeline, + sortOrder: direction, +}); + +const favoriteTimelineResult = runtimeTypes.partial({ + fullName: unionWithNullType(runtimeTypes.string), + userName: unionWithNullType(runtimeTypes.string), + favoriteDate: unionWithNullType(runtimeTypes.number), +}); + +export type FavoriteTimelineResult = runtimeTypes.TypeOf; + +export const responseFavoriteTimeline = runtimeTypes.partial({ + savedObjectId: runtimeTypes.string, + version: runtimeTypes.string, + code: unionWithNullType(runtimeTypes.number), + message: unionWithNullType(runtimeTypes.string), + templateTimelineId: unionWithNullType(runtimeTypes.string), + templateTimelineVersion: unionWithNullType(runtimeTypes.number), + timelineType: unionWithNullType(TimelineTypeLiteralRt), + favorite: unionWithNullType(runtimeTypes.array(favoriteTimelineResult)), +}); + +export type ResponseFavoriteTimeline = runtimeTypes.TypeOf; + +export const getTimelinesArgs = runtimeTypes.partial({ + onlyUserFavorite: unionWithNullType(runtimeTypes.boolean), + pageInfo: unionWithNullType(pageInfoTimeline), + search: unionWithNullType(runtimeTypes.string), + sort: unionWithNullType(sortTimeline), + status: unionWithNullType(TimelineStatusLiteralRt), + timelineType: unionWithNullType(TimelineTypeLiteralRt), +}); + +export type GetTimelinesArgs = runtimeTypes.TypeOf; + +const responseTimelines = runtimeTypes.type({ + timeline: runtimeTypes.array(TimelineSavedToReturnObjectRuntimeType), + totalCount: runtimeTypes.number, +}); + +export type ResponseTimelines = runtimeTypes.TypeOf; + +export const allTimelinesResponse = runtimeTypes.intersection([ + responseTimelines, + runtimeTypes.type({ + defaultTimelineCount: runtimeTypes.number, + templateTimelineCount: runtimeTypes.number, + elasticTemplateTimelineCount: runtimeTypes.number, + customTemplateTimelineCount: runtimeTypes.number, + favoriteCount: runtimeTypes.number, + }), +]); + +export type AllTimelinesResponse = runtimeTypes.TypeOf; + +export interface PageInfoTimeline { + pageIndex: number; + + pageSize: number; +} + +export interface ColumnHeaderResult { + aggregatable?: Maybe; + category?: Maybe; + columnHeaderType?: Maybe; + description?: Maybe; + example?: Maybe; + indexes?: Maybe; + id?: Maybe; + name?: Maybe; + placeholder?: Maybe; + searchable?: Maybe; + type?: Maybe; +} + +export interface DataProviderResult { + id?: Maybe; + name?: Maybe; + enabled?: Maybe; + excluded?: Maybe; + kqlQuery?: Maybe; + queryMatch?: Maybe; + type?: Maybe; + and?: Maybe; +} + +export interface QueryMatchResult { + field?: Maybe; + displayField?: Maybe; + value?: Maybe; + displayValue?: Maybe; + operator?: Maybe; +} + +export interface DateRangePickerResult { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + start?: Maybe; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + end?: Maybe; +} + +export interface EqlOptionsResult { + eventCategoryField?: Maybe; + tiebreakerField?: Maybe; + timestampField?: Maybe; + query?: Maybe; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + size?: Maybe; +} + +export interface FilterTimelineResult { + exists?: Maybe; + meta?: Maybe; + match_all?: Maybe; + missing?: Maybe; + query?: Maybe; + range?: Maybe; + script?: Maybe; +} + +export interface FilterMetaTimelineResult { + alias?: Maybe; + controlledBy?: Maybe; + disabled?: Maybe; + field?: Maybe; + formattedValue?: Maybe; + index?: Maybe; + key?: Maybe; + negate?: Maybe; + params?: Maybe; + type?: Maybe; + value?: Maybe; +} + +export interface SerializedFilterQueryResult { + filterQuery?: Maybe; +} + +export interface SerializedKueryQueryResult { + kuery?: Maybe; + serializedQuery?: Maybe; +} + +export interface KueryFilterQueryResult { + kind?: Maybe; + expression?: Maybe; +} + +export interface TimelineResult { + columns?: Maybe; + created?: Maybe; + createdBy?: Maybe; + dataProviders?: Maybe; + dateRange?: Maybe; + description?: Maybe; + eqlOptions?: Maybe; + eventIdToNoteIds?: Maybe; + eventType?: Maybe; + excludedRowRendererIds?: Maybe; + favorite?: Maybe; + filters?: Maybe; + kqlMode?: Maybe; + kqlQuery?: Maybe; + indexNames?: Maybe; + notes?: Maybe; + noteIds?: Maybe; + pinnedEventIds?: Maybe; + pinnedEventsSaveObject?: Maybe; + savedQueryId?: Maybe; + savedObjectId: string; + sort?: Maybe; + status?: Maybe; + title?: Maybe; + templateTimelineId?: Maybe; + templateTimelineVersion?: Maybe; + timelineType?: Maybe; + updated?: Maybe; + updatedBy?: Maybe; + version: string; +} + +export interface ResponseTimeline { + code?: Maybe; + message?: Maybe; + timeline: TimelineResult; +} +export interface SortTimeline { + sortField: SortFieldTimeline; + sortOrder: Direction; +} + +export interface GetAllTimelineVariables { + pageInfo: PageInfoTimeline; + search?: Maybe; + sort?: Maybe; + onlyUserFavorite?: Maybe; + timelineType?: Maybe; + status?: Maybe; +} diff --git a/x-pack/plugins/timelines/common/types/timeline/note/index.ts b/x-pack/plugins/timelines/common/types/timeline/note/index.ts new file mode 100644 index 00000000000000..074e4132efdffb --- /dev/null +++ b/x-pack/plugins/timelines/common/types/timeline/note/index.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-empty-interface */ + +import * as runtimeTypes from 'io-ts'; +import { Direction, Maybe } from '../../../search_strategy/common'; + +import { unionWithNullType } from '../../../utility_types'; + +/* + * Note Types + */ +export const SavedNoteRuntimeType = runtimeTypes.intersection([ + runtimeTypes.type({ + timelineId: unionWithNullType(runtimeTypes.string), + }), + runtimeTypes.partial({ + eventId: unionWithNullType(runtimeTypes.string), + note: unionWithNullType(runtimeTypes.string), + created: unionWithNullType(runtimeTypes.number), + createdBy: unionWithNullType(runtimeTypes.string), + updated: unionWithNullType(runtimeTypes.number), + updatedBy: unionWithNullType(runtimeTypes.string), + }), +]); + +export interface SavedNote extends runtimeTypes.TypeOf {} + +/** + * Note Saved object type with metadata + */ + +export const NoteSavedObjectRuntimeType = runtimeTypes.intersection([ + runtimeTypes.type({ + id: runtimeTypes.string, + attributes: SavedNoteRuntimeType, + version: runtimeTypes.string, + }), + runtimeTypes.partial({ + noteId: runtimeTypes.string, + timelineVersion: runtimeTypes.union([ + runtimeTypes.string, + runtimeTypes.null, + runtimeTypes.undefined, + ]), + }), +]); + +export const NoteSavedObjectToReturnRuntimeType = runtimeTypes.intersection([ + SavedNoteRuntimeType, + runtimeTypes.type({ + noteId: runtimeTypes.string, + version: runtimeTypes.string, + }), + runtimeTypes.partial({ + timelineVersion: unionWithNullType(runtimeTypes.string), + }), +]); + +export interface NoteSavedObject + extends runtimeTypes.TypeOf {} + +export enum SortFieldNote { + updatedBy = 'updatedBy', + updated = 'updated', +} + +export const pageInfoNoteRt = runtimeTypes.type({ + pageIndex: runtimeTypes.number, + pageSize: runtimeTypes.number, +}); + +export type PageInfoNote = runtimeTypes.TypeOf; + +export const sortNoteRt = runtimeTypes.type({ + sortField: runtimeTypes.union([ + runtimeTypes.literal(SortFieldNote.updatedBy), + runtimeTypes.literal(SortFieldNote.updated), + ]), + sortOrder: runtimeTypes.union([ + runtimeTypes.literal(Direction.asc), + runtimeTypes.literal(Direction.desc), + ]), +}); + +export type SortNote = runtimeTypes.TypeOf; + +export interface NoteResult { + eventId?: Maybe; + + note?: Maybe; + + timelineId?: Maybe; + + noteId: string; + + created?: Maybe; + + createdBy?: Maybe; + + timelineVersion?: Maybe; + + updated?: Maybe; + + updatedBy?: Maybe; + + version?: Maybe; +} + +export interface ResponseNotes { + notes: NoteResult[]; + + totalCount?: Maybe; +} + +export interface ResponseNote { + code?: Maybe; + + message?: Maybe; + + note: NoteResult; +} diff --git a/x-pack/plugins/timelines/common/types/timeline/pinned_event/index.ts b/x-pack/plugins/timelines/common/types/timeline/pinned_event/index.ts new file mode 100644 index 00000000000000..dbb19df7a6b05a --- /dev/null +++ b/x-pack/plugins/timelines/common/types/timeline/pinned_event/index.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-empty-interface */ + +import * as runtimeTypes from 'io-ts'; +import { Maybe } from '../../../search_strategy/common'; + +import { unionWithNullType } from '../../../utility_types'; + +/* + * Note Types + */ +export const SavedPinnedEventRuntimeType = runtimeTypes.intersection([ + runtimeTypes.type({ + timelineId: runtimeTypes.string, + eventId: runtimeTypes.string, + }), + runtimeTypes.partial({ + created: unionWithNullType(runtimeTypes.number), + createdBy: unionWithNullType(runtimeTypes.string), + updated: unionWithNullType(runtimeTypes.number), + updatedBy: unionWithNullType(runtimeTypes.string), + }), +]); + +export interface SavedPinnedEvent extends runtimeTypes.TypeOf {} + +/** + * Note Saved object type with metadata + */ + +export const PinnedEventSavedObjectRuntimeType = runtimeTypes.intersection([ + runtimeTypes.type({ + id: runtimeTypes.string, + attributes: SavedPinnedEventRuntimeType, + version: runtimeTypes.string, + }), + runtimeTypes.partial({ + pinnedEventId: unionWithNullType(runtimeTypes.string), + timelineVersion: unionWithNullType(runtimeTypes.string), + }), +]); + +export const PinnedEventToReturnSavedObjectRuntimeType = runtimeTypes.intersection([ + runtimeTypes.type({ + pinnedEventId: runtimeTypes.string, + version: runtimeTypes.string, + }), + SavedPinnedEventRuntimeType, + runtimeTypes.partial({ + timelineVersion: unionWithNullType(runtimeTypes.string), + }), +]); + +export interface PinnedEventSavedObject + extends runtimeTypes.TypeOf {} + +export interface PinnedEvent { + code?: Maybe; + + message?: Maybe; + + pinnedEventId: string; + + eventId?: Maybe; + + timelineId?: Maybe; + + timelineVersion?: Maybe; + + created?: Maybe; + + createdBy?: Maybe; + + updated?: Maybe; + + updatedBy?: Maybe; + + version?: Maybe; +} diff --git a/x-pack/plugins/timelines/common/types/timeline/rows/index.ts b/x-pack/plugins/timelines/common/types/timeline/rows/index.ts new file mode 100644 index 00000000000000..b598d132737981 --- /dev/null +++ b/x-pack/plugins/timelines/common/types/timeline/rows/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RowRendererId } from '..'; +import { Ecs } from '../../../ecs'; +import { BrowserFields } from '../../../search_strategy/index_fields'; + +export interface RowRenderer { + id: RowRendererId; + isInstance: (data: Ecs) => boolean; + renderRow: ({ + browserFields, + data, + timelineId, + }: { + browserFields: BrowserFields; + data: Ecs; + timelineId: string; + }) => React.ReactNode; +} diff --git a/x-pack/plugins/timelines/common/types/timeline/store.ts b/x-pack/plugins/timelines/common/types/timeline/store.ts new file mode 100644 index 00000000000000..8e3a9fda9475cb --- /dev/null +++ b/x-pack/plugins/timelines/common/types/timeline/store.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ColumnHeaderOptions, + ColumnId, + RowRendererId, + Sort, + TimelineExpandedDetail, + TimelineTypeLiteral, +} from '.'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Filter } from '../../../../../../src/plugins/data/public'; + +import { Direction } from '../../search_strategy'; +import { DataProvider } from './data_provider'; + +export type KueryFilterQueryKind = 'kuery' | 'lucene' | 'eql'; + +export interface KueryFilterQuery { + kind: KueryFilterQueryKind; + expression: string; +} + +export interface SerializedFilterQuery { + kuery: KueryFilterQuery | null; + serializedQuery: string; +} + +export type SortDirection = 'none' | 'asc' | 'desc' | Direction; +export interface SortColumnTimeline { + columnId: string; + columnType: string; + sortDirection: SortDirection; +} + +export interface TimelinePersistInput { + id: string; + dataProviders?: DataProvider[]; + dateRange?: { + start: string; + end: string; + }; + excludedRowRendererIds?: RowRendererId[]; + expandedDetail?: TimelineExpandedDetail; + filters?: Filter[]; + columns: ColumnHeaderOptions[]; + itemsPerPage?: number; + indexNames: string[]; + kqlQuery?: { + filterQuery: SerializedFilterQuery | null; + }; + show?: boolean; + sort?: Sort[]; + showCheckboxes?: boolean; + timelineType?: TimelineTypeLiteral; + templateTimelineId?: string | null; + templateTimelineVersion?: number | null; +} + +/** Invoked when a column is sorted */ +export type OnColumnSorted = (sorted: { columnId: ColumnId; sortDirection: SortDirection }) => void; + +export type OnColumnsSorted = ( + sorted: Array<{ columnId: ColumnId; sortDirection: SortDirection }> +) => void; + +export type OnColumnRemoved = (columnId: ColumnId) => void; + +export type OnColumnResized = ({ columnId, delta }: { columnId: ColumnId; delta: number }) => void; + +/** Invoked when a user clicks to load more item */ +export type OnChangePage = (nextPage: number) => void; + +/** Invoked when a user checks/un-checks a row */ +export type OnRowSelected = ({ + eventIds, + isSelected, +}: { + eventIds: string[]; + isSelected: boolean; +}) => void; + +/** Invoked when a user checks/un-checks the select all checkbox */ +export type OnSelectAll = ({ isSelected }: { isSelected: boolean }) => void; + +/** Invoked when columns are updated */ +export type OnUpdateColumns = (columns: ColumnHeaderOptions[]) => void; + +/** Invoked when a user pins an event */ +export type OnPinEvent = (eventId: string) => void; + +/** Invoked when a user unpins an event */ +export type OnUnPinEvent = (eventId: string) => void; diff --git a/x-pack/plugins/timelines/common/utility_types.ts b/x-pack/plugins/timelines/common/utility_types.ts new file mode 100644 index 00000000000000..498b18dccaca56 --- /dev/null +++ b/x-pack/plugins/timelines/common/utility_types.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as runtimeTypes from 'io-ts'; +import { ReactNode } from 'react'; + +// This type is for typing EuiDescriptionList +export interface DescriptionList { + title: NonNullable; + description: NonNullable; +} + +export const unionWithNullType = (type: T) => + runtimeTypes.union([type, runtimeTypes.null]); + +export const stringEnum = (enumObj: T, enumName = 'enum') => + new runtimeTypes.Type( + enumName, + (u): u is T[keyof T] => Object.values(enumObj).includes(u), + (u, c) => + Object.values(enumObj).includes(u) + ? runtimeTypes.success(u as T[keyof T]) + : runtimeTypes.failure(u, c), + (a) => (a as unknown) as string + ); + +/** + * Unreachable Assertion helper for scenarios like exhaustive switches. + * For references see: https://stackoverflow.com/questions/39419170/how-do-i-check-that-a-switch-block-is-exhaustive-in-typescript + * This "x" should _always_ be a type of "never" and not change to "unknown" or any other type. See above link or the generic + * concept of exhaustive checks in switch blocks. + * + * Optionally you can avoid the use of this by using early returns and TypeScript will clear your type checking without complaints + * but there are situations and times where this function might still be needed. + * + * If you see an error, DO NOT cast "as never" such as: + * assertUnreachable(x as never) // BUG IN YOUR CODE NOW AND IT WILL THROW DURING RUNTIME + * If you see code like that remove it, as that deactivates the intent of this utility. + * If you need to do that, then you should remove assertUnreachable from your code and + * use a default at the end of the switch instead. + * @param x Unreachable field + * @param message Message of error thrown + */ +export const assertUnreachable = ( + x: never, // This should always be a type of "never" + message = 'Unknown Field in switch statement' +): never => { + throw new Error(`${message}: ${x}`); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/accessibility/helpers.test.tsx b/x-pack/plugins/timelines/common/utils/accessibility/helpers.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/common/components/accessibility/helpers.test.tsx rename to x-pack/plugins/timelines/common/utils/accessibility/helpers.test.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/accessibility/helpers.ts b/x-pack/plugins/timelines/common/utils/accessibility/helpers.ts similarity index 99% rename from x-pack/plugins/security_solution/public/common/components/accessibility/helpers.ts rename to x-pack/plugins/timelines/common/utils/accessibility/helpers.ts index a1ee9c3cc3bd5b..e877edd28458bd 100644 --- a/x-pack/plugins/security_solution/public/common/components/accessibility/helpers.ts +++ b/x-pack/plugins/timelines/common/utils/accessibility/helpers.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '../drag_and_drop/helpers'; +import React from 'react'; import { + DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, + HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME, NOTES_CONTAINER_CLASS_NAME, NOTE_CONTENT_CLASS_NAME, ROW_RENDERER_CLASS_NAME, -} from '../../../timelines/components/timeline/body/helpers'; -import { HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME } from '../with_hover_actions'; - +} from '@kbn/securitysolution-t-grid'; /** * The name of the ARIA attribute representing a column, used in conjunction with * the ARIA: grid role https://www.w3.org/TR/wai-aria-practices-1.1/examples/grid/dataGrids.html diff --git a/x-pack/plugins/timelines/common/utils/accessibility/index.ts b/x-pack/plugins/timelines/common/utils/accessibility/index.ts new file mode 100644 index 00000000000000..6c315f929b9bbd --- /dev/null +++ b/x-pack/plugins/timelines/common/utils/accessibility/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './helpers'; diff --git a/x-pack/plugins/security_solution/public/common/utils/api/index.ts b/x-pack/plugins/timelines/common/utils/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/utils/api/index.ts rename to x-pack/plugins/timelines/common/utils/api.ts diff --git a/x-pack/plugins/timelines/common/utils/field_formatters.test.ts b/x-pack/plugins/timelines/common/utils/field_formatters.test.ts new file mode 100644 index 00000000000000..50a3117e53b9ba --- /dev/null +++ b/x-pack/plugins/timelines/common/utils/field_formatters.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { eventDetailsFormattedFields, eventHit } from '@kbn/securitysolution-t-grid'; +import { EventHit, EventSource } from '../search_strategy'; +import { getDataFromFieldsHits, getDataFromSourceHits, getDataSafety } from './field_formatters'; + +describe('Events Details Helpers', () => { + const fields: EventHit['fields'] = eventHit.fields; + const resultFields = eventDetailsFormattedFields; + describe('#getDataFromFieldsHits', () => { + it('happy path', () => { + const result = getDataFromFieldsHits(fields); + expect(result).toEqual(resultFields); + }); + it('lets get weird', () => { + const whackFields = { + 'crazy.pants': [ + { + 'matched.field': ['matched_field'], + first_seen: ['2021-02-22T17:29:25.195Z'], + provider: ['yourself'], + type: ['custom'], + 'matched.atomic': ['matched_atomic'], + lazer: [ + { + 'great.field': ['grrrrr'], + lazer: [ + { + lazer: [ + { + cool: true, + lazer: [ + { + lazer: [ + { + lazer: [ + { + lazer: [ + { + whoa: false, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + lazer: [ + { + cool: false, + }, + ], + }, + ], + }, + { + 'great.field': ['grrrrr_2'], + }, + ], + }, + ], + }; + const whackResultFields = [ + { + category: 'crazy', + field: 'crazy.pants.matched.field', + values: ['matched_field'], + originalValue: ['matched_field'], + isObjectArray: false, + }, + { + category: 'crazy', + field: 'crazy.pants.first_seen', + values: ['2021-02-22T17:29:25.195Z'], + originalValue: ['2021-02-22T17:29:25.195Z'], + isObjectArray: false, + }, + { + category: 'crazy', + field: 'crazy.pants.provider', + values: ['yourself'], + originalValue: ['yourself'], + isObjectArray: false, + }, + { + category: 'crazy', + field: 'crazy.pants.type', + values: ['custom'], + originalValue: ['custom'], + isObjectArray: false, + }, + { + category: 'crazy', + field: 'crazy.pants.matched.atomic', + values: ['matched_atomic'], + originalValue: ['matched_atomic'], + isObjectArray: false, + }, + { + category: 'crazy', + field: 'crazy.pants.lazer.great.field', + values: ['grrrrr', 'grrrrr_2'], + originalValue: ['grrrrr', 'grrrrr_2'], + isObjectArray: false, + }, + { + category: 'crazy', + field: 'crazy.pants.lazer.lazer.lazer.cool', + values: ['true', 'false'], + originalValue: ['true', 'false'], + isObjectArray: false, + }, + { + category: 'crazy', + field: 'crazy.pants.lazer.lazer.lazer.lazer.lazer.lazer.lazer.whoa', + values: ['false'], + originalValue: ['false'], + isObjectArray: false, + }, + ]; + const result = getDataFromFieldsHits(whackFields); + expect(result).toEqual(whackResultFields); + }); + }); + it('#getDataFromSourceHits', () => { + const _source: EventSource = { + '@timestamp': '2021-02-24T00:41:06.527Z', + 'signal.status': 'open', + 'signal.rule.name': 'Rawr', + 'threat.indicator': [ + { + provider: 'yourself', + type: 'custom', + first_seen: ['2021-02-22T17:29:25.195Z'], + matched: { atomic: 'atom', field: 'field', type: 'type' }, + }, + { + provider: 'other_you', + type: 'custom', + first_seen: '2021-02-22T17:29:25.195Z', + matched: { atomic: 'atom', field: 'field', type: 'type' }, + }, + ], + }; + expect(getDataFromSourceHits(_source)).toEqual([ + { + category: 'base', + field: '@timestamp', + values: ['2021-02-24T00:41:06.527Z'], + originalValue: ['2021-02-24T00:41:06.527Z'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.status', + values: ['open'], + originalValue: ['open'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.name', + values: ['Rawr'], + originalValue: ['Rawr'], + isObjectArray: false, + }, + { + category: 'threat', + field: 'threat.indicator', + values: [ + '{"provider":"yourself","type":"custom","first_seen":["2021-02-22T17:29:25.195Z"],"matched":{"atomic":"atom","field":"field","type":"type"}}', + '{"provider":"other_you","type":"custom","first_seen":"2021-02-22T17:29:25.195Z","matched":{"atomic":"atom","field":"field","type":"type"}}', + ], + originalValue: [ + '{"provider":"yourself","type":"custom","first_seen":["2021-02-22T17:29:25.195Z"],"matched":{"atomic":"atom","field":"field","type":"type"}}', + '{"provider":"other_you","type":"custom","first_seen":"2021-02-22T17:29:25.195Z","matched":{"atomic":"atom","field":"field","type":"type"}}', + ], + isObjectArray: true, + }, + ]); + }); + it('#getDataSafety', async () => { + const result = await getDataSafety(getDataFromFieldsHits, fields); + expect(result).toEqual(resultFields); + }); +}); diff --git a/x-pack/plugins/timelines/common/utils/field_formatters.ts b/x-pack/plugins/timelines/common/utils/field_formatters.ts new file mode 100644 index 00000000000000..b436f8e6161222 --- /dev/null +++ b/x-pack/plugins/timelines/common/utils/field_formatters.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get, isEmpty, isNumber, isObject, isString } from 'lodash/fp'; + +import { EventHit, EventSource, TimelineEventsDetailsItem } from '../search_strategy'; +import { toObjectArrayOfStrings, toStringArray } from './to_array'; + +export const baseCategoryFields = ['@timestamp', 'labels', 'message', 'tags']; + +export const getFieldCategory = (field: string): string => { + const fieldCategory = field.split('.')[0]; + if (!isEmpty(fieldCategory) && baseCategoryFields.includes(fieldCategory)) { + return 'base'; + } + return fieldCategory; +}; + +export const formatGeoLocation = (item: unknown[]) => { + const itemGeo = item.length > 0 ? (item[0] as { coordinates: number[] }) : null; + if (itemGeo != null && !isEmpty(itemGeo.coordinates)) { + try { + return toStringArray({ + lon: itemGeo.coordinates[0], + lat: itemGeo.coordinates[1], + }); + } catch { + return toStringArray(item); + } + } + return toStringArray(item); +}; + +export const isGeoField = (field: string) => + field.includes('geo.location') || field.includes('geoip.location'); + +export const getDataFromSourceHits = ( + sources: EventSource, + category?: string, + path?: string +): TimelineEventsDetailsItem[] => + Object.keys(sources).reduce((accumulator, source) => { + const item: EventSource = get(source, sources); + if (Array.isArray(item) || isString(item) || isNumber(item)) { + const field = path ? `${path}.${source}` : source; + const fieldCategory = getFieldCategory(field); + + const objArrStr = toObjectArrayOfStrings(item); + const strArr = objArrStr.map(({ str }) => str); + const isObjectArray = objArrStr.some((o) => o.isObjectArray); + + return [ + ...accumulator, + { + category: fieldCategory, + field, + values: strArr, + originalValue: strArr, + isObjectArray, + } as TimelineEventsDetailsItem, + ]; + } else if (isObject(item)) { + return [ + ...accumulator, + ...getDataFromSourceHits(item, category || source, path ? `${path}.${source}` : source), + ]; + } + return accumulator; + }, []); + +export const getDataFromFieldsHits = ( + fields: EventHit['fields'], + prependField?: string, + prependFieldCategory?: string +): TimelineEventsDetailsItem[] => + Object.keys(fields).reduce((accumulator, field) => { + const item: unknown[] = fields[field]; + + const fieldCategory = + prependFieldCategory != null ? prependFieldCategory : getFieldCategory(field); + if (isGeoField(field)) { + return [ + ...accumulator, + { + category: fieldCategory, + field, + values: formatGeoLocation(item), + originalValue: formatGeoLocation(item), + isObjectArray: true, // important for UI + }, + ]; + } + const objArrStr = toObjectArrayOfStrings(item); + const strArr = objArrStr.map(({ str }) => str); + const isObjectArray = objArrStr.some((o) => o.isObjectArray); + const dotField = prependField ? `${prependField}.${field}` : field; + + // return simple field value (non-object, non-array) + if (!isObjectArray) { + return [ + ...accumulator, + { + category: fieldCategory, + field: dotField, + values: strArr, + originalValue: strArr, + isObjectArray, + }, + ]; + } + + // format nested fields + const nestedFields = Array.isArray(item) + ? item + .reduce((acc, i) => [...acc, getDataFromFieldsHits(i, dotField, fieldCategory)], []) + .flat() + : getDataFromFieldsHits(item, prependField, fieldCategory); + + // combine duplicate fields + const flat: Record = [ + ...accumulator, + ...nestedFields, + ].reduce( + (acc, f) => ({ + ...acc, + // acc/flat is hashmap to determine if we already have the field or not without an array iteration + // its converted back to array in return with Object.values + ...(acc[f.field] != null + ? { + [f.field]: { + ...f, + originalValue: acc[f.field].originalValue.includes(f.originalValue[0]) + ? acc[f.field].originalValue + : [...acc[f.field].originalValue, ...f.originalValue], + values: acc[f.field].values.includes(f.values[0]) + ? acc[f.field].values + : [...acc[f.field].values, ...f.values], + }, + } + : { [f.field]: f }), + }), + {} + ); + + return Object.values(flat); + }, []); + +export const getDataSafety = (fn: (args: A) => T, args: A): Promise => + new Promise((resolve) => setTimeout(() => resolve(fn(args)))); diff --git a/x-pack/plugins/timelines/common/utils/to_array.ts b/x-pack/plugins/timelines/common/utils/to_array.ts new file mode 100644 index 00000000000000..fbb2b8d48a250d --- /dev/null +++ b/x-pack/plugins/timelines/common/utils/to_array.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const toArray = (value: T | T[] | null): T[] => + Array.isArray(value) ? value : value == null ? [] : [value]; +export const toStringArray = (value: T | T[] | null): string[] => { + if (Array.isArray(value)) { + return value.reduce((acc, v) => { + if (v != null) { + switch (typeof v) { + case 'number': + case 'boolean': + return [...acc, v.toString()]; + case 'object': + try { + return [...acc, JSON.stringify(v)]; + } catch { + return [...acc, 'Invalid Object']; + } + case 'string': + return [...acc, v]; + default: + return [...acc, `${v}`]; + } + } + return acc; + }, []); + } else if (value == null) { + return []; + } else if (!Array.isArray(value) && typeof value === 'object') { + try { + return [JSON.stringify(value)]; + } catch { + return ['Invalid Object']; + } + } else { + return [`${value}`]; + } +}; +export const toObjectArrayOfStrings = ( + value: T | T[] | null +): Array<{ + str: string; + isObjectArray?: boolean; +}> => { + if (Array.isArray(value)) { + return value.reduce< + Array<{ + str: string; + isObjectArray?: boolean; + }> + >((acc, v) => { + if (v != null) { + switch (typeof v) { + case 'number': + case 'boolean': + return [...acc, { str: v.toString() }]; + case 'object': + try { + return [...acc, { str: JSON.stringify(v), isObjectArray: true }]; // need to track when string is not a simple value + } catch { + return [...acc, { str: 'Invalid Object' }]; + } + case 'string': + return [...acc, { str: v }]; + default: + return [...acc, { str: `${v}` }]; + } + } + return acc; + }, []); + } else if (value == null) { + return []; + } else if (!Array.isArray(value) && typeof value === 'object') { + try { + return [{ str: JSON.stringify(value), isObjectArray: true }]; + } catch { + return [{ str: 'Invalid Object' }]; + } + } else { + return [{ str: `${value}` }]; + } +}; diff --git a/x-pack/plugins/timelines/jest.config.js b/x-pack/plugins/timelines/jest.config.js new file mode 100644 index 00000000000000..12bc67dbb2f07b --- /dev/null +++ b/x-pack/plugins/timelines/jest.config.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/timelines'], +}; diff --git a/x-pack/plugins/timelines/kibana.json b/x-pack/plugins/timelines/kibana.json index 552ddfd25ce733..5cc05a5996f74d 100644 --- a/x-pack/plugins/timelines/kibana.json +++ b/x-pack/plugins/timelines/kibana.json @@ -3,8 +3,9 @@ "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "timelines"], + "extraPublicDirs": ["common"], "server": true, "ui": true, - "requiredPlugins": [], + "requiredPlugins": ["data", "dataEnhanced", "kibanaReact", "kibanaUtils"], "optionalPlugins": [] } diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_keyboard_wrapper_hook/index.tsx b/x-pack/plugins/timelines/public/components/drag_and_drop/draggable_keyboard_wrapper_hook/index.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_keyboard_wrapper_hook/index.tsx rename to x-pack/plugins/timelines/public/components/drag_and_drop/draggable_keyboard_wrapper_hook/index.tsx index ac08fbe63e7c97..9eb5d7dc640c76 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_keyboard_wrapper_hook/index.tsx +++ b/x-pack/plugins/timelines/public/components/drag_and_drop/draggable_keyboard_wrapper_hook/index.tsx @@ -6,13 +6,13 @@ */ import React, { useCallback, useMemo, useState } from 'react'; -import { FluidDragActions } from 'react-beautiful-dnd'; +import type { FluidDragActions } from 'react-beautiful-dnd'; import { useAddToTimeline } from '../../../hooks/use_add_to_timeline'; import { draggableKeyDownHandler } from '../helpers'; -interface Props { +export interface UseDraggableKeyboardWrapperProps { closePopover?: () => void; draggableId: string; fieldName: string; @@ -31,7 +31,7 @@ export const useDraggableKeyboardWrapper = ({ fieldName, keyboardHandlerRef, openPopover, -}: Props): UseDraggableKeyboardWrapper => { +}: UseDraggableKeyboardWrapperProps): UseDraggableKeyboardWrapper => { const { beginDrag, cancelDrag, dragToLocation, endDrag, hasDraggableLock } = useAddToTimeline({ draggableId, fieldName, @@ -44,7 +44,7 @@ export const useDraggableKeyboardWrapper = ({ cancelDrag(prevDragAction); return null; } - return prevDragAction; + return null; }); }, [cancelDrag]); diff --git a/x-pack/plugins/timelines/public/components/drag_and_drop/helpers.ts b/x-pack/plugins/timelines/public/components/drag_and_drop/helpers.ts new file mode 100644 index 00000000000000..aaf4499cf5ad8d --- /dev/null +++ b/x-pack/plugins/timelines/public/components/drag_and_drop/helpers.ts @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DropResult, FluidDragActions, Position } from 'react-beautiful-dnd'; +import { KEYBOARD_DRAG_OFFSET, getFieldIdFromDraggable } from '@kbn/securitysolution-t-grid'; +import { Dispatch } from 'redux'; +import { isString, keyBy } from 'lodash/fp'; + +import { stopPropagationAndPreventDefault, TimelineId } from '../../../common'; +// eslint-disable-next-line no-duplicate-imports +import type { BrowserField, BrowserFields, ColumnHeaderOptions } from '../../../common'; +import { tGridActions } from '../../store/t_grid'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../t_grid/body/constants'; + +/** + * Temporarily disables tab focus on child links of the draggable to work + * around an issue where tab focus becomes stuck on the interactive children + * + * NOTE: This function is (intentionally) only effective when used in a key + * event handler, because it automatically restores focus capabilities on + * the next tick. + */ +export const temporarilyDisableInteractiveChildTabIndexes = (draggableElement: HTMLDivElement) => { + const interactiveChildren = draggableElement.querySelectorAll('a, button'); + interactiveChildren.forEach((interactiveChild) => { + interactiveChild.setAttribute('tabindex', '-1'); // DOM mutation + }); + + // restore the default tabindexs on the next tick: + setTimeout(() => { + interactiveChildren.forEach((interactiveChild) => { + interactiveChild.setAttribute('tabindex', '0'); // DOM mutation + }); + }, 0); +}; + +export interface DraggableKeyDownHandlerProps { + beginDrag: () => FluidDragActions | null; + cancelDragActions: () => void; + closePopover?: () => void; + draggableElement: HTMLDivElement; + dragActions: FluidDragActions | null; + dragToLocation: ({ + dragActions, + position, + }: { + dragActions: FluidDragActions | null; + position: Position; + }) => void; + keyboardEvent: React.KeyboardEvent; + endDrag: (dragActions: FluidDragActions | null) => void; + openPopover?: () => void; + setDragActions: (value: React.SetStateAction) => void; +} + +export const draggableKeyDownHandler = ({ + beginDrag, + cancelDragActions, + closePopover, + draggableElement, + dragActions, + dragToLocation, + endDrag, + keyboardEvent, + openPopover, + setDragActions, +}: DraggableKeyDownHandlerProps) => { + let currentPosition: DOMRect | null = null; + + switch (keyboardEvent.key) { + case ' ': + if (!dragActions) { + // start dragging, because space was pressed + if (closePopover != null) { + closePopover(); + } + setDragActions(beginDrag()); + } else { + // end dragging, because space was pressed + endDrag(dragActions); + setDragActions(null); + } + break; + case 'Escape': + cancelDragActions(); + break; + case 'Tab': + // IMPORTANT: we do NOT want to stop propagation and prevent default when Tab is pressed + temporarilyDisableInteractiveChildTabIndexes(draggableElement); + break; + case 'ArrowUp': + currentPosition = draggableElement.getBoundingClientRect(); + dragToLocation({ + dragActions, + position: { x: currentPosition.x, y: currentPosition.y - KEYBOARD_DRAG_OFFSET }, + }); + break; + case 'ArrowDown': + currentPosition = draggableElement.getBoundingClientRect(); + dragToLocation({ + dragActions, + position: { x: currentPosition.x, y: currentPosition.y + KEYBOARD_DRAG_OFFSET }, + }); + break; + case 'ArrowLeft': + currentPosition = draggableElement.getBoundingClientRect(); + dragToLocation({ + dragActions, + position: { x: currentPosition.x - KEYBOARD_DRAG_OFFSET, y: currentPosition.y }, + }); + break; + case 'ArrowRight': + currentPosition = draggableElement.getBoundingClientRect(); + dragToLocation({ + dragActions, + position: { x: currentPosition.x + KEYBOARD_DRAG_OFFSET, y: currentPosition.y }, + }); + break; + case 'Enter': + stopPropagationAndPreventDefault(keyboardEvent); // prevents the first item in the popover from getting an errant ENTER + if (!dragActions && openPopover != null) { + openPopover(); + } + break; + default: + break; + } +}; +const getAllBrowserFields = (browserFields: BrowserFields): Array> => + Object.values(browserFields).reduce>>( + (acc, namespace) => [ + ...acc, + ...Object.values(namespace.fields != null ? namespace.fields : {}), + ], + [] + ); + +const getAllFieldsByName = ( + browserFields: BrowserFields +): { [fieldName: string]: Partial } => + keyBy('name', getAllBrowserFields(browserFields)); + +const linkFields: Record = { + 'signal.rule.name': 'signal.rule.id', + 'event.module': 'rule.reference', +}; + +interface AddFieldToTimelineColumnsParams { + defaultsHeader: ColumnHeaderOptions[]; + browserFields: BrowserFields; + dispatch: Dispatch; + result: DropResult; + timelineId: string; +} + +export const addFieldToTimelineColumns = ({ + browserFields, + dispatch, + result, + timelineId, + defaultsHeader, +}: AddFieldToTimelineColumnsParams): void => { + const fieldId = getFieldIdFromDraggable(result); + const allColumns = getAllFieldsByName(browserFields); + const column = allColumns[fieldId]; + const initColumnHeader = + timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage + ? defaultsHeader.find((c) => c.id === fieldId) ?? {} + : {}; + + if (column != null) { + dispatch( + tGridActions.upsertColumn({ + column: { + category: column.category, + columnHeaderType: 'not-filtered', + description: isString(column.description) ? column.description : undefined, + example: isString(column.example) ? column.example : undefined, + id: fieldId, + linkField: linkFields[fieldId] ?? undefined, + type: column.type, + aggregatable: column.aggregatable, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + ...initColumnHeader, + }, + id: timelineId, + index: result.destination != null ? result.destination.index : 0, + }) + ); + } else { + // create a column definition, because it doesn't exist in the browserFields: + dispatch( + tGridActions.upsertColumn({ + column: { + columnHeaderType: 'not-filtered', + id: fieldId, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + id: timelineId, + index: result.destination != null ? result.destination.index : 0, + }) + ); + } +}; + +export const getTimelineIdFromColumnDroppableId = (droppableId: string) => + droppableId.slice(droppableId.lastIndexOf('.') + 1); diff --git a/x-pack/plugins/timelines/public/components/drag_and_drop/index.tsx b/x-pack/plugins/timelines/public/components/drag_and_drop/index.tsx new file mode 100644 index 00000000000000..65ec238ea4d402 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/drag_and_drop/index.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + IS_DRAGGING_CLASS_NAME, + draggableIsField, + fieldWasDroppedOnTimelineColumns, + IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME, +} from '@kbn/securitysolution-t-grid'; +import { noop } from 'lodash/fp'; +import deepEqual from 'fast-deep-equal'; +import React, { useCallback } from 'react'; +import { DropResult, DragDropContext, BeforeCapture } from 'react-beautiful-dnd'; +import { useDispatch } from 'react-redux'; + +import type { ColumnHeaderOptions, BrowserFields } from '../../../common'; +import { useAddToTimelineSensor } from '../../hooks/use_add_to_timeline'; +import { addFieldToTimelineColumns, getTimelineIdFromColumnDroppableId } from './helpers'; + +export * from './draggable_keyboard_wrapper_hook'; +export * from './helpers'; + +interface Props { + browserFields: BrowserFields; + defaultsHeader: ColumnHeaderOptions[]; + children: React.ReactNode; +} + +const sensors = [useAddToTimelineSensor]; + +const DragDropContextWrapperComponent: React.FC = ({ + browserFields, + defaultsHeader, + children, +}) => { + const dispatch = useDispatch(); + + const onDragEnd = useCallback( + (result: DropResult) => { + try { + enableScrolling(); + + if (fieldWasDroppedOnTimelineColumns(result)) { + addFieldToTimelineColumns({ + browserFields, + defaultsHeader, + dispatch, + result, + timelineId: getTimelineIdFromColumnDroppableId(result.destination?.droppableId ?? ''), + }); + } + } finally { + document.body.classList.remove(IS_DRAGGING_CLASS_NAME); + + if (draggableIsField(result)) { + document.body.classList.remove(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME); + } + } + }, + [browserFields, defaultsHeader, dispatch] + ); + return ( + + {children} + + ); +}; + +DragDropContextWrapperComponent.displayName = 'DragDropContextWrapperComponent'; + +export const DragDropContextWrapper = React.memo( + DragDropContextWrapperComponent, + // prevent re-renders when data providers are added or removed, but all other props are the same + (prevProps, nextProps) => deepEqual(prevProps.children, nextProps.children) +); + +DragDropContextWrapper.displayName = 'DragDropContextWrapper'; + +const onBeforeCapture = (before: BeforeCapture) => { + if (!draggableIsField(before)) { + document.body.classList.add(IS_DRAGGING_CLASS_NAME); + } + + if (draggableIsField(before)) { + document.body.classList.add(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME); + } +}; + +const enableScrolling = () => (window.onscroll = () => noop); diff --git a/x-pack/plugins/timelines/public/components/draggables/field_badge/index.tsx b/x-pack/plugins/timelines/public/components/draggables/field_badge/index.tsx new file mode 100644 index 00000000000000..62f7e091fae9c3 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/draggables/field_badge/index.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { rgba } from 'polished'; +import React from 'react'; +import styled from 'styled-components'; + +interface WidthProp { + width?: number; +} + +const Field = styled.div.attrs(({ width }) => { + if (width) { + return { + style: { + width: `${width}px`, + }, + }; + } +})` + background-color: ${({ theme }) => theme.eui.euiColorEmptyShade}; + border: ${({ theme }) => theme.eui.euiBorderThin}; + box-shadow: 0 2px 2px -1px ${({ theme }) => rgba(theme.eui.euiColorMediumShade, 0.3)}, + 0 1px 5px -2px ${({ theme }) => rgba(theme.eui.euiColorMediumShade, 0.3)}; + font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; + line-height: ${({ theme }) => theme.eui.euiLineHeight}; + padding: ${({ theme }) => theme.eui.paddingSizes.xs}; +`; +Field.displayName = 'Field'; + +/** + * Renders a field (e.g. `event.action`) as a draggable badge + */ + +export const DraggableFieldBadge = React.memo<{ fieldId: string; fieldWidth?: number }>( + ({ fieldId, fieldWidth }) => ( + + {fieldId} + + ) +); + +DraggableFieldBadge.displayName = 'DraggableFieldBadge'; diff --git a/x-pack/plugins/timelines/public/components/draggables/field_badge/translations.ts b/x-pack/plugins/timelines/public/components/draggables/field_badge/translations.ts new file mode 100644 index 00000000000000..6c8143c228e147 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/draggables/field_badge/translations.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CATEGORY = i18n.translate('xpack.timelines.draggables.field.categoryLabel', { + defaultMessage: 'Category', +}); + +export const COPY_TO_CLIPBOARD = i18n.translate( + 'xpack.timelines.eventDetails.copyToClipboardTooltip', + { + defaultMessage: 'Copy to Clipboard', + } +); + +export const FIELD = i18n.translate('xpack.timelines.draggables.field.fieldLabel', { + defaultMessage: 'Field', +}); + +export const TYPE = i18n.translate('xpack.timelines.draggables.field.typeLabel', { + defaultMessage: 'Type', +}); + +export const VIEW_CATEGORY = i18n.translate( + 'xpack.timelines.draggables.field.viewCategoryTooltip', + { + defaultMessage: 'View Category', + } +); diff --git a/x-pack/plugins/timelines/public/components/draggables/index.tsx b/x-pack/plugins/timelines/public/components/draggables/index.tsx new file mode 100644 index 00000000000000..a87d97b7ea74a8 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/draggables/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './field_badge'; diff --git a/x-pack/plugins/timelines/public/components/exit_full_screen/index.test.tsx b/x-pack/plugins/timelines/public/components/exit_full_screen/index.test.tsx new file mode 100644 index 00000000000000..b60bdafd0835fe --- /dev/null +++ b/x-pack/plugins/timelines/public/components/exit_full_screen/index.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../mock/test_providers'; +import * as i18n from './translations'; +import { ExitFullScreen, EXIT_FULL_SCREEN_CLASS_NAME } from '.'; + +describe('ExitFullScreen', () => { + test('it returns null when fullScreen is false', () => { + const exitFullScreen = mount( + + + + ); + + expect(exitFullScreen.find('[data-test-subj="exit-full-screen"]').exists()).toBe(false); + }); + + test('it renders a button with the exported EXIT_FULL_SCREEN_CLASS_NAME class when fullScreen is true', () => { + const exitFullScreen = mount( + + + + ); + + expect(exitFullScreen.find(`button.${EXIT_FULL_SCREEN_CLASS_NAME}`).exists()).toBe(true); + }); + + test('it renders the expected button text when fullScreen is true', () => { + const exitFullScreen = mount( + + + + ); + + expect(exitFullScreen.find('[data-test-subj="exit-full-screen"]').first().text()).toBe( + i18n.EXIT_FULL_SCREEN + ); + }); + + test('it invokes setFullScreen with a value of false when the button is clicked', () => { + const setFullScreen = jest.fn(); + + const exitFullScreen = mount( + + + + ); + + exitFullScreen.find('[data-test-subj="exit-full-screen"]').first().simulate('click'); + expect(setFullScreen).toBeCalledWith(false); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/exit_full_screen/index.tsx b/x-pack/plugins/timelines/public/components/exit_full_screen/index.tsx new file mode 100644 index 00000000000000..5ae537128bee60 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/exit_full_screen/index.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiWindowEvent } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import styled from 'styled-components'; + +import * as i18n from './translations'; + +export const EXIT_FULL_SCREEN_CLASS_NAME = 'exit-full-screen'; + +const StyledEuiButton = styled(EuiButton)` + margin: ${({ theme }) => theme.eui.paddingSizes.s}; +`; + +interface Props { + fullScreen: boolean; + setFullScreen: (fullScreen: boolean) => void; +} + +const ExitFullScreenComponent: React.FC = ({ fullScreen, setFullScreen }) => { + const exitFullScreen = useCallback(() => { + setFullScreen(false); + }, [setFullScreen]); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + + exitFullScreen(); + } + }, + [exitFullScreen] + ); + + if (!fullScreen) { + return null; + } + + return ( + <> + + + {i18n.EXIT_FULL_SCREEN} + + + ); +}; + +ExitFullScreenComponent.displayName = 'ExitFullScreenComponent'; + +export const ExitFullScreen = React.memo(ExitFullScreenComponent); diff --git a/x-pack/plugins/timelines/public/components/exit_full_screen/translations.ts b/x-pack/plugins/timelines/public/components/exit_full_screen/translations.ts new file mode 100644 index 00000000000000..22aecebf12a079 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/exit_full_screen/translations.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const EXIT_FULL_SCREEN = i18n.translate('xpack.timelines.exitFullScreenButton', { + defaultMessage: 'Exit full screen', +}); diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index f44ad8052917f3..b242c0ec2a4a7c 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -6,24 +6,53 @@ */ import React from 'react'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { Provider } from 'react-redux'; +import { I18nProvider } from '@kbn/i18n/react'; +import { Store } from 'redux'; -import { PLUGIN_NAME } from '../../common'; -import { TimelineProps } from '../types'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { createStore } from '../store/t_grid'; -export const Timeline = (props: TimelineProps) => { +import { TGrid as TGridComponent } from './tgrid'; +import { TGridProps } from '../types'; +import { DragDropContextWrapper } from './drag_and_drop'; +import { initialTGridState } from '../store/t_grid/reducer'; +import { TGridIntegratedProps } from './t_grid/integrated'; + +const EMPTY_BROWSER_FIELDS = {}; + +type TGridComponent = TGridProps & { + store?: Store; + storage: Storage; + data?: DataPublicPluginStart; +}; + +export const TGrid = (props: TGridComponent) => { + const { store, storage, ...tGridProps } = props; + let tGridStore = store; + if (!tGridStore && props.type === 'standalone') { + tGridStore = createStore(initialTGridState, storage); + } + let browserFields = EMPTY_BROWSER_FIELDS; + if ((tGridProps as TGridIntegratedProps).browserFields != null) { + browserFields = (tGridProps as TGridIntegratedProps).browserFields; + } return ( - -
- -
-
+ + + + + + + ); }; // eslint-disable-next-line import/no-default-export -export { Timeline as default }; +export { TGrid as default }; + +export * from './drag_and_drop'; +export * from './draggables'; +export * from './last_updated'; +export * from './loading'; diff --git a/x-pack/plugins/timelines/public/components/inspect/index.test.tsx b/x-pack/plugins/timelines/public/components/inspect/index.test.tsx new file mode 100644 index 00000000000000..5d8af0a0653bdb --- /dev/null +++ b/x-pack/plugins/timelines/public/components/inspect/index.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { cloneDeep } from 'lodash/fp'; + +import { InspectButton, InspectButtonContainer, BUTTON_CLASS, InspectButtonProps } from '.'; + +describe('Inspect Button', () => { + const newQuery: InspectButtonProps = { + inspect: null, + loading: false, + title: 'My title', + }; + + describe('Render', () => { + test('Eui Icon Button', () => { + const wrapper = mount(); + expect(wrapper.find('button[data-test-subj="inspect-icon-button"]').first().exists()).toBe( + true + ); + }); + + test('Eui Icon Button disabled', () => { + const wrapper = mount(); + expect(wrapper.find('.euiButtonIcon').get(0).props.disabled).toBe(true); + }); + + describe('InspectButtonContainer', () => { + test('it renders a transparent inspect button by default', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`InspectButtonContainer`)).toHaveStyleRule('opacity', '0', { + modifier: `.${BUTTON_CLASS}`, + }); + }); + + test('it renders an opaque inspect button when it has mouse focus', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`InspectButtonContainer`)).toHaveStyleRule('opacity', '1', { + modifier: `:hover .${BUTTON_CLASS}`, + }); + }); + }); + }); + + describe('Modal Inspect - happy path', () => { + const myQuery = cloneDeep(newQuery); + beforeEach(() => { + myQuery.inspect = { + dsl: ['my dsl'], + response: ['my response'], + }; + }); + test('Open Inspect Modal', () => { + const wrapper = mount(); + + wrapper.find('button[data-test-subj="inspect-icon-button"]').first().simulate('click'); + wrapper.update(); + expect(wrapper.find('button[data-test-subj="modal-inspect-close"]').first().exists()).toBe( + true + ); + }); + + test('Close Inspect Modal', () => { + const wrapper = mount(); + wrapper.find('button[data-test-subj="inspect-icon-button"]').first().simulate('click'); + + wrapper.update(); + wrapper.find('button[data-test-subj="modal-inspect-close"]').first().simulate('click'); + + wrapper.update(); + expect(wrapper.find('button[data-test-subj="modal-inspect-close"]').first().exists()).toBe( + false + ); + }); + + test('Do not Open Inspect Modal if it is loading', () => { + const wrapper = mount( + + ); + wrapper.find('button[data-test-subj="inspect-icon-button"]').first().simulate('click'); + + wrapper.update(); + + expect(wrapper.find('button[data-test-subj="modal-inspect-close"]').first().exists()).toBe( + false + ); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/inspect/index.tsx b/x-pack/plugins/timelines/public/components/inspect/index.tsx new file mode 100644 index 00000000000000..a174cc08a83ee1 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/inspect/index.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonIcon } from '@elastic/eui'; +import { getOr } from 'lodash/fp'; +import React, { useCallback, useState } from 'react'; +import styled, { css } from 'styled-components'; + +import { ModalInspectQuery } from './modal'; +import * as i18n from './translations'; +import { InspectQuery } from '../../store/t_grid/inputs'; + +export const BUTTON_CLASS = 'inspectButtonComponent'; + +export const InspectButtonContainer = styled.div<{ show?: boolean }>` + width: 100%; + display: flex; + flex-grow: 1; + + > * { + max-width: 100%; + } + + .${BUTTON_CLASS} { + pointer-events: none; + opacity: 0; + transition: opacity ${(props) => getOr(250, 'theme.eui.euiAnimSpeedNormal', props)} ease; + } + + ${({ show }) => + show && + css` + &:hover .${BUTTON_CLASS} { + pointer-events: auto; + opacity: 1; + } + `} +`; + +InspectButtonContainer.displayName = 'InspectButtonContainer'; + +InspectButtonContainer.defaultProps = { + show: true, +}; + +interface OwnProps { + inspect: InspectQuery | null; + isDisabled?: boolean; + loading: boolean; + onCloseInspect?: () => void; + title: string | React.ReactElement | React.ReactNode; +} + +export type InspectButtonProps = OwnProps; + +const InspectButtonComponent: React.FC = ({ + inspect, + isDisabled, + loading, + onCloseInspect, + title = '', +}) => { + const [isInspected, setIsInspected] = useState(false); + const isShowingModal = !loading && isInspected; + const handleClick = useCallback(() => { + setIsInspected(true); + }, []); + + const handleCloseModal = useCallback(() => { + if (onCloseInspect != null) { + onCloseInspect(); + } + setIsInspected(false); + }, [onCloseInspect, setIsInspected]); + + let request: string | null = null; + if (inspect != null && inspect.dsl.length > 0) { + request = inspect.dsl[0]; + } + + let response: string | null = null; + if (inspect != null && inspect.response.length > 0) { + response = inspect.response[0]; + } + + return ( + <> + + + + ); +}; + +export const InspectButton = React.memo(InspectButtonComponent); diff --git a/x-pack/plugins/timelines/public/components/inspect/modal.test.tsx b/x-pack/plugins/timelines/public/components/inspect/modal.test.tsx new file mode 100644 index 00000000000000..5ac75f92ea45fa --- /dev/null +++ b/x-pack/plugins/timelines/public/components/inspect/modal.test.tsx @@ -0,0 +1,282 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { getMockTheme } from '../../mock/kibana_react.mock'; + +import { ModalInspectQuery, formatIndexPatternRequested, NO_ALERT_INDEX } from './modal'; + +const mockTheme = getMockTheme({ + eui: { + euiBreakpoints: { + l: '1200px', + }, + }, +}); + +const request = + '{"index": ["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"allowNoIndices": true, "ignoreUnavailable": true, "body": { "aggregations": {"hosts": {"cardinality": {"field": "host.name" } }, "hosts_histogram": {"auto_date_histogram": {"field": "@timestamp","buckets": "6"},"aggs": { "count": {"cardinality": {"field": "host.name" }}}}}, "query": {"bool": {"filter": [{"range": { "@timestamp": {"gte": 1562290224506,"lte": 1562376624506 }}}]}}, "size": 0, "track_total_hits": false}}'; +const response = + '{"took": 880,"timed_out": false,"_shards": {"total": 26,"successful": 26,"skipped": 0,"failed": 0},"hits": {"max_score": null,"hits": []},"aggregations": {"hosts": {"value": 541},"hosts_histogram": {"buckets": [{"key_as_string": "2019 - 07 - 05T01: 00: 00.000Z", "key": 1562288400000, "doc_count": 1492321, "count": { "value": 105 }}, {"key_as_string": "2019 - 07 - 05T13: 00: 00.000Z", "key": 1562331600000, "doc_count": 2412761, "count": { "value": 453}},{"key_as_string": "2019 - 07 - 06T01: 00: 00.000Z", "key": 1562374800000, "doc_count": 111658, "count": { "value": 15}}],"interval": "12h"}},"status": 200}'; + +describe('Modal Inspect', () => { + const closeModal = jest.fn(); + + describe('rendering', () => { + test('when isShowing is positive and request and response are not null', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="modal-inspect-euiModal"]').first().exists()).toBe(true); + expect(wrapper.find('.euiModalHeader__title').first().text()).toBe('Inspect My title'); + }); + + test('when isShowing is negative and request and response are not null', () => { + const wrapper = mount( + + ); + expect(wrapper.find('[data-test-subj="modal-inspect-euiModal"]').first().exists()).toBe( + false + ); + }); + + test('when isShowing is positive and request is null and response is not null', () => { + const wrapper = mount( + + ); + expect(wrapper.find('[data-test-subj="modal-inspect-euiModal"]').first().exists()).toBe( + false + ); + }); + + test('when isShowing is positive and request is not null and response is null', () => { + const wrapper = mount( + + ); + expect(wrapper.find('[data-test-subj="modal-inspect-euiModal"]').first().exists()).toBe( + false + ); + }); + }); + + describe('functionality from tab statistics/request/response', () => { + test('Click on statistic Tab', () => { + const wrapper = mount( + + + + ); + + wrapper.find('.euiTab').first().simulate('click'); + wrapper.update(); + + expect( + wrapper.find('.euiDescriptionList__title span[data-test-subj="index-pattern-title"]').text() + ).toBe('Index pattern '); + expect( + wrapper + .find('.euiDescriptionList__description span[data-test-subj="index-pattern-description"]') + .text() + ).toBe('auditbeat-*, filebeat-*, packetbeat-*, winlogbeat-*'); + expect( + wrapper.find('.euiDescriptionList__title span[data-test-subj="query-time-title"]').text() + ).toBe('Query time '); + expect( + wrapper + .find('.euiDescriptionList__description span[data-test-subj="query-time-description"]') + .text() + ).toBe('880ms'); + expect( + wrapper + .find('.euiDescriptionList__title span[data-test-subj="request-timestamp-title"]') + .text() + ).toBe('Request timestamp '); + }); + + test('Click on request Tab', () => { + const wrapper = mount( + + + + ); + + wrapper.find('.euiTab').at(2).simulate('click'); + wrapper.update(); + + expect(JSON.parse(wrapper.find('EuiCodeBlock').first().text())).toEqual({ + took: 880, + timed_out: false, + _shards: { + total: 26, + successful: 26, + skipped: 0, + failed: 0, + }, + hits: { + max_score: null, + hits: [], + }, + aggregations: { + hosts: { + value: 541, + }, + hosts_histogram: { + buckets: [ + { + key_as_string: '2019 - 07 - 05T01: 00: 00.000Z', + key: 1562288400000, + doc_count: 1492321, + count: { + value: 105, + }, + }, + { + key_as_string: '2019 - 07 - 05T13: 00: 00.000Z', + key: 1562331600000, + doc_count: 2412761, + count: { + value: 453, + }, + }, + { + key_as_string: '2019 - 07 - 06T01: 00: 00.000Z', + key: 1562374800000, + doc_count: 111658, + count: { + value: 15, + }, + }, + ], + interval: '12h', + }, + }, + status: 200, + }); + }); + + test('Click on response Tab', () => { + const wrapper = mount( + + + + ); + + wrapper.find('.euiTab').at(1).simulate('click'); + wrapper.update(); + + expect(JSON.parse(wrapper.find('EuiCodeBlock').first().text())).toEqual({ + aggregations: { + hosts: { cardinality: { field: 'host.name' } }, + hosts_histogram: { + aggs: { count: { cardinality: { field: 'host.name' } } }, + auto_date_histogram: { buckets: '6', field: '@timestamp' }, + }, + }, + query: { + bool: { + filter: [{ range: { '@timestamp': { gte: 1562290224506, lte: 1562376624506 } } }], + }, + }, + size: 0, + track_total_hits: false, + }); + }); + }); + + describe('events', () => { + test('Make sure that toggle function has been called when you click on the close button', () => { + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="modal-inspect-close"]').simulate('click'); + wrapper.update(); + expect(closeModal).toHaveBeenCalled(); + }); + }); + + describe('formatIndexPatternRequested', () => { + test('Return specific messages to NO_ALERT_INDEX if we only have one index and we match the index name `NO_ALERT_INDEX`', () => { + const expected = formatIndexPatternRequested([NO_ALERT_INDEX]); + expect(expected).toEqual({'No alert index found'}); + }); + + test('Ignore NO_ALERT_INDEX if you have more than one indices', () => { + const expected = formatIndexPatternRequested([NO_ALERT_INDEX, 'indice-1']); + expect(expected).toEqual('indice-1'); + }); + + test('Happy path', () => { + const expected = formatIndexPatternRequested(['indice-1, indice-2']); + expect(expected).toEqual('indice-1, indice-2'); + }); + + test('Empty array with no indices', () => { + const expected = formatIndexPatternRequested([]); + expect(expected).toEqual('Sorry about that, something went wrong.'); + }); + + test('Undefined indices', () => { + const expected = formatIndexPatternRequested(undefined); + expect(expected).toEqual('Sorry about that, something went wrong.'); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/inspect/modal.tsx b/x-pack/plugins/timelines/public/components/inspect/modal.tsx new file mode 100644 index 00000000000000..54cfc9827bb5ff --- /dev/null +++ b/x-pack/plugins/timelines/public/components/inspect/modal.tsx @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiCodeBlock, + EuiDescriptionList, + EuiIconTip, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalFooter, + EuiSpacer, + EuiTabbedContent, +} from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import React, { Fragment, ReactNode } from 'react'; +import styled from 'styled-components'; + +import * as i18n from './translations'; + +export const NO_ALERT_INDEX = 'no-alert-index-049FC71A-4C2C-446F-9901-37XMC5024C51'; + +const DescriptionListStyled = styled(EuiDescriptionList)` + @media only screen and (min-width: ${(props) => + props?.theme?.eui?.euiBreakpoints?.s ?? '600px'}) { + .euiDescriptionList__title { + width: 30% !important; + } + + .euiDescriptionList__description { + width: 70% !important; + } + } +`; + +DescriptionListStyled.displayName = 'DescriptionListStyled'; + +interface ModalInspectProps { + closeModal: () => void; + isShowing: boolean; + request: string | null; + response: string | null; + additionalRequests?: string[] | null; + additionalResponses?: string[] | null; + title: string | React.ReactElement | React.ReactNode; +} + +interface Request { + index: string[]; + allowNoIndices: boolean; + ignoreUnavailable: boolean; + body: Record; +} + +interface Response { + took: number; + timed_out: boolean; + _shards: Record; + hits: Record; + aggregations: Record; +} + +const MyEuiModal = styled(EuiModal)` + .euiModal__flex { + width: 60vw; + } + .euiCodeBlock { + height: auto !important; + max-width: 718px; + } +`; + +MyEuiModal.displayName = 'MyEuiModal'; +const parseInspectStrings = function (stringsArray: string[]): T[] { + try { + return stringsArray.map((objectStringify) => JSON.parse(objectStringify)); + } catch { + return []; + } +}; + +const manageStringify = (object: Record | Response): string => { + try { + return JSON.stringify(object, null, 2); + } catch { + return i18n.SOMETHING_WENT_WRONG; + } +}; + +export const formatIndexPatternRequested = (indices: string[] = []) => { + if (indices.length === 1 && indices[0] === NO_ALERT_INDEX) { + return {i18n.NO_ALERT_INDEX_FOUND}; + } + return indices.length > 0 + ? indices.filter((i) => i !== NO_ALERT_INDEX).join(', ') + : i18n.SOMETHING_WENT_WRONG; +}; + +export const ModalInspectQuery = ({ + closeModal, + isShowing = false, + request, + response, + additionalRequests, + additionalResponses, + title, +}: ModalInspectProps) => { + if (!isShowing || request == null || response == null) { + return null; + } + + const requests: string[] = [request, ...(additionalRequests != null ? additionalRequests : [])]; + const responses: string[] = [ + response, + ...(additionalResponses != null ? additionalResponses : []), + ]; + + const inspectRequests: Request[] = parseInspectStrings(requests); + const inspectResponses: Response[] = parseInspectStrings(responses); + + const statistics: Array<{ + title: NonNullable; + description: NonNullable; + }> = [ + { + title: ( + + {i18n.INDEX_PATTERN}{' '} + + + ), + description: ( + + {formatIndexPatternRequested(inspectRequests[0]?.index ?? [])} + + ), + }, + + { + title: ( + + {i18n.QUERY_TIME}{' '} + + + ), + description: ( + + {inspectResponses[0]?.took + ? `${numeral(inspectResponses[0].took).format('0,0')}ms` + : i18n.SOMETHING_WENT_WRONG} + + ), + }, + { + title: ( + + {i18n.REQUEST_TIMESTAMP}{' '} + + + ), + description: ( + {new Date().toISOString()} + ), + }, + ]; + + const tabs = [ + { + id: 'statistics', + name: 'Statistics', + content: ( + <> + + + + ), + }, + { + id: 'request', + name: 'Request', + content: + inspectRequests.length > 0 ? ( + inspectRequests.map((inspectRequest, index) => ( + + + + {manageStringify(inspectRequest.body)} + + + )) + ) : ( + {i18n.SOMETHING_WENT_WRONG} + ), + }, + { + id: 'response', + name: 'Response', + content: + inspectResponses.length > 0 ? ( + responses.map((responseText, index) => ( + + + + {responseText} + + + )) + ) : ( + {i18n.SOMETHING_WENT_WRONG} + ), + }, + ]; + + return ( + + + + {i18n.INSPECT} {title} + + + + + + + + + + {i18n.CLOSE} + + + + ); +}; diff --git a/x-pack/plugins/timelines/public/components/inspect/translations.ts b/x-pack/plugins/timelines/public/components/inspect/translations.ts new file mode 100644 index 00000000000000..286ec9d10c2873 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/inspect/translations.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INSPECT = i18n.translate('xpack.timelines.inspectDescription', { + defaultMessage: 'Inspect', +}); + +export const CLOSE = i18n.translate('xpack.timelines.inspect.modal.closeTitle', { + defaultMessage: 'Close', +}); + +export const SOMETHING_WENT_WRONG = i18n.translate( + 'xpack.timelines.inspect.modal.somethingWentWrongDescription', + { + defaultMessage: 'Sorry about that, something went wrong.', + } +); +export const INDEX_PATTERN = i18n.translate('xpack.timelines.inspect.modal.indexPatternLabel', { + defaultMessage: 'Index pattern', +}); + +export const INDEX_PATTERN_DESC = i18n.translate( + 'xpack.timelines.inspect.modal.indexPatternDescription', + { + defaultMessage: + 'The index pattern that connected to the Elasticsearch indices. These indices can be configured in Kibana > Advanced Settings.', + } +); + +export const QUERY_TIME = i18n.translate('xpack.timelines.inspect.modal.queryTimeLabel', { + defaultMessage: 'Query time', +}); + +export const QUERY_TIME_DESC = i18n.translate( + 'xpack.timelines.inspect.modal.queryTimeDescription', + { + defaultMessage: + 'The time it took to process the query. Does not include the time to send the request or parse it in the browser.', + } +); + +export const REQUEST_TIMESTAMP = i18n.translate('xpack.timelines.inspect.modal.reqTimestampLabel', { + defaultMessage: 'Request timestamp', +}); + +export const REQUEST_TIMESTAMP_DESC = i18n.translate( + 'xpack.timelines.inspect.modal.reqTimestampDescription', + { + defaultMessage: 'Time when the start of the request has been logged', + } +); + +export const NO_ALERT_INDEX_FOUND = i18n.translate( + 'xpack.timelines.inspect.modal.noAlertIndexFound', + { + defaultMessage: 'No alert index found', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx b/x-pack/plugins/timelines/public/components/last_updated/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx rename to x-pack/plugins/timelines/public/components/last_updated/index.test.tsx index 71807eb71776a4..f7d81db6709832 100644 --- a/x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/last_updated/index.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { mount } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; - import { LastUpdatedAt } from './'; + jest.mock('@kbn/i18n/react', () => { const originalModule = jest.requireActual('@kbn/i18n/react'); const FormattedRelative = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx b/x-pack/plugins/timelines/public/components/last_updated/index.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx rename to x-pack/plugins/timelines/public/components/last_updated/index.tsx index 90c21eb82d8b77..344cb36791dd55 100644 --- a/x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx +++ b/x-pack/plugins/timelines/public/components/last_updated/index.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import * as i18n from './translations'; -interface LastUpdatedAtProps { +export interface LastUpdatedAtProps { compact?: boolean; updatedAt: number; showUpdating?: boolean; @@ -82,3 +82,6 @@ export const LastUpdatedAt = React.memo( ); LastUpdatedAt.displayName = 'LastUpdatedAt'; + +// eslint-disable-next-line import/no-default-export +export { LastUpdatedAt as default }; diff --git a/x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts b/x-pack/plugins/timelines/public/components/last_updated/translations.ts similarity index 67% rename from x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts rename to x-pack/plugins/timelines/public/components/last_updated/translations.ts index 7d1cfc9537239a..975c6972e90cdf 100644 --- a/x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts +++ b/x-pack/plugins/timelines/public/components/last_updated/translations.ts @@ -7,10 +7,10 @@ import { i18n } from '@kbn/i18n'; -export const UPDATING = i18n.translate('xpack.securitySolution.lastUpdated.updating', { +export const UPDATING = i18n.translate('xpack.timelines.lastUpdated.updating', { defaultMessage: 'Updating...', }); -export const UPDATED = i18n.translate('xpack.securitySolution.lastUpdated.updated', { +export const UPDATED = i18n.translate('xpack.timelines.lastUpdated.updated', { defaultMessage: 'Updated', }); diff --git a/x-pack/plugins/timelines/public/components/loading/index.tsx b/x-pack/plugins/timelines/public/components/loading/index.tsx new file mode 100644 index 00000000000000..59cc18767af215 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/loading/index.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +const SpinnerFlexItem = styled(EuiFlexItem)` + margin-right: 5px; +`; + +SpinnerFlexItem.displayName = 'SpinnerFlexItem'; + +export interface LoadingPanelProps { + dataTestSubj?: string; + text: string; + height: number | string; + showBorder?: boolean; + width: number | string; + zIndex?: number | string; + position?: string; +} + +export const LoadingPanel = React.memo( + ({ + dataTestSubj = '', + height = 'auto', + showBorder = true, + text, + width, + position = 'relative', + zIndex = 'inherit', + }) => ( + + + + + + + + + + {text} + + + + + + ) +); + +LoadingPanel.displayName = 'LoadingPanel'; + +export const LoadingStaticPanel = styled.div<{ + height: number | string; + position: string; + width: number | string; + zIndex: number | string; +}>` + height: ${({ height }) => height}; + position: ${({ position }) => position}; + width: ${({ width }) => width}; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + z-index: ${({ zIndex }) => zIndex}; +`; + +LoadingStaticPanel.displayName = 'LoadingStaticPanel'; + +export const LoadingStaticContentPanel = styled.div` + flex: 0 0 auto; + align-self: center; + text-align: center; + height: fit-content; + .euiPanel.euiPanel--paddingMedium { + padding: 10px; + } +`; + +LoadingStaticContentPanel.displayName = 'LoadingStaticContentPanel'; + +// eslint-disable-next-line import/no-default-export +export { LoadingPanel as default }; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000000..9ee08bcd966f35 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/__snapshots__/index.test.tsx.snap @@ -0,0 +1,526 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` + +`; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/actions/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/actions/index.tsx new file mode 100644 index 00000000000000..322059576d2b7a --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/actions/index.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonIcon } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline'; +import { EventsHeadingExtra, EventsLoading } from '../../../styles'; +import type { OnColumnRemoved } from '../../../types'; +import type { Sort } from '../../sort'; + +import * as i18n from '../translations'; + +interface Props { + header: ColumnHeaderOptions; + isLoading: boolean; + onColumnRemoved: OnColumnRemoved; + sort: Sort[]; +} + +/** Given a `header`, returns the `SortDirection` applicable to it */ + +export const CloseButton = React.memo<{ + columnId: string; + onColumnRemoved: OnColumnRemoved; +}>(({ columnId, onColumnRemoved }) => { + const handleClick = useCallback( + (event: React.MouseEvent) => { + // To avoid a re-sorting when you delete a column + event.preventDefault(); + event.stopPropagation(); + onColumnRemoved(columnId); + }, + [columnId, onColumnRemoved] + ); + + return ( + + ); +}); + +CloseButton.displayName = 'CloseButton'; + +export const Actions = React.memo(({ header, onColumnRemoved, sort, isLoading }) => { + return ( + <> + {sort.some((i) => i.columnId === header.id) && isLoading ? ( + + + + ) : ( + + + + )} + + ); +}); + +Actions.displayName = 'Actions'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/column_header.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/column_header.tsx new file mode 100644 index 00000000000000..bd8e9508de859e --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/column_header.tsx @@ -0,0 +1,310 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiContextMenu, EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover } from '@elastic/eui'; +import { + DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, + getDraggableFieldId, +} from '@kbn/securitysolution-t-grid'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Draggable } from 'react-beautiful-dnd'; +import { Resizable, ResizeCallback } from 're-resizable'; +import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; + +import { DEFAULT_COLUMN_MIN_WIDTH } from '../constants'; + +import { ARIA_COLUMN_INDEX_OFFSET } from '../../helpers'; +import { EventsTh, EventsThContent, EventsHeadingHandle } from '../../styles'; +import { Sort } from '../sort'; + +import { Header } from './header'; + +import * as i18n from './translations'; +import { tGridActions } from '../../../../store/t_grid'; +import { TimelineTabs } from '../../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { ColumnHeaderOptions } from '../../../../../common/types/timeline'; + +import { Direction } from '../../../../../common/search_strategy'; +import { useDraggableKeyboardWrapper } from '../../../drag_and_drop'; + +const ContextMenu = styled(EuiContextMenu)` + width: 115px; + + & .euiContextMenuItem { + font-size: 12px; + padding: 4px 8px; + width: 115px; + } +`; + +const PopoverContainer = styled.div<{ $width: number }>` + & .euiPopover__anchor { + padding-right: 8px; + width: ${({ $width }) => $width}px; + } +`; + +const RESIZABLE_ENABLE = { right: true }; + +interface ColumneHeaderProps { + draggableIndex: number; + header: ColumnHeaderOptions; + isDragging: boolean; + sort: Sort[]; + tabType: TimelineTabs; + timelineId: string; +} + +const ColumnHeaderComponent: React.FC = ({ + draggableIndex, + header, + timelineId, + isDragging, + sort, + tabType, +}) => { + const keyboardHandlerRef = useRef(null); + const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState(false); + const restoreFocus = useCallback(() => keyboardHandlerRef.current?.focus(), []); + + const dispatch = useDispatch(); + const resizableSize = useMemo( + () => ({ + width: header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH, + height: 'auto', + }), + [header.initialWidth] + ); + const resizableStyle: { + position: 'absolute' | 'relative'; + } = useMemo( + () => ({ + position: isDragging ? 'absolute' : 'relative', + }), + [isDragging] + ); + const resizableHandleComponent = useMemo( + () => ({ + right: , + }), + [] + ); + const handleResizeStop: ResizeCallback = useCallback( + (e, direction, ref, delta) => { + dispatch( + tGridActions.applyDeltaToColumnWidth({ + columnId: header.id, + delta: delta.width, + id: timelineId, + }) + ); + }, + [dispatch, header.id, timelineId] + ); + const draggableId = useMemo( + () => + getDraggableFieldId({ + contextId: `timeline-column-headers-${tabType}-${timelineId}`, + fieldId: header.id, + }), + [tabType, timelineId, header.id] + ); + + const onColumnSort = useCallback( + (sortDirection: Direction) => { + const columnId = header.id; + const headerIndex = sort.findIndex((col) => col.columnId === columnId); + const newSort = + headerIndex === -1 + ? [ + ...sort, + { + columnId, + columnType: `${header.type}`, + sortDirection, + }, + ] + : [ + ...sort.slice(0, headerIndex), + { + columnId, + columnType: `${header.type}`, + sortDirection, + }, + ...sort.slice(headerIndex + 1), + ]; + + dispatch( + tGridActions.updateSort({ + id: timelineId, + sort: newSort, + }) + ); + }, + [dispatch, header, sort, timelineId] + ); + + const handleClosePopOverTrigger = useCallback(() => { + setHoverActionsOwnFocus(false); + restoreFocus(); + }, [restoreFocus]); + + const panels: EuiContextMenuPanelDescriptor[] = useMemo( + () => [ + { + id: 0, + items: [ + { + icon: , + name: i18n.HIDE_COLUMN, + onClick: () => { + dispatch(tGridActions.removeColumn({ id: timelineId, columnId: header.id })); + handleClosePopOverTrigger(); + }, + }, + ...(tabType !== TimelineTabs.eql + ? [ + { + disabled: !header.aggregatable, + icon: , + name: i18n.SORT_AZ, + onClick: () => { + onColumnSort(Direction.asc); + handleClosePopOverTrigger(); + }, + }, + { + disabled: !header.aggregatable, + icon: , + name: i18n.SORT_ZA, + onClick: () => { + onColumnSort(Direction.desc); + handleClosePopOverTrigger(); + }, + }, + ] + : []), + ], + }, + ], + [ + dispatch, + handleClosePopOverTrigger, + header.aggregatable, + header.id, + onColumnSort, + tabType, + timelineId, + ] + ); + + const headerButton = useMemo( + () =>
, + [header, sort, timelineId] + ); + + const DraggableContent = useCallback( + (dragProvided) => ( + + + + + + + + + + ), + [handleClosePopOverTrigger, headerButton, header.initialWidth, hoverActionsOwnFocus, panels] + ); + + const onFocus = useCallback(() => { + keyboardHandlerRef.current?.focus(); + }, []); + + const openPopover = useCallback(() => { + setHoverActionsOwnFocus(true); + }, []); + + const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({ + closePopover: handleClosePopOverTrigger, + draggableId, + fieldName: header.id, + keyboardHandlerRef, + openPopover, + }); + + const keyDownHandler = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (!hoverActionsOwnFocus) { + onKeyDown(keyboardEvent); + } + }, + [hoverActionsOwnFocus, onKeyDown] + ); + + return ( + +
+ + {DraggableContent} + +
+
+ ); +}; + +export const ColumnHeader = React.memo( + ColumnHeaderComponent, + (prevProps, nextProps) => + prevProps.draggableIndex === nextProps.draggableIndex && + prevProps.tabType === nextProps.tabType && + prevProps.timelineId === nextProps.timelineId && + prevProps.isDragging === nextProps.isDragging && + deepEqual(prevProps.sort, nextProps.sort) && + deepEqual(prevProps.header, nextProps.header) +); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/common/dragging_container.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/common/dragging_container.tsx new file mode 100644 index 00000000000000..0d7ed0a91121e5 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/common/dragging_container.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FC, memo, useEffect } from 'react'; + +interface DraggingContainerProps { + children: JSX.Element; + onDragging: Function; +} + +const DraggingContainerComponent: FC = ({ children, onDragging }) => { + useEffect(() => { + onDragging(true); + + return () => onDragging(false); + }); + + return children; +}; + +export const DraggingContainer = memo(DraggingContainerComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/common/styles.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/common/styles.tsx new file mode 100644 index 00000000000000..254c7076fcf5a4 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/common/styles.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +export const FullHeightFlexGroup = styled(EuiFlexGroup)` + height: 100%; +`; +FullHeightFlexGroup.displayName = 'FullHeightFlexGroup'; + +export const FullHeightFlexItem = styled(EuiFlexItem)` + height: 100%; +`; +FullHeightFlexItem.displayName = 'FullHeightFlexItem'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/default_headers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/default_headers.ts new file mode 100644 index 00000000000000..9a32c514e7064b --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/default_headers.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ColumnHeaderOptions, ColumnHeaderType } from '../../../../../common/types/timeline'; +import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; + +export const defaultColumnHeaderType: ColumnHeaderType = 'not-filtered'; + +export const defaultHeaders: ColumnHeaderOptions[] = [ + { + columnHeaderType: defaultColumnHeaderType, + id: '@timestamp', + type: 'number', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'message', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'event.category', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'event.action', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'host.name', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'source.ip', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'destination.ip', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'user.name', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, +]; + +/** The default category of fields shown in the Timeline */ +export const DEFAULT_CATEGORY_NAME = 'default ECS'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000000..ff2bdf2f643a00 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/__snapshots__/index.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header renders correctly against snapshot 1`] = ` + + + + + +`; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/header_content.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/header_content.tsx new file mode 100644 index 00000000000000..04004b3e903143 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/header_content.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiToolTip } from '@elastic/eui'; +import { noop } from 'lodash/fp'; +import React from 'react'; + +import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline'; +import { TruncatableText } from '../../../../truncatable_text'; + +import { EventsHeading, EventsHeadingTitleButton, EventsHeadingTitleSpan } from '../../../styles'; +import { Sort } from '../../sort'; +import { SortIndicator } from '../../sort/sort_indicator'; +import { HeaderToolTipContent } from '../header_tooltip_content'; +import { getSortDirection, getSortIndex } from './helpers'; +interface HeaderContentProps { + children: React.ReactNode; + header: ColumnHeaderOptions; + isLoading: boolean; + isResizing: boolean; + onClick: () => void; + showSortingCapability: boolean; + sort: Sort[]; +} + +const HeaderContentComponent: React.FC = ({ + children, + header, + isLoading, + isResizing, + onClick, + showSortingCapability, + sort, +}) => ( + + {header.aggregatable && showSortingCapability ? ( + + + } + > + <> + {React.isValidElement(header.display) + ? header.display + : header.displayAsText ?? header.id} + + + + + + + ) : ( + + + } + > + <> + {React.isValidElement(header.display) + ? header.display + : header.displayAsText ?? header.id} + + + + + )} + + {children} + +); + +export const HeaderContent = React.memo(HeaderContentComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/helpers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/helpers.ts new file mode 100644 index 00000000000000..84c7155aba8c0a --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/helpers.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Direction } from '../../../../../../common'; +// eslint-disable-next-line no-duplicate-imports +import type { ColumnHeaderOptions } from '../../../../../../common'; +import { assertUnreachable } from '../../../../../../common/utility_types'; +import { Sort, SortDirection } from '../../sort'; + +interface GetNewSortDirectionOnClickParams { + clickedHeader: ColumnHeaderOptions; + currentSort: Sort[]; +} + +/** Given a `header`, returns the `SortDirection` applicable to it */ +export const getNewSortDirectionOnClick = ({ + clickedHeader, + currentSort, +}: GetNewSortDirectionOnClickParams): Direction => + currentSort.reduce( + (acc, item) => (clickedHeader.id === item.columnId ? getNextSortDirection(item) : acc), + Direction.desc + ); + +/** Given a current sort direction, it returns the next sort direction */ +export const getNextSortDirection = (currentSort: Sort): Direction => { + switch (currentSort.sortDirection) { + case Direction.desc: + return Direction.asc; + case Direction.asc: + return Direction.desc; + case 'none': + return Direction.desc; + default: + return assertUnreachable(currentSort.sortDirection as never, 'Unhandled sort direction'); + } +}; + +interface GetSortDirectionParams { + header: ColumnHeaderOptions; + sort: Sort[]; +} + +export const getSortDirection = ({ header, sort }: GetSortDirectionParams): SortDirection => + sort.reduce( + (acc, item) => (header.id === item.columnId ? item.sortDirection : acc), + 'none' + ); + +export const getSortIndex = ({ header, sort }: GetSortDirectionParams): number => + sort.findIndex((s) => s.columnId === header.id); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/index.test.tsx new file mode 100644 index 00000000000000..4685af483c21e5 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/index.test.tsx @@ -0,0 +1,331 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount, shallow } from 'enzyme'; +import React from 'react'; + +import { Sort } from '../../sort'; +import { CloseButton } from '../actions'; +import { defaultHeaders } from '../default_headers'; + +import { HeaderComponent } from '.'; +import { getNewSortDirectionOnClick, getNextSortDirection, getSortDirection } from './helpers'; +import { Direction } from '../../../../../../common/search_strategy'; +import { TestProviders } from '../../../../../mock'; +import { tGridActions } from '../../../../../store/t_grid'; +import { mockGlobalState } from '../../../../../mock/global_state'; + +const mockDispatch = jest.fn(); +jest.mock('../../../../../hooks/use_selector', () => ({ + useShallowEqualSelector: () => mockGlobalState.timelineById.test, + useDeepEqualSelector: () => mockGlobalState.timelineById.test, +})); + +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useSelector: jest.fn(), + useDispatch: () => mockDispatch, + }; +}); + +describe('Header', () => { + const columnHeader = defaultHeaders[0]; + const sort: Sort[] = [ + { + columnId: columnHeader.id, + columnType: columnHeader.type ?? 'number', + sortDirection: Direction.desc, + }, + ]; + const timelineId = 'test'; + + test('renders correctly against snapshot', () => { + const wrapper = shallow( + + + + ); + expect(wrapper.find('HeaderComponent').dive()).toMatchSnapshot(); + }); + + describe('rendering', () => { + test('it renders the header text', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() + ).toEqual(columnHeader.id); + }); + + test('it renders the header text alias when displayAsText is provided', () => { + const displayAsText = 'Timestamp'; + const headerWithLabel = { ...columnHeader, displayAsText }; + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() + ).toEqual(displayAsText); + }); + + test('it renders the header as a `ReactNode` when `display` is provided', () => { + const display: React.ReactNode = ( +
+ {'The display property renders the column heading as a ReactNode'} +
+ ); + const headerWithLabel = { ...columnHeader, display }; + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="rendered-via-display"]`).exists()).toBe(true); + }); + + test('it prefers to render `display` instead of `displayAsText` when both are provided', () => { + const displayAsText = 'this text should NOT be rendered'; + const display: React.ReactNode = ( +
{'this text is rendered via display'}
+ ); + const headerWithLabel = { ...columnHeader, display, displayAsText }; + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toBe('this text is rendered via display'); + }); + + test('it falls back to rendering header.id when `display` is not a valid React node', () => { + const display = {}; // a plain object is NOT a `ReactNode` + const headerWithLabel = { ...columnHeader, display }; + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() + ).toEqual(columnHeader.id); + }); + + test('it renders a sort indicator', () => { + const headerSortable = { ...columnHeader, aggregatable: true }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="header-sort-indicator"]').first().exists()).toEqual( + true + ); + }); + }); + + describe('onColumnSorted', () => { + test('it invokes the onColumnSorted callback when the header sort button is clicked', () => { + const headerSortable = { ...columnHeader, aggregatable: true }; + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="header-sort-button"]').first().simulate('click'); + + expect(mockDispatch).toBeCalledWith( + tGridActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: columnHeader.id, + columnType: columnHeader.type ?? 'number', + sortDirection: Direction.asc, // (because the previous state was Direction.desc) + }, + ], + }) + ); + }); + + test('it does NOT render the header sort button when aggregatable is false', () => { + const headerSortable = { ...columnHeader, aggregatable: false }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="header-sort-button"]').length).toEqual(0); + }); + + test('it does NOT render the header sort button when aggregatable is missing', () => { + const headerSortable = { ...columnHeader }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="header-sort-button"]').length).toEqual(0); + }); + + test('it does NOT invoke the onColumnSorted callback when the header is clicked and aggregatable is undefined', () => { + const mockOnColumnSorted = jest.fn(); + const headerSortable = { ...columnHeader, aggregatable: undefined }; + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="header-${columnHeader.id}"]`).first().simulate('click'); + + expect(mockOnColumnSorted).not.toHaveBeenCalled(); + }); + }); + + describe('CloseButton', () => { + test('it invokes the onColumnRemoved callback with the column ID when the close button is clicked', () => { + const mockOnColumnRemoved = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="remove-column"]').first().simulate('click'); + + expect(mockOnColumnRemoved).toBeCalledWith(columnHeader.id); + }); + }); + + describe('getSortDirection', () => { + test('it returns the sort direction when the header id matches the sort column id', () => { + expect(getSortDirection({ header: columnHeader, sort })).toEqual(sort[0].sortDirection); + }); + + test('it returns "none" when sort direction when the header id does NOT match the sort column id', () => { + const nonMatching: Sort[] = [ + { + columnId: 'differentSocks', + columnType: columnHeader.type ?? 'number', + sortDirection: Direction.desc, + }, + ]; + + expect(getSortDirection({ header: columnHeader, sort: nonMatching })).toEqual('none'); + }); + }); + + describe('getNextSortDirection', () => { + test('it returns "asc" when the current direction is "desc"', () => { + const sortDescending: Sort = { + columnId: columnHeader.id, + columnType: columnHeader.type ?? 'number', + sortDirection: Direction.desc, + }; + + expect(getNextSortDirection(sortDescending)).toEqual('asc'); + }); + + test('it returns "desc" when the current direction is "asc"', () => { + const sortAscending: Sort = { + columnId: columnHeader.id, + columnType: columnHeader.type ?? 'number', + sortDirection: Direction.asc, + }; + + expect(getNextSortDirection(sortAscending)).toEqual(Direction.desc); + }); + + test('it returns "desc" by default', () => { + const sortNone: Sort = { + columnId: columnHeader.id, + columnType: columnHeader.type ?? 'number', + sortDirection: 'none', + }; + + expect(getNextSortDirection(sortNone)).toEqual(Direction.desc); + }); + }); + + describe('getNewSortDirectionOnClick', () => { + test('it returns the expected new sort direction when the header id matches the sort column id', () => { + const sortMatches: Sort[] = [ + { + columnId: columnHeader.id, + columnType: columnHeader.type ?? 'number', + sortDirection: Direction.desc, + }, + ]; + + expect( + getNewSortDirectionOnClick({ + clickedHeader: columnHeader, + currentSort: sortMatches, + }) + ).toEqual(Direction.asc); + }); + + test('it returns the expected new sort direction when the header id does NOT match the sort column id', () => { + const sortDoesNotMatch: Sort[] = [ + { + columnId: 'someOtherColumn', + columnType: columnHeader.type ?? 'number', + sortDirection: 'none', + }, + ]; + + expect( + getNewSortDirectionOnClick({ + clickedHeader: columnHeader, + currentSort: sortDoesNotMatch, + }) + ).toEqual(Direction.desc); + }); + }); + + describe('text truncation styling', () => { + test('truncates the header text with an ellipsis', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).at(1) + ).toHaveStyleRule('text-overflow', 'ellipsis'); + }); + }); + + describe('header tooltip', () => { + test('it has a tooltip to display the properties of the field', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="header-tooltip"]').exists()).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/index.tsx new file mode 100644 index 00000000000000..1b0f44e686501a --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header/index.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; + +import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline'; +import type { Sort } from '../../sort'; +import { Actions } from '../actions'; +import { getNewSortDirectionOnClick } from './helpers'; +import { HeaderContent } from './header_content'; +import { tGridActions, tGridSelectors } from '../../../../../store/t_grid'; +import { useDeepEqualSelector } from '../../../../../hooks/use_selector'; +interface Props { + header: ColumnHeaderOptions; + sort: Sort[]; + timelineId: string; +} + +export const HeaderComponent: React.FC = ({ header, sort, timelineId }) => { + const dispatch = useDispatch(); + + const onColumnSort = useCallback(() => { + const columnId = header.id; + const columnType = header.type ?? 'text'; + const sortDirection = getNewSortDirectionOnClick({ + clickedHeader: header, + currentSort: sort, + }); + const headerIndex = sort.findIndex((col) => col.columnId === columnId); + let newSort = []; + if (headerIndex === -1) { + newSort = [ + ...sort, + { + columnId, + columnType, + sortDirection, + }, + ]; + } else { + newSort = [ + ...sort.slice(0, headerIndex), + { + columnId, + columnType, + sortDirection, + }, + ...sort.slice(headerIndex + 1), + ]; + } + dispatch( + tGridActions.updateSort({ + id: timelineId, + sort: newSort, + }) + ); + }, [dispatch, header, sort, timelineId]); + + const onColumnRemoved = useCallback( + (columnId) => dispatch(tGridActions.removeColumn({ id: timelineId, columnId })), + [dispatch, timelineId] + ); + + const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); + const { isLoading } = useDeepEqualSelector((state) => getManageTimeline(state, timelineId ?? '')); + const showSortingCapability = !(header.subType && header.subType.nested); + + return ( + <> + + + + + ); +}; + +export const Header = React.memo(HeaderComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header_tooltip_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header_tooltip_content/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000000..945a9a7aee698c --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header_tooltip_content/__snapshots__/index.test.tsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderToolTipContent it renders the expected table content 1`] = ` + +

+ + Category + : + + + base + +

+

+ + Field + : + + + @timestamp + +

+

+ + Type + : + + + + + date + + +

+

+ + Description + : + + + Date/time when the event originated. +For log events this is the date/time when the event was generated, and not when it was read. +Required field for all events. + +

+
+`; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header_tooltip_content/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header_tooltip_content/index.test.tsx new file mode 100644 index 00000000000000..a38261994267ca --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header_tooltip_content/index.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount, shallow } from 'enzyme'; +import { cloneDeep } from 'lodash/fp'; +import React from 'react'; + +import { ColumnHeaderOptions } from '../../../../../../common/types/timeline'; +import { HeaderToolTipContent } from '.'; +import { defaultHeaders } from '../../../../../mock/header'; + +describe('HeaderToolTipContent', () => { + let header: ColumnHeaderOptions; + beforeEach(() => { + header = cloneDeep(defaultHeaders[0]); + }); + + test('it renders the category', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="category-value"]').first().text()).toEqual( + header.category + ); + }); + + test('it renders the name of the field', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="field-value"]').first().text()).toEqual(header.id); + }); + + test('it renders the expected icon for the header type', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="type-icon"]').first().props().type).toEqual('clock'); + }); + + test('it renders the type of the field', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="type-value"]').first().text()).toEqual(header.type); + }); + + test('it renders the description of the field', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="description-value"]').first().text()).toEqual( + header.description + ); + }); + + test('it does NOT render the description column when the field does NOT contain a description', () => { + const noDescription = { + ...header, + description: '', + }; + + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="description"]').exists()).toEqual(false); + }); + + test('it renders the expected table content', () => { + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header_tooltip_content/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header_tooltip_content/index.tsx new file mode 100644 index 00000000000000..b973d99584d61c --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/header_tooltip_content/index.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIcon } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React from 'react'; +import styled from 'styled-components'; + +import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline'; +import { getIconFromType } from '../../../../utils/helpers'; +import * as i18n from '../translations'; + +const IconType = styled(EuiIcon)` + margin-right: 3px; + position: relative; + top: -2px; +`; +IconType.displayName = 'IconType'; + +const P = styled.span` + margin-bottom: 5px; +`; +P.displayName = 'P'; + +const ToolTipTableMetadata = styled.span` + margin-right: 5px; + display: block; +`; +ToolTipTableMetadata.displayName = 'ToolTipTableMetadata'; + +const ToolTipTableValue = styled.span` + word-wrap: break-word; +`; +ToolTipTableValue.displayName = 'ToolTipTableValue'; + +export const HeaderToolTipContent = React.memo<{ header: ColumnHeaderOptions }>(({ header }) => ( + <> + {!isEmpty(header.category) && ( +

+ + {i18n.CATEGORY} + {':'} + + {header.category} +

+ )} +

+ + {i18n.FIELD} + {':'} + + {header.id} +

+

+ + {i18n.TYPE} + {':'} + + + + {header.type} + +

+ {!isEmpty(header.description) && ( +

+ + {i18n.DESCRIPTION} + {':'} + + + {header.description} + +

+ )} + +)); +HeaderToolTipContent.displayName = 'HeaderToolTipContent'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.ts new file mode 100644 index 00000000000000..d19f221966e55e --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { defaultHeaders } from './default_headers'; +import { getActionsColumnWidth, getColumnWidthFromType, getColumnHeaders } from './helpers'; +import { + DEFAULT_COLUMN_MIN_WIDTH, + DEFAULT_DATE_COLUMN_MIN_WIDTH, + DEFAULT_ACTIONS_COLUMN_WIDTH, + EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH, + SHOW_CHECK_BOXES_COLUMN_WIDTH, +} from '../constants'; +import { mockBrowserFields } from '../../../../mock/browser_fields'; + +window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + }; +}); + +describe('helpers', () => { + describe('getColumnWidthFromType', () => { + test('it returns the expected width for a non-date column', () => { + expect(getColumnWidthFromType('keyword')).toEqual(DEFAULT_COLUMN_MIN_WIDTH); + }); + + test('it returns the expected width for a date column', () => { + expect(getColumnWidthFromType('date')).toEqual(DEFAULT_DATE_COLUMN_MIN_WIDTH); + }); + }); + + describe('getActionsColumnWidth', () => { + test('returns the default actions column width when isEventViewer is false', () => { + expect(getActionsColumnWidth(false)).toEqual(DEFAULT_ACTIONS_COLUMN_WIDTH); + }); + + test('returns the default actions column width + checkbox width when isEventViewer is false and showCheckboxes is true', () => { + expect(getActionsColumnWidth(false, true)).toEqual( + DEFAULT_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH + ); + }); + + test('returns the events viewer actions column width when isEventViewer is true', () => { + expect(getActionsColumnWidth(true)).toEqual(EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH); + }); + + test('returns the events viewer actions column width + checkbox width when isEventViewer is true and showCheckboxes is true', () => { + expect(getActionsColumnWidth(true, true)).toEqual( + EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH + ); + }); + }); + + describe('getColumnHeaders', () => { + test('should return a full object of ColumnHeader from the default header', () => { + const expectedData = [ + { + aggregatable: true, + category: 'base', + columnHeaderType: 'not-filtered', + description: + 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.', + example: '2016-05-23T08:05:34.853Z', + format: '', + id: '@timestamp', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: '@timestamp', + searchable: true, + type: 'date', + initialWidth: 190, + }, + { + aggregatable: true, + category: 'source', + columnHeaderType: 'not-filtered', + description: 'IP address of the source. Can be one or multiple IPv4 or IPv6 addresses.', + example: '', + format: '', + id: 'source.ip', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'source.ip', + searchable: true, + type: 'ip', + initialWidth: 180, + }, + { + aggregatable: true, + category: 'destination', + columnHeaderType: 'not-filtered', + description: + 'IP address of the destination. Can be one or multiple IPv4 or IPv6 addresses.', + example: '', + format: '', + id: 'destination.ip', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'destination.ip', + searchable: true, + type: 'ip', + initialWidth: 180, + }, + ]; + const mockHeader = defaultHeaders.filter((h) => + ['@timestamp', 'source.ip', 'destination.ip'].includes(h.id) + ); + expect(getColumnHeaders(mockHeader, mockBrowserFields)).toEqual(expectedData); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts new file mode 100644 index 00000000000000..fc566da8c58a2a --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get } from 'lodash/fp'; +import type { BrowserFields } from '../../../../../common/search_strategy/index_fields'; +import type { ColumnHeaderOptions } from '../../../../../common/types/timeline'; + +import { + DEFAULT_COLUMN_MIN_WIDTH, + DEFAULT_DATE_COLUMN_MIN_WIDTH, + SHOW_CHECK_BOXES_COLUMN_WIDTH, + EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH, + DEFAULT_ACTIONS_COLUMN_WIDTH, + MINIMUM_ACTIONS_COLUMN_WIDTH, +} from '../constants'; + +/** Enriches the column headers with field details from the specified browserFields */ +export const getColumnHeaders = ( + headers: ColumnHeaderOptions[], + browserFields: BrowserFields +): ColumnHeaderOptions[] => { + return headers.map((header) => { + const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + + return { + ...header, + ...get( + [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], + browserFields + ), + }; + }); +}; + +export const getColumnWidthFromType = (type: string): number => + type !== 'date' ? DEFAULT_COLUMN_MIN_WIDTH : DEFAULT_DATE_COLUMN_MIN_WIDTH; + +/** Returns the (fixed) width of the Actions column */ +export const getActionsColumnWidth = ( + isEventViewer: boolean, + showCheckboxes = false, + additionalActionWidth = 0 +): number => { + const checkboxesWidth = showCheckboxes ? SHOW_CHECK_BOXES_COLUMN_WIDTH : 0; + const actionsColumnWidth = + checkboxesWidth + + (isEventViewer ? EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH : DEFAULT_ACTIONS_COLUMN_WIDTH) + + additionalActionWidth; + + return actionsColumnWidth > MINIMUM_ACTIONS_COLUMN_WIDTH + checkboxesWidth + ? actionsColumnWidth + : MINIMUM_ACTIONS_COLUMN_WIDTH + checkboxesWidth; +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/index.test.tsx new file mode 100644 index 00000000000000..1466b06f8ed25b --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/index.test.tsx @@ -0,0 +1,316 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; + +import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants'; +import { defaultHeaders } from './default_headers'; +import { Sort } from '../sort'; + +import { ColumnHeadersComponent } from '.'; +import { cloneDeep } from 'lodash/fp'; +import { useMountAppended } from '../../../utils/use_mount_appended'; +import { mockBrowserFields } from '../../../../mock/browser_fields'; +import { Direction } from '../../../../../common/search_strategy'; +import { TimelineTabs } from '../../../../../common/types/timeline'; +import { tGridActions } from '../../../../store/t_grid'; +import { testTrailingControlColumns } from '../../../../mock/mock_timeline_control_columns'; +import { TestProviders } from '../../../../mock'; +import { mockGlobalState } from '../../../../mock/global_state'; + +const mockDispatch = jest.fn(); +jest.mock('../../../../hooks/use_selector', () => ({ + useShallowEqualSelector: () => mockGlobalState.timelineById.test, + useDeepEqualSelector: () => mockGlobalState.timelineById.test, +})); + +window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + }; +}); + +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); +const timelineId = 'test'; + +describe('ColumnHeaders', () => { + const mount = useMountAppended(); + + describe('rendering', () => { + const sort: Sort[] = [ + { + columnId: '@timestamp', + columnType: 'number', + sortDirection: Direction.desc, + }, + ]; + + test('renders correctly against snapshot', () => { + const wrapper = shallow( + + + + ); + expect(wrapper.find('ColumnHeadersComponent')).toMatchSnapshot(); + }); + + // TODO BrowserField When we bring back browser fields unskip + test.skip('it renders the field browser', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="field-browser"]').first().exists()).toEqual(true); + }); + + test('it renders every column header', () => { + const wrapper = mount( + + + + ); + + defaultHeaders.forEach((h) => { + expect(wrapper.find('[data-test-subj="headers-group"]').first().text()).toContain(h.id); + }); + }); + }); + + describe('#onColumnsSorted', () => { + let mockSort: Sort[] = [ + { + columnId: '@timestamp', + columnType: 'number', + sortDirection: Direction.desc, + }, + { + columnId: 'host.name', + columnType: 'text', + sortDirection: Direction.asc, + }, + ]; + let mockDefaultHeaders = cloneDeep( + defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true })) + ); + + beforeEach(() => { + mockDefaultHeaders = cloneDeep( + defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true })) + ); + mockSort = [ + { + columnId: '@timestamp', + columnType: 'number', + sortDirection: Direction.desc, + }, + { + columnId: 'host.name', + columnType: 'text', + sortDirection: Direction.asc, + }, + ]; + }); + + test('Add column `event.category` as desc sorting', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="header-event.category"] [data-test-subj="header-sort-button"]') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: '@timestamp', + columnType: 'number', + sortDirection: Direction.desc, + }, + { + columnId: 'host.name', + columnType: 'text', + sortDirection: Direction.asc, + }, + { columnId: 'event.category', columnType: 'text', sortDirection: Direction.desc }, + ], + }) + ); + }); + + test('Change order of column `@timestamp` from desc to asc without changing index position', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="header-@timestamp"] [data-test-subj="header-sort-button"]') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: '@timestamp', + columnType: 'number', + sortDirection: Direction.asc, + }, + { columnId: 'host.name', columnType: 'text', sortDirection: Direction.asc }, + ], + }) + ); + }); + + test('Change order of column `host.name` from asc to desc without changing index position', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="header-host.name"] [data-test-subj="header-sort-button"]') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: '@timestamp', + columnType: 'number', + sortDirection: Direction.desc, + }, + { columnId: 'host.name', columnType: 'text', sortDirection: Direction.desc }, + ], + }) + ); + }); + test('Does not render the default leading action column header and renders a custom trailing header', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.exists('[data-test-subj="field-browser"]')).toBeFalsy(); + expect(wrapper.exists('[data-test-subj="test-header-action-cell"]')).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/index.tsx new file mode 100644 index 00000000000000..1d4141cd1ff5d3 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/index.tsx @@ -0,0 +1,295 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DRAG_TYPE_FIELD, droppableTimelineColumnsPrefix } from '@kbn/securitysolution-t-grid'; +import deepEqual from 'fast-deep-equal'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { Droppable, DraggableChildrenFn } from 'react-beautiful-dnd'; + +import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { + ControlColumnProps, + ColumnHeaderOptions, + HeaderActionProps, +} from '../../../../../common/types/timeline'; + +import type { BrowserFields } from '../../../../../common/search_strategy/index_fields'; + +import type { OnSelectAll } from '../../types'; +import { + EventsTh, + EventsThead, + EventsThGroupData, + EventsTrHeader, + EventsThGroupActions, +} from '../../styles'; +import { Sort } from '../sort'; +import { ColumnHeader } from './column_header'; +import { DraggableFieldBadge } from '../../../draggables'; + +interface Props { + actionsColumnWidth: number; + browserFields: BrowserFields; + columnHeaders: ColumnHeaderOptions[]; + isEventViewer?: boolean; + isSelectAllChecked: boolean; + onSelectAll: OnSelectAll; + showEventsSelect: boolean; + showSelectAllCheckbox: boolean; + sort: Sort[]; + tabType: TimelineTabs; + timelineId: string; + leadingControlColumns: ControlColumnProps[]; + trailingControlColumns: ControlColumnProps[]; +} + +interface DraggableContainerProps { + children: React.ReactNode; + onMount: () => void; + onUnmount: () => void; +} + +export const DraggableContainer = React.memo( + ({ children, onMount, onUnmount }) => { + useEffect(() => { + onMount(); + + return () => onUnmount(); + }, [onMount, onUnmount]); + + return <>{children}; + } +); + +DraggableContainer.displayName = 'DraggableContainer'; + +export const isFullScreen = ({ + globalFullScreen, + timelineId, + timelineFullScreen, +}: { + globalFullScreen: boolean; + timelineId: string; + timelineFullScreen: boolean; +}) => + (timelineId === TimelineId.active && timelineFullScreen) || + (timelineId !== TimelineId.active && globalFullScreen); + +/** Renders the timeline header columns */ +export const ColumnHeadersComponent = ({ + actionsColumnWidth, + browserFields, + columnHeaders, + isEventViewer = false, + isSelectAllChecked, + onSelectAll, + showEventsSelect, + showSelectAllCheckbox, + sort, + tabType, + timelineId, + leadingControlColumns, + trailingControlColumns, +}: Props) => { + const [draggingIndex, setDraggingIndex] = useState(null); + + const renderClone: DraggableChildrenFn = useCallback( + (dragProvided, _dragSnapshot, rubric) => { + const index = rubric.source.index; + const header = columnHeaders[index]; + + const onMount = () => setDraggingIndex(index); + const onUnmount = () => setDraggingIndex(null); + + return ( + + + + + + ); + }, + [columnHeaders, setDraggingIndex] + ); + + const ColumnHeaderList = useMemo( + () => + columnHeaders.map((header, draggableIndex) => ( + + )), + [columnHeaders, timelineId, draggingIndex, sort, tabType] + ); + + const DroppableContent = useCallback( + (dropProvided, snapshot) => ( + <> + + {ColumnHeaderList} + + + ), + [ColumnHeaderList] + ); + + const leadingHeaderCells = useMemo( + () => + leadingControlColumns ? leadingControlColumns.map((column) => column.headerCellRender) : [], + [leadingControlColumns] + ); + + const trailingHeaderCells = useMemo( + () => + trailingControlColumns ? trailingControlColumns.map((column) => column.headerCellRender) : [], + [trailingControlColumns] + ); + + const LeadingHeaderActions = useMemo(() => { + return leadingHeaderCells.map( + (Header: React.ComponentType | React.ComponentType | undefined, index) => { + const passedWidth = leadingControlColumns[index] && leadingControlColumns[index].width; + const width = passedWidth ? passedWidth : actionsColumnWidth; + return ( + + {Header && ( +
+ )} + + ); + } + ); + }, [ + leadingHeaderCells, + leadingControlColumns, + actionsColumnWidth, + browserFields, + columnHeaders, + isEventViewer, + isSelectAllChecked, + onSelectAll, + showEventsSelect, + showSelectAllCheckbox, + sort, + tabType, + timelineId, + ]); + + const TrailingHeaderActions = useMemo(() => { + return trailingHeaderCells.map( + (Header: React.ComponentType | React.ComponentType | undefined, index) => { + const passedWidth = trailingControlColumns[index] && trailingControlColumns[index].width; + const width = passedWidth ? passedWidth : actionsColumnWidth; + return ( + + {Header && ( +
+ )} + + ); + } + ); + }, [ + trailingHeaderCells, + trailingControlColumns, + actionsColumnWidth, + browserFields, + columnHeaders, + isEventViewer, + isSelectAllChecked, + onSelectAll, + showEventsSelect, + showSelectAllCheckbox, + sort, + tabType, + timelineId, + ]); + return ( + + + {LeadingHeaderActions} + + {DroppableContent} + + {TrailingHeaderActions} + + + ); +}; + +export const ColumnHeaders = React.memo( + ColumnHeadersComponent, + (prevProps, nextProps) => + prevProps.actionsColumnWidth === nextProps.actionsColumnWidth && + prevProps.isEventViewer === nextProps.isEventViewer && + prevProps.isSelectAllChecked === nextProps.isSelectAllChecked && + prevProps.onSelectAll === nextProps.onSelectAll && + prevProps.showEventsSelect === nextProps.showEventsSelect && + prevProps.showSelectAllCheckbox === nextProps.showSelectAllCheckbox && + deepEqual(prevProps.sort, nextProps.sort) && + prevProps.timelineId === nextProps.timelineId && + deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) && + prevProps.tabType === nextProps.tabType && + deepEqual(prevProps.browserFields, nextProps.browserFields) +); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/translations.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/translations.ts new file mode 100644 index 00000000000000..2d4fbcbd54cfa2 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/translations.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CATEGORY = i18n.translate('xpack.timelines.timeline.categoryTooltip', { + defaultMessage: 'Category', +}); + +export const DESCRIPTION = i18n.translate('xpack.timelines.timeline.descriptionTooltip', { + defaultMessage: 'Description', +}); + +export const FIELD = i18n.translate('xpack.timelines.timeline.fieldTooltip', { + defaultMessage: 'Field', +}); + +export const FULL_SCREEN = i18n.translate('xpack.timelines.timeline.fullScreenButton', { + defaultMessage: 'Full screen', +}); + +export const HIDE_COLUMN = i18n.translate('xpack.timelines.timeline.hideColumnLabel', { + defaultMessage: 'Hide column', +}); + +export const SORT_AZ = i18n.translate('xpack.timelines.timeline.sortAZLabel', { + defaultMessage: 'Sort A-Z', +}); + +export const SORT_FIELDS = i18n.translate('xpack.timelines.timeline.sortFieldsButton', { + defaultMessage: 'Sort fields', +}); + +export const SORT_ZA = i18n.translate('xpack.timelines.timeline.sortZALabel', { + defaultMessage: 'Sort Z-A', +}); + +export const TYPE = i18n.translate('xpack.timelines.timeline.typeTooltip', { + defaultMessage: 'Type', +}); + +export const REMOVE_COLUMN = i18n.translate( + 'xpack.timelines.timeline.flyout.pane.removeColumnButtonLabel', + { + defaultMessage: 'Remove column', + } +); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/constants.ts b/x-pack/plugins/timelines/public/components/t_grid/body/constants.ts new file mode 100644 index 00000000000000..445211229574b5 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/constants.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** The minimum (fixed) width of the Actions column */ +export const MINIMUM_ACTIONS_COLUMN_WIDTH = 50; // px; + +/** Additional column width to include when checkboxes are shown **/ +export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px; + +/** The (fixed) width of the Actions column */ +export const DEFAULT_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH * 5; // px; +/** + * The (fixed) width of the Actions column when the timeline body is used as + * an events viewer, which has fewer actions than a regular events viewer + */ +export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH * 4; // px; + +/** The default minimum width of a column (when a width for the column type is not specified) */ +export const DEFAULT_COLUMN_MIN_WIDTH = 180; // px + +/** The minimum width of a resized column */ +export const RESIZED_COLUMN_MIN_WITH = 70; // px + +/** The default minimum width of a column of type `date` */ +export const DEFAULT_DATE_COLUMN_MIN_WIDTH = 190; // px diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/__snapshots__/index.test.tsx.snap b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000000..cbec3a3baa695b --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/__snapshots__/index.test.tsx.snap @@ -0,0 +1,967 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Columns it renders the expected columns 1`] = ` + + + + + + + + + +`; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.test.tsx new file mode 100644 index 00000000000000..e8459fa99d8c8a --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { shallow } from 'enzyme'; + +import React from 'react'; + +import { defaultHeaders } from '../column_headers/default_headers'; + +import { DataDrivenColumns } from '.'; +import { mockTimelineData } from '../../../../mock/mock_timeline_data'; +import { TestCellRenderer } from '../../../../mock/cell_renderer'; + +window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + }; +}); + +describe('Columns', () => { + const headersSansTimestamp = defaultHeaders.filter((h) => h.id !== '@timestamp'); + + test('it renders the expected columns', () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx new file mode 100644 index 00000000000000..23e94b92eaf3d6 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx @@ -0,0 +1,394 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiScreenReaderOnly } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { getOr } from 'lodash/fp'; + +import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-grid'; +import { OnRowSelected } from '../../types'; + +import { + EventsTd, + EVENTS_TD_CLASS_NAME, + EventsTdContent, + EventsTdGroupData, + EventsTdGroupActions, +} from '../../styles'; + +import { StatefulCell } from './stateful_cell'; +import * as i18n from './translations'; +import { TimelineTabs } from '../../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { + ActionProps, + CellValueElementProps, + ColumnHeaderOptions, + ControlColumnProps, + RowCellRender, +} from '../../../../../common/types/timeline'; +import type { TimelineNonEcsData } from '../../../../../common/search_strategy'; +import { ARIA_COLUMN_INDEX_OFFSET } from '../../helpers'; +import type { Ecs } from '../../../../../common/ecs'; + +interface CellProps { + _id: string; + ariaRowindex: number; + index: number; + header: ColumnHeaderOptions; + data: TimelineNonEcsData[]; + ecsData: Ecs; + hasRowRenderers: boolean; + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + tabType?: TimelineTabs; + timelineId: string; +} + +interface DataDrivenColumnProps { + id: string; + actionsColumnWidth: number; + ariaRowindex: number; + checked: boolean; + columnHeaders: ColumnHeaderOptions[]; + columnValues: string; + data: TimelineNonEcsData[]; + ecsData: Ecs; + isEventViewer?: boolean; + loadingEventIds: Readonly; + onEventDetailsPanelOpened: () => void; + onRowSelected: OnRowSelected; + onRuleChange?: () => void; + hasRowRenderers: boolean; + selectedEventIds: Readonly>; + showCheckboxes: boolean; + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + tabType?: TimelineTabs; + timelineId: string; + trailingControlColumns: ControlColumnProps[]; + leadingControlColumns: ControlColumnProps[]; +} + +const SPACE = ' '; + +export const shouldForwardKeyDownEvent = (key: string): boolean => { + switch (key) { + case SPACE: // fall through + case 'Enter': + return true; + default: + return false; + } +}; + +export const onKeyDown = (keyboardEvent: React.KeyboardEvent) => { + const { altKey, ctrlKey, key, metaKey, shiftKey, target, type } = keyboardEvent; + + const targetElement = target as Element; + + // we *only* forward the event to the (child) draggable keyboard wrapper + // if the keyboard event originated from the container (TD) element + if (shouldForwardKeyDownEvent(key) && targetElement.className?.includes(EVENTS_TD_CLASS_NAME)) { + const draggableKeyboardWrapper = targetElement.querySelector( + `.${DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME}` + ); + + const newEvent = new KeyboardEvent(type, { + altKey, + bubbles: true, + cancelable: true, + ctrlKey, + key, + metaKey, + shiftKey, + }); + + if (key === ' ') { + // prevent the default behavior of scrolling the table when space is pressed + keyboardEvent.preventDefault(); + } + + draggableKeyboardWrapper?.dispatchEvent(newEvent); + } +}; + +const TgridActionTdCell = ({ + action: Action, + width, + actionsColumnWidth, + ariaRowindex, + columnId, + columnValues, + data, + ecsData, + eventIdToNoteIds, + index, + isEventPinned, + isEventViewer, + eventId, + loadingEventIds, + onEventDetailsPanelOpened, + onRowSelected, + rowIndex, + hasRowRenderers, + onRuleChange, + selectedEventIds, + showCheckboxes, + showNotes = false, + tabType, + timelineId, + toggleShowNotes, +}: ActionProps & { + columnId: string; + hasRowRenderers: boolean; + actionsColumnWidth: number; + selectedEventIds: Readonly>; +}) => { + const displayWidth = width ? width : actionsColumnWidth; + return ( + + + + <> + +

{i18n.YOU_ARE_IN_A_TABLE_CELL({ row: ariaRowindex, column: index + 2 })}

+
+ {Action && ( + + )} + +
+ {hasRowRenderers ? ( + +

{i18n.EVENT_HAS_AN_EVENT_RENDERER(ariaRowindex)}

+
+ ) : null} +
+
+ ); +}; + +const TgridTdCell = ({ + _id, + ariaRowindex, + index, + header, + data, + ecsData, + hasRowRenderers, + renderCellValue, + tabType, + timelineId, +}: CellProps) => { + return ( + + + <> + +

{i18n.YOU_ARE_IN_A_TABLE_CELL({ row: ariaRowindex, column: index + 2 })}

+
+ + +
+ {hasRowRenderers ? ( + +

{i18n.EVENT_HAS_AN_EVENT_RENDERER(ariaRowindex)}

+
+ ) : null} +
+ ); +}; + +export const DataDrivenColumns = React.memo( + ({ + ariaRowindex, + actionsColumnWidth, + columnHeaders, + columnValues, + data, + ecsData, + isEventViewer, + id: _id, + loadingEventIds, + onEventDetailsPanelOpened, + onRowSelected, + hasRowRenderers, + onRuleChange, + renderCellValue, + selectedEventIds, + showCheckboxes, + tabType, + timelineId, + trailingControlColumns, + leadingControlColumns, + }) => { + const trailingActionCells = useMemo( + () => + trailingControlColumns ? trailingControlColumns.map((column) => column.rowCellRender) : [], + [trailingControlColumns] + ); + const leadingAndDataColumnCount = useMemo( + () => leadingControlColumns.length + columnHeaders.length, + [leadingControlColumns, columnHeaders] + ); + const TrailingActions = useMemo( + () => + trailingActionCells.map((Action: RowCellRender | undefined, index) => { + return ( + Action && ( + + ) + ); + }), + [ + trailingControlColumns, + _id, + data, + ecsData, + onRowSelected, + isEventViewer, + actionsColumnWidth, + ariaRowindex, + columnValues, + hasRowRenderers, + leadingAndDataColumnCount, + loadingEventIds, + onEventDetailsPanelOpened, + onRuleChange, + selectedEventIds, + showCheckboxes, + tabType, + timelineId, + trailingActionCells, + ] + ); + const ColumnHeaders = useMemo( + () => + columnHeaders.map((header, index) => ( + + )), + [ + _id, + ariaRowindex, + columnHeaders, + data, + ecsData, + hasRowRenderers, + renderCellValue, + tabType, + timelineId, + ] + ); + return ( + + {ColumnHeaders} + {TrailingActions} + + ); + } +); + +DataDrivenColumns.displayName = 'DataDrivenColumns'; + +export const getMappedNonEcsValue = ({ + data, + fieldName, +}: { + data: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + const item = data.find((d) => d.field === fieldName); + if (item != null && item.value != null) { + return item.value; + } + return undefined; +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.test.tsx new file mode 100644 index 00000000000000..752e3018fc4049 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.test.tsx @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { cloneDeep } from 'lodash/fp'; +import React, { useEffect } from 'react'; + +import { StatefulCell } from './stateful_cell'; +import { getMappedNonEcsValue } from '.'; +import { defaultHeaders } from '../../../../mock/header'; +import { + CellValueElementProps, + ColumnHeaderOptions, + TimelineTabs, +} from '../../../../../common/types/timeline'; +import { TimelineNonEcsData } from '../../../../../common/search_strategy'; +import { mockTimelineData } from '../../../../mock/mock_timeline_data'; + +/** + * This (test) component implement's `EuiDataGrid`'s `renderCellValue` interface, + * as documented here: https://elastic.github.io/eui/#/tabular-content/data-grid + * + * Its `CellValueElementProps` props are a superset of `EuiDataGridCellValueElementProps`. + * The `setCellProps` function, defined by the `EuiDataGridCellValueElementProps` interface, + * is typically called in a `useEffect`, as illustrated by `EuiDataGrid`'s code sandbox example: + * https://codesandbox.io/s/zhxmo + */ +const RenderCellValue: React.FC = ({ columnId, data, setCellProps }) => { + useEffect(() => { + // branching logic that conditionally renders a specific cell green: + if (columnId === defaultHeaders[0].id) { + const value = getMappedNonEcsValue({ + data, + fieldName: columnId, + }); + + if (value?.length) { + setCellProps({ + style: { + backgroundColor: 'green', + }, + }); + } + } + }, [columnId, data, setCellProps]); + + return ( +
+ {getMappedNonEcsValue({ + data, + fieldName: columnId, + })} +
+ ); +}; + +describe('StatefulCell', () => { + const ariaRowindex = 123; + const eventId = '_id-123'; + const linkValues = ['foo', 'bar', '@baz']; + const tabType = TimelineTabs.query; + const timelineId = 'test'; + + let header: ColumnHeaderOptions; + let data: TimelineNonEcsData[]; + beforeEach(() => { + data = cloneDeep(mockTimelineData[0].data); + header = cloneDeep(defaultHeaders[0]); + }); + + test('it invokes renderCellValue with the expected arguments when tabType is specified', () => { + const renderCellValue = jest.fn(); + + mount( + + ); + + expect(renderCellValue).toBeCalledWith( + expect.objectContaining({ + columnId: header.id, + eventId, + data, + header, + isExpandable: true, + isExpanded: false, + isDetails: false, + linkValues, + rowIndex: ariaRowindex - 1, + timelineId: `${timelineId}-${tabType}`, + }) + ); + }); + + test('it invokes renderCellValue with the expected arguments when tabType is NOT specified', () => { + const renderCellValue = jest.fn(); + + mount( + + ); + + expect(renderCellValue).toBeCalledWith( + expect.objectContaining({ + columnId: header.id, + eventId, + data, + header, + isExpandable: true, + isExpanded: false, + isDetails: false, + linkValues, + rowIndex: ariaRowindex - 1, + timelineId, + }) + ); + }); + + test('it renders the React.Node returned by renderCellValue', () => { + const renderCellValue = () =>
; + + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="renderCellValue"]').exists()).toBe(true); + }); + + test("it renders a div with the styles set by `renderCellValue`'s `setCellProps` argument", () => { + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="statefulCell"]').getDOMNode().getAttribute('style') + ).toEqual('background-color: green;'); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.tsx new file mode 100644 index 00000000000000..82d872d30c273d --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { HTMLAttributes, useState } from 'react'; +import type { TimelineNonEcsData } from '../../../../../common/search_strategy'; + +import { TimelineTabs } from '../../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { + CellValueElementProps, + ColumnHeaderOptions, +} from '../../../../../common/types/timeline'; + +export interface CommonProps { + className?: string; + 'aria-label'?: string; + 'data-test-subj'?: string; +} + +const StatefulCellComponent = ({ + ariaRowindex, + data, + header, + eventId, + linkValues, + renderCellValue, + tabType, + timelineId, +}: { + ariaRowindex: number; + data: TimelineNonEcsData[]; + header: ColumnHeaderOptions; + eventId: string; + linkValues: string[] | undefined; + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + tabType?: TimelineTabs; + timelineId: string; +}) => { + const [cellProps, setCellProps] = useState>({}); + return ( +
+ {renderCellValue({ + columnId: header.id, + eventId, + data, + header, + isExpandable: true, + isExpanded: false, + isDetails: false, + linkValues, + rowIndex: ariaRowindex - 1, + setCellProps, + timelineId: tabType != null ? `${timelineId}-${tabType}` : timelineId, + })} +
+ ); +}; + +StatefulCellComponent.displayName = 'StatefulCellComponent'; + +export const StatefulCell = React.memo(StatefulCellComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/translations.ts b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/translations.ts new file mode 100644 index 00000000000000..1e5b10bb7cbc20 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const YOU_ARE_IN_A_TABLE_CELL = ({ column, row }: { column: number; row: number }) => + i18n.translate('xpack.timelines.timeline.youAreInATableCellScreenReaderOnly', { + values: { column, row }, + defaultMessage: 'You are in a table cell. row: {row}, column: {column}', + }); + +export const EVENT_HAS_AN_EVENT_RENDERER = (row: number) => + i18n.translate('xpack.timelines.timeline.eventHasEventRendererScreenReaderOnly', { + values: { row }, + defaultMessage: + 'The event in row {row} has an event renderer. Press shift + down arrow to focus it.', + }); + +export const EVENT_HAS_NOTES = ({ notesCount, row }: { notesCount: number; row: number }) => + i18n.translate('xpack.timelines.timeline.eventHasNotesScreenReaderOnly', { + values: { notesCount, row }, + defaultMessage: + 'The event in row {row} has {notesCount, plural, =1 {a note} other {{notesCount} notes}}. Press shift + right arrow to focus notes.', + }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.test.tsx new file mode 100644 index 00000000000000..23a66c9e18f7dc --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import React from 'react'; + +import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants'; + +import { EventColumnView } from './event_column_view'; +import { TestCellRenderer } from '../../../../mock/cell_renderer'; +import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +import { TestProviders } from '../../../../mock/test_providers'; +import { testLeadingControlColumn } from '../../../../mock/mock_timeline_control_columns'; +import { mockGlobalState } from '../../../../mock/global_state'; + +jest.mock('../../../../hooks/use_selector', () => ({ + useShallowEqualSelector: () => mockGlobalState.timelineById.test, + useDeepEqualSelector: () => mockGlobalState.timelineById.test, +})); + +describe('EventColumnView', () => { + const props = { + ariaRowindex: 2, + id: 'event-id', + actionsColumnWidth: DEFAULT_ACTIONS_COLUMN_WIDTH, + associateNote: jest.fn(), + columnHeaders: [], + columnRenderers: [], + data: [ + { + field: 'host.name', + }, + ], + ecsData: { + _id: 'id', + }, + eventIdToNoteIds: {}, + expanded: false, + hasRowRenderers: false, + loading: false, + loadingEventIds: [], + notesCount: 0, + onEventDetailsPanelOpened: jest.fn(), + onPinEvent: jest.fn(), + onRowSelected: jest.fn(), + onUnPinEvent: jest.fn(), + refetch: jest.fn(), + renderCellValue: TestCellRenderer, + selectedEventIds: {}, + showCheckboxes: false, + showNotes: false, + tabType: TimelineTabs.query, + timelineId: TimelineId.active, + toggleShowNotes: jest.fn(), + updateNote: jest.fn(), + isEventPinned: false, + leadingControlColumns: [], + trailingControlColumns: [], + }; + + // TODO: next 3 tests will be re-enabled in the future. + test.skip('it render AddToCaseAction if timelineId === TimelineId.detectionsPage', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeTruthy(); + }); + + test.skip('it render AddToCaseAction if timelineId === TimelineId.detectionsRulesDetailsPage', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeTruthy(); + }); + + test.skip('it render AddToCaseAction if timelineId === TimelineId.active', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeTruthy(); + }); + + test.skip('it does NOT render AddToCaseAction when timelineId is not in the allowed list', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeFalsy(); + }); + + test('it renders a custom control column in addition to the default control column', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + expect(wrapper.find('[data-test-subj="test-body-control-column-cell"]').exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx new file mode 100644 index 00000000000000..dca3b84eb84b7f --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; + +import type { OnRowSelected } from '../../types'; +import { EventsTrData, EventsTdGroupActions } from '../../styles'; +import { DataDrivenColumns, getMappedNonEcsValue } from '../data_driven_columns'; +import { TimelineTabs } from '../../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { + CellValueElementProps, + ColumnHeaderOptions, + ControlColumnProps, + RowCellRender, +} from '../../../../../common/types/timeline'; +import type { TimelineNonEcsData } from '../../../../../common/search_strategy'; +import type { Ecs } from '../../../../../common/ecs'; + +interface Props { + id: string; + actionsColumnWidth: number; + ariaRowindex: number; + columnHeaders: ColumnHeaderOptions[]; + data: TimelineNonEcsData[]; + ecsData: Ecs; + isEventViewer?: boolean; + loadingEventIds: Readonly; + onEventDetailsPanelOpened: () => void; + onRowSelected: OnRowSelected; + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + onRuleChange?: () => void; + hasRowRenderers: boolean; + selectedEventIds: Readonly>; + showCheckboxes: boolean; + tabType?: TimelineTabs; + timelineId: string; + leadingControlColumns: ControlColumnProps[]; + trailingControlColumns: ControlColumnProps[]; +} + +export const EventColumnView = React.memo( + ({ + id, + actionsColumnWidth, + ariaRowindex, + columnHeaders, + data, + ecsData, + isEventViewer = false, + loadingEventIds, + onEventDetailsPanelOpened, + onRowSelected, + hasRowRenderers, + onRuleChange, + renderCellValue, + selectedEventIds, + showCheckboxes, + tabType, + timelineId, + leadingControlColumns, + trailingControlColumns, + }) => { + // Each action button shall announce itself to screen readers via an `aria-label` + // in the following format: + // "button description, for the event in row {ariaRowindex}, with columns {columnValues}", + // so we combine the column values here: + const columnValues = useMemo( + () => + columnHeaders + .map( + (header) => + getMappedNonEcsValue({ + data, + fieldName: header.id, + }) ?? [] + ) + .join(' '), + [columnHeaders, data] + ); + + const leadingActionCells = useMemo( + () => + leadingControlColumns ? leadingControlColumns.map((column) => column.rowCellRender) : [], + [leadingControlColumns] + ); + const LeadingActions = useMemo( + () => + leadingActionCells.map((Action: RowCellRender | undefined, index) => { + const width = leadingControlColumns[index].width + ? leadingControlColumns[index].width + : actionsColumnWidth; + return ( + + {Action && ( + + )} + + ); + }), + [ + actionsColumnWidth, + ariaRowindex, + columnValues, + data, + ecsData, + id, + isEventViewer, + leadingActionCells, + leadingControlColumns, + loadingEventIds, + onEventDetailsPanelOpened, + onRowSelected, + onRuleChange, + selectedEventIds, + showCheckboxes, + tabType, + timelineId, + ] + ); + return ( + + {LeadingActions} + + + ); + } +); + +EventColumnView.displayName = 'EventColumnView'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/index.tsx new file mode 100644 index 00000000000000..8036fdd8f858fd --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/index.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { isEmpty } from 'lodash'; + +import { EventsTbody } from '../../styles'; +import { StatefulEvent } from './stateful_event'; +import type { BrowserFields } from '../../../../../common/search_strategy/index_fields'; +import { TimelineTabs } from '../../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { + CellValueElementProps, + ColumnHeaderOptions, + ControlColumnProps, + OnRowSelected, + RowRenderer, +} from '../../../../../common/types/timeline'; + +import { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; + +/** This offset begins at two, because the header row counts as "row 1", and aria-rowindex starts at "1" */ +const ARIA_ROW_INDEX_OFFSET = 2; + +interface Props { + actionsColumnWidth: number; + browserFields: BrowserFields; + columnHeaders: ColumnHeaderOptions[]; + containerRef: React.MutableRefObject; + data: TimelineItem[]; + id: string; + isEventViewer?: boolean; + lastFocusedAriaColindex: number; + loadingEventIds: Readonly; + onRowSelected: OnRowSelected; + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + onRuleChange?: () => void; + rowRenderers: RowRenderer[]; + selectedEventIds: Readonly>; + showCheckboxes: boolean; + tabType?: TimelineTabs; + leadingControlColumns: ControlColumnProps[]; + trailingControlColumns: ControlColumnProps[]; +} + +const EventsComponent: React.FC = ({ + actionsColumnWidth, + browserFields, + columnHeaders, + containerRef, + data, + id, + isEventViewer = false, + lastFocusedAriaColindex, + loadingEventIds, + onRowSelected, + onRuleChange, + renderCellValue, + rowRenderers, + selectedEventIds, + showCheckboxes, + tabType, + leadingControlColumns, + trailingControlColumns, +}) => ( + + {data.map((event, i) => ( + + ))} + +); + +export const Events = React.memo(EventsComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event.tsx new file mode 100644 index 00000000000000..4eaa22ce5e2a97 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event.tsx @@ -0,0 +1,207 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers'; +import { EventsTrGroup, EventsTrSupplement } from '../../styles'; +import type { OnRowSelected } from '../../types'; +import { isEventBuildingBlockType, getEventType, isEvenEqlSequence } from '../helpers'; +import { EventColumnView } from './event_column_view'; +import { getRowRenderer } from '../renderers/get_row_renderer'; +import { StatefulRowRenderer } from './stateful_row_renderer'; +import { getMappedNonEcsValue } from '../data_driven_columns'; +import { StatefulEventContext } from './stateful_event_context'; +import type { BrowserFields } from '../../../../../common/search_strategy/index_fields'; +import { TimelineTabs } from '../../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { + CellValueElementProps, + ColumnHeaderOptions, + ControlColumnProps, + RowRenderer, + TimelineExpandedDetailType, +} from '../../../../../common/types/timeline'; + +import type { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; +import { tGridActions, tGridSelectors } from '../../../../store/t_grid'; +import { useDeepEqualSelector } from '../../../../hooks/use_selector'; + +interface Props { + actionsColumnWidth: number; + containerRef: React.MutableRefObject; + browserFields: BrowserFields; + columnHeaders: ColumnHeaderOptions[]; + event: TimelineItem; + isEventViewer?: boolean; + lastFocusedAriaColindex: number; + loadingEventIds: Readonly; + onRowSelected: OnRowSelected; + ariaRowindex: number; + onRuleChange?: () => void; + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + rowRenderers: RowRenderer[]; + selectedEventIds: Readonly>; + showCheckboxes: boolean; + tabType?: TimelineTabs; + timelineId: string; + leadingControlColumns: ControlColumnProps[]; + trailingControlColumns: ControlColumnProps[]; +} + +const StatefulEventComponent: React.FC = ({ + actionsColumnWidth, + browserFields, + containerRef, + columnHeaders, + event, + isEventViewer = false, + lastFocusedAriaColindex, + loadingEventIds, + onRowSelected, + renderCellValue, + rowRenderers, + onRuleChange, + ariaRowindex, + selectedEventIds, + showCheckboxes, + tabType, + timelineId, + leadingControlColumns, + trailingControlColumns, +}) => { + const trGroupRef = useRef(null); + const dispatch = useDispatch(); + // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created + const [activeStatefulEventContext] = useState({ timelineID: timelineId, tabType }); + const getTGrid = useMemo(() => tGridSelectors.getTGridByIdSelector(), []); + const expandedDetail = useDeepEqualSelector( + (state) => getTGrid(state, timelineId).expandedDetail ?? {} + ); + const hostName = useMemo(() => { + const hostNameArr = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.name' }); + return hostNameArr && hostNameArr.length > 0 ? hostNameArr[0] : null; + }, [event?.data]); + + const hostIPAddresses = useMemo(() => { + const hostIpList = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.ip' }) ?? []; + const sourceIpList = getMappedNonEcsValue({ data: event?.data, fieldName: 'source.ip' }) ?? []; + const destinationIpList = + getMappedNonEcsValue({ + data: event?.data, + fieldName: 'destination.ip', + }) ?? []; + return new Set([...hostIpList, ...sourceIpList, ...destinationIpList]); + }, [event?.data]); + + const activeTab = tabType ?? TimelineTabs.query; + const activeExpandedDetail = expandedDetail[activeTab]; + + const isDetailPanelExpanded: boolean = + (activeExpandedDetail?.panelView === 'eventDetail' && + activeExpandedDetail?.params?.eventId === event._id) || + (activeExpandedDetail?.panelView === 'hostDetail' && + activeExpandedDetail?.params?.hostName === hostName) || + (activeExpandedDetail?.panelView === 'networkDetail' && + activeExpandedDetail?.params?.ip && + hostIPAddresses?.has(activeExpandedDetail?.params?.ip)) || + false; + + const hasRowRenderers: boolean = useMemo(() => getRowRenderer(event.ecs, rowRenderers) != null, [ + event.ecs, + rowRenderers, + ]); + + const handleOnEventDetailPanelOpened = useCallback(() => { + const eventId = event._id; + const indexName = event._index!; + + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'eventDetail', + params: { + eventId, + indexName, + }, + }; + + dispatch( + tGridActions.toggleDetailPanel({ + ...updatedExpandedDetail, + tabType, + timelineId, + }) + ); + }, [dispatch, event._id, event._index, tabType, timelineId]); + + const RowRendererContent = useMemo( + () => ( + + + + ), + [ + ariaRowindex, + browserFields, + containerRef, + event, + lastFocusedAriaColindex, + rowRenderers, + timelineId, + ] + ); + + return ( + + + + +
{RowRendererContent}
+
+
+ ); +}; + +export const StatefulEvent = React.memo(StatefulEventComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event_context.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event_context.tsx new file mode 100644 index 00000000000000..a2ad0b55f5cbc7 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_event_context.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { TimelineTabs } from '../../../../../common/types/timeline'; + +interface StatefulEventContext { + tabType: TimelineTabs | undefined; + timelineID: string; +} + +// This context is available to all children of the stateful_event component where the provider is currently set +export const StatefulEventContext = React.createContext(null); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_row_renderer/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_row_renderer/index.tsx new file mode 100644 index 00000000000000..65762b93cd43f9 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/stateful_row_renderer/index.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { noop } from 'lodash/fp'; +import { EuiFocusTrap, EuiOutsideClickDetector, EuiScreenReaderOnly } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { + ARIA_COLINDEX_ATTRIBUTE, + ARIA_ROWINDEX_ATTRIBUTE, + getRowRendererClassName, +} from '../../../../../../common'; +import { useStatefulEventFocus } from '../use_stateful_event_focus'; + +import * as i18n from '../translations'; +import type { BrowserFields } from '../../../../../../common/search_strategy/index_fields'; +import type { TimelineItem } from '../../../../../../common/search_strategy'; +import type { RowRenderer } from '../../../../../../common/types/timeline'; +import { getRowRenderer } from '../../renderers/get_row_renderer'; + +/** + * This component addresses the accessibility of row renderers. + * + * accessibility details: + * - This component has a 'dialog' `role` because it's rendered as a dialog + * "outside" the current row for screen readers, similar to a popover + * - It has tabIndex="0" to allow for keyboard focus + * - It traps keyboard focus when a user clicks inside a row renderer, to + * allow for tabbing through the contents of row renderers + * - The "dialog" can be dismissed via the up arrow key, down arrow key, + * which focuses the current or next row, respectively. + * - A screen-reader-only message provides additional context and instruction + */ +export const StatefulRowRenderer = ({ + ariaRowindex, + browserFields, + containerRef, + event, + lastFocusedAriaColindex, + rowRenderers, + timelineId, +}: { + ariaRowindex: number; + browserFields: BrowserFields; + containerRef: React.MutableRefObject; + event: TimelineItem; + lastFocusedAriaColindex: number; + rowRenderers: RowRenderer[]; + timelineId: string; +}) => { + const { focusOwnership, onFocus, onKeyDown, onOutsideClick } = useStatefulEventFocus({ + ariaRowindex, + colindexAttribute: ARIA_COLINDEX_ATTRIBUTE, + containerRef, + lastFocusedAriaColindex, + onColumnFocused: noop, + rowindexAttribute: ARIA_ROWINDEX_ATTRIBUTE, + }); + + const rowRenderer = useMemo(() => getRowRenderer(event.ecs, rowRenderers), [ + event.ecs, + rowRenderers, + ]); + + const content = useMemo( + () => + rowRenderer && ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
+ + + +

{i18n.YOU_ARE_IN_AN_EVENT_RENDERER(ariaRowindex)}

+
+
+ {rowRenderer.renderRow({ + browserFields, + data: event.ecs, + timelineId, + })} +
+
+
+
+ ), + [ + ariaRowindex, + browserFields, + event.ecs, + focusOwnership, + onFocus, + onKeyDown, + onOutsideClick, + rowRenderer, + timelineId, + ] + ); + + return content; +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/translations.ts b/x-pack/plugins/timelines/public/components/t_grid/body/events/translations.ts new file mode 100644 index 00000000000000..9d1071a80071b7 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const YOU_ARE_IN_AN_EVENT_RENDERER = (row: number) => + i18n.translate('xpack.timelines.timeline.youAreInAnEventRendererScreenReaderOnly', { + values: { row }, + defaultMessage: + 'You are in an event renderer for row: {row}. Press the up arrow key to exit and return to the current row, or the down arrow key to exit and advance to the next row.', + }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/use_stateful_event_focus/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/use_stateful_event_focus/index.tsx new file mode 100644 index 00000000000000..27d6ba846eb989 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/use_stateful_event_focus/index.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState, useMemo } from 'react'; +import { focusColumn, isArrowDownOrArrowUp, isArrowUp, isEscape } from '../../../../../../common'; +// eslint-disable-next-line no-duplicate-imports +import type { OnColumnFocused } from '../../../../../../common'; + +type FocusOwnership = 'not-owned' | 'owned'; + +export const getSameOrNextAriaRowindex = ({ + ariaRowindex, + event, +}: { + ariaRowindex: number; + event: React.KeyboardEvent; +}): number => (isArrowUp(event) ? ariaRowindex : ariaRowindex + 1); + +export const useStatefulEventFocus = ({ + ariaRowindex, + colindexAttribute, + containerRef, + lastFocusedAriaColindex, + onColumnFocused, + rowindexAttribute, +}: { + ariaRowindex: number; + colindexAttribute: string; + containerRef: React.MutableRefObject; + lastFocusedAriaColindex: number; + onColumnFocused: OnColumnFocused; + rowindexAttribute: string; +}) => { + const [focusOwnership, setFocusOwnership] = useState('not-owned'); + + const onFocus = useCallback(() => { + setFocusOwnership((prevFocusOwnership) => { + if (prevFocusOwnership !== 'owned') { + return 'owned'; + } + return prevFocusOwnership; + }); + }, []); + + const onOutsideClick = useCallback(() => { + setFocusOwnership('not-owned'); + }, []); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isArrowDownOrArrowUp(e) || isEscape(e)) { + e.preventDefault(); + e.stopPropagation(); + + setFocusOwnership('not-owned'); + + const newAriaRowindex = isEscape(e) + ? ariaRowindex // return focus to the same row + : getSameOrNextAriaRowindex({ ariaRowindex, event: e }); + + setTimeout(() => { + onColumnFocused( + focusColumn({ + ariaColindex: lastFocusedAriaColindex, + ariaRowindex: newAriaRowindex, + colindexAttribute, + containerElement: containerRef.current, + rowindexAttribute, + }) + ); + }, 0); + } + }, + [ + ariaRowindex, + colindexAttribute, + containerRef, + lastFocusedAriaColindex, + onColumnFocused, + rowindexAttribute, + ] + ); + + const memoizedReturn = useMemo(() => ({ focusOwnership, onFocus, onOutsideClick, onKeyDown }), [ + focusOwnership, + onFocus, + onKeyDown, + onOutsideClick, + ]); + + return memoizedReturn; +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.ts b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.ts new file mode 100644 index 00000000000000..ffdf91425c4f74 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Ecs } from '../../../../common/ecs'; +import { stringifyEvent } from './helpers'; + +describe('helpers', () => { + describe('stringifyEvent', () => { + test('it omits __typename when it appears at arbitrary levels', () => { + const toStringify: Ecs = { + __typename: 'level 0', + _id: '4', + timestamp: '2018-11-08T19:03:25.937Z', + host: { + __typename: 'level 1', + name: ['suricata'], + ip: ['192.168.0.1'], + }, + event: { + id: ['4'], + category: ['Attempted Administrator Privilege Gain'], + type: ['Alert'], + module: ['suricata'], + severity: [1], + }, + source: { + ip: ['192.168.0.3'], + port: [53], + }, + destination: { + ip: ['192.168.0.3'], + port: [6343], + }, + suricata: { + eve: { + flow_id: [4], + proto: [''], + alert: { + signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'], + signature_id: [4], + __typename: 'level 2', + }, + }, + }, + user: { + id: ['4'], + name: ['jack.black'], + }, + geo: { + region_name: ['neither'], + country_iso_code: ['sasquatch'], + }, + } as Ecs; // as cast so that `__typename` can be added for the tests even though it is not part of ECS + const expected: Ecs = { + _id: '4', + timestamp: '2018-11-08T19:03:25.937Z', + host: { + name: ['suricata'], + ip: ['192.168.0.1'], + }, + event: { + id: ['4'], + category: ['Attempted Administrator Privilege Gain'], + type: ['Alert'], + module: ['suricata'], + severity: [1], + }, + source: { + ip: ['192.168.0.3'], + port: [53], + }, + destination: { + ip: ['192.168.0.3'], + port: [6343], + }, + suricata: { + eve: { + flow_id: [4], + proto: [''], + alert: { + signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'], + signature_id: [4], + }, + }, + }, + user: { + id: ['4'], + name: ['jack.black'], + }, + geo: { + region_name: ['neither'], + country_iso_code: ['sasquatch'], + }, + }; + expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected); + }); + + test('it omits null and undefined values at arbitrary levels, for arbitrary data types', () => { + const expected: Ecs = { + _id: '4', + host: {}, + event: { + id: ['4'], + category: ['theory'], + type: ['Alert'], + module: ['me'], + severity: [1], + }, + source: { + port: [53], + }, + destination: { + ip: ['192.168.0.3'], + port: [6343], + }, + suricata: { + eve: { + flow_id: [4], + proto: [''], + alert: { + signature: ['dance moves'], + }, + }, + }, + user: { + id: ['4'], + name: ['no use for a'], + }, + geo: { + region_name: ['bizzaro'], + country_iso_code: ['world'], + }, + }; + const toStringify: Ecs = { + _id: '4', + host: {}, + event: { + id: ['4'], + category: ['theory'], + type: ['Alert'], + module: ['me'], + severity: [1], + }, + source: { + ip: undefined, + port: [53], + }, + destination: { + ip: ['192.168.0.3'], + port: [6343], + }, + suricata: { + eve: { + flow_id: [4], + proto: [''], + alert: { + signature: ['dance moves'], + signature_id: undefined, + }, + }, + }, + user: { + id: ['4'], + name: ['no use for a'], + }, + geo: { + region_name: ['bizzaro'], + country_iso_code: ['world'], + }, + }; + expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx new file mode 100644 index 00000000000000..85edefc0c0fa67 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash/fp'; + +import type { Ecs } from '../../../../common/ecs'; +import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy'; +import type { TimelineEventsType } from '../../../../common/types/timeline'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const omitTypenameAndEmpty = (k: string, v: any): any | undefined => + k !== '__typename' && v != null ? v : undefined; + +export const stringifyEvent = (ecs: Ecs): string => JSON.stringify(ecs, omitTypenameAndEmpty, 2); + +/** + * Creates mapping of eventID -> fieldData for given fieldsToKeep. Used to store additional field + * data necessary for custom timeline actions in conjunction with selection state + * @param timelineData + * @param eventIds + * @param fieldsToKeep + */ +export const getEventIdToDataMapping = ( + timelineData: TimelineItem[], + eventIds: string[], + fieldsToKeep: string[] +): Record => + timelineData.reduce((acc, v) => { + const fvm = eventIds.includes(v._id) + ? { [v._id]: v.data.filter((ti) => fieldsToKeep.includes(ti.field)) } + : {}; + return { + ...acc, + ...fvm, + }; + }, {}); + +export const isEventBuildingBlockType = (event: Ecs): boolean => + !isEmpty(event.signal?.rule?.building_block_type); + +export const isEvenEqlSequence = (event: Ecs): boolean => { + if (!isEmpty(event.eql?.sequenceNumber)) { + try { + const sequenceNumber = (event.eql?.sequenceNumber ?? '').split('-')[0]; + return parseInt(sequenceNumber, 10) % 2 === 0; + } catch { + return false; + } + } + return false; +}; +/** Return eventType raw or signal or eql */ +export const getEventType = (event: Ecs): Omit => { + if (!isEmpty(event.signal?.rule?.id)) { + return 'signal'; + } else if (!isEmpty(event.eql?.parentId)) { + return 'eql'; + } + return 'raw'; +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx new file mode 100644 index 00000000000000..b8533f33a82e9b --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { BodyComponent, StatefulBodyProps } from '.'; +import { Sort } from './sort'; +import { Direction } from '../../../../common/search_strategy'; +import { useMountAppended } from '../../utils/use_mount_appended'; +import { defaultHeaders, mockBrowserFields, mockTimelineData, TestProviders } from '../../../mock'; +import { TimelineTabs } from '../../../../common/types/timeline'; +import { TestCellRenderer } from '../../../mock/cell_renderer'; +import { mockGlobalState } from '../../../mock/global_state'; + +const mockSort: Sort[] = [ + { + columnId: '@timestamp', + columnType: 'number', + sortDirection: Direction.desc, + }, +]; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +jest.mock('../../../hooks/use_selector', () => ({ + useShallowEqualSelector: () => mockGlobalState.timelineById.test, + useDeepEqualSelector: () => mockGlobalState.timelineById.test, +})); + +jest.mock( + 'react-visibility-sensor', + () => ({ children }: { children: (args: { isVisible: boolean }) => React.ReactNode }) => + children({ isVisible: true }) +); + +window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + }; +}); + +describe('Body', () => { + const mount = useMountAppended(); + const props: StatefulBodyProps = { + activePage: 0, + browserFields: mockBrowserFields, + clearSelected: (jest.fn() as unknown) as StatefulBodyProps['clearSelected'], + columnHeaders: defaultHeaders, + data: mockTimelineData, + excludedRowRendererIds: [], + id: 'timeline-test', + isSelectAllChecked: false, + loadingEventIds: [], + renderCellValue: TestCellRenderer, + rowRenderers: [], + selectedEventIds: {}, + setSelected: (jest.fn() as unknown) as StatefulBodyProps['setSelected'], + sort: mockSort, + showCheckboxes: false, + tabType: TimelineTabs.query, + totalPages: 1, + leadingControlColumns: [], + trailingControlColumns: [], + }; + + describe('rendering', () => { + test('it renders the column headers', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="column-headers"]').first().exists()).toEqual(true); + }); + + test('it renders the scroll container', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timeline-body"]').first().exists()).toEqual(true); + }); + + test('it renders events', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="events"]').first().exists()).toEqual(true); + }); + + test('it renders a tooltip for timestamp', () => { + const headersJustTimestamp = defaultHeaders.filter((h) => h.id === '@timestamp'); + const testProps = { ...props, columnHeaders: headersJustTimestamp }; + const wrapper = mount( + + + + ); + wrapper.update(); + expect( + wrapper + .find('[data-test-subj="data-driven-columns"]') + .first() + .find('[data-test-subj="statefulCell"]') + .last() + .text() + ).toEqual(mockTimelineData[0].ecs.timestamp); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx new file mode 100644 index 00000000000000..51227c0e811f26 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -0,0 +1,334 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { noop } from 'lodash/fp'; +import memoizeOne from 'memoize-one'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; + +import { + ARIA_COLINDEX_ATTRIBUTE, + ARIA_ROWINDEX_ATTRIBUTE, + FIRST_ARIA_INDEX, + onKeyDownFocusHandler, +} from '../../../../common'; +import { DEFAULT_COLUMN_MIN_WIDTH } from './constants'; +import { RowRendererId, TimelineId, TimelineTabs } from '../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { + CellValueElementProps, + ColumnHeaderOptions, + ControlColumnProps, + RowRenderer, +} from '../../../../common/types/timeline'; +import type { TimelineItem } from '../../../../common/search_strategy/timeline'; + +import { getActionsColumnWidth, getColumnHeaders } from './column_headers/helpers'; +import { getEventIdToDataMapping } from './helpers'; +import { Sort } from './sort'; + +import { EventsTable, TimelineBody, TimelineBodyGlobalStyle } from '../styles'; +import { ColumnHeaders } from './column_headers'; +import { Events } from './events'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; +import { BrowserFields } from '../../../../common/search_strategy/index_fields'; +import { OnRowSelected, OnSelectAll } from '../types'; +import { tGridActions } from '../../../'; +import { TGridModel, tGridSelectors, TimelineState } from '../../../store/t_grid'; +import { useDeepEqualSelector } from '../../../hooks/use_selector'; +import { plainRowRenderer } from './renderers/plain_row_renderer'; + +interface OwnProps { + activePage: number; + browserFields: BrowserFields; + data: TimelineItem[]; + id: string; + isEventViewer?: boolean; + sort: Sort[]; + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + rowRenderers: RowRenderer[]; + leadingControlColumns: ControlColumnProps[]; + trailingControlColumns: ControlColumnProps[]; + tabType: TimelineTabs; + totalPages: number; + onRuleChange?: () => void; +} + +const NUM_OF_ICON_IN_TIMELINE_ROW = 2; + +export const hasAdditionalActions = (id: TimelineId): boolean => + [TimelineId.detectionsPage, TimelineId.detectionsRulesDetailsPage, TimelineId.active].includes( + id + ); + +const EXTRA_WIDTH = 4; // px + +export type StatefulBodyProps = OwnProps & PropsFromRedux; + +/** + * The Body component is used everywhere timeline is used within the security application. It is the highest level component + * that is shared across all implementations of the timeline. + */ +export const BodyComponent = React.memo( + ({ + activePage, + browserFields, + columnHeaders, + data, + excludedRowRendererIds, + id, + isEventViewer = false, + isSelectAllChecked, + loadingEventIds, + selectedEventIds, + setSelected, + clearSelected, + onRuleChange, + showCheckboxes, + renderCellValue, + rowRenderers, + sort, + tabType, + totalPages, + leadingControlColumns = [], + trailingControlColumns = [], + }) => { + const containerRef = useRef(null); + const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); + const { queryFields, selectAll } = useDeepEqualSelector((state) => + getManageTimeline(state, id) + ); + + const onRowSelected: OnRowSelected = useCallback( + ({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => { + setSelected!({ + id, + eventIds: getEventIdToDataMapping(data, eventIds, queryFields), + isSelected, + isSelectAllChecked: + isSelected && Object.keys(selectedEventIds).length + 1 === data.length, + }); + }, + [setSelected, id, data, selectedEventIds, queryFields] + ); + + const onSelectAll: OnSelectAll = useCallback( + ({ isSelected }: { isSelected: boolean }) => + isSelected + ? setSelected!({ + id, + eventIds: getEventIdToDataMapping( + data, + data.map((event) => event._id), + queryFields + ), + isSelected, + isSelectAllChecked: isSelected, + }) + : clearSelected!({ id }), + [setSelected, clearSelected, id, data, queryFields] + ); + + // Sync to selectAll so parent components can select all events + useEffect(() => { + if (selectAll && !isSelectAllChecked) { + onSelectAll({ isSelected: true }); + } + }, [isSelectAllChecked, onSelectAll, selectAll]); + + const enabledRowRenderers = useMemo(() => { + if ( + excludedRowRendererIds && + excludedRowRendererIds.length === Object.keys(RowRendererId).length + ) + return [plainRowRenderer]; + + if (!excludedRowRendererIds) return rowRenderers; + + return rowRenderers.filter((rowRenderer) => !excludedRowRendererIds.includes(rowRenderer.id)); + }, [excludedRowRendererIds, rowRenderers]); + + const actionsColumnWidth = useMemo( + () => + getActionsColumnWidth( + isEventViewer, + showCheckboxes, + hasAdditionalActions(id as TimelineId) + ? DEFAULT_ICON_BUTTON_WIDTH * NUM_OF_ICON_IN_TIMELINE_ROW + EXTRA_WIDTH + : 0 + ), + [isEventViewer, showCheckboxes, id] + ); + + const columnWidths = useMemo( + () => + columnHeaders.reduce( + (totalWidth, header) => totalWidth + (header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH), + 0 + ), + [columnHeaders] + ); + + const leadingActionColumnsWidth = useMemo(() => { + return leadingControlColumns + ? leadingControlColumns.reduce( + (totalWidth, header) => + header.width ? totalWidth + header.width : totalWidth + actionsColumnWidth, + 0 + ) + : 0; + }, [actionsColumnWidth, leadingControlColumns]); + + const trailingActionColumnsWidth = useMemo(() => { + return trailingControlColumns + ? trailingControlColumns.reduce( + (totalWidth, header) => + header.width ? totalWidth + header.width : totalWidth + actionsColumnWidth, + 0 + ) + : 0; + }, [actionsColumnWidth, trailingControlColumns]); + + const totalWidth = useMemo(() => { + return columnWidths + leadingActionColumnsWidth + trailingActionColumnsWidth; + }, [columnWidths, leadingActionColumnsWidth, trailingActionColumnsWidth]); + + const [lastFocusedAriaColindex] = useState(FIRST_ARIA_INDEX); + + const columnCount = useMemo(() => { + return columnHeaders.length + trailingControlColumns.length + leadingControlColumns.length; + }, [columnHeaders, trailingControlColumns, leadingControlColumns]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + onKeyDownFocusHandler({ + colindexAttribute: ARIA_COLINDEX_ATTRIBUTE, + containerElement: containerRef.current, + event: e, + maxAriaColindex: columnHeaders.length + 1, + maxAriaRowindex: data.length + 1, + onColumnFocused: noop, + rowindexAttribute: ARIA_ROWINDEX_ATTRIBUTE, + }); + }, + [columnHeaders.length, containerRef, data.length] + ); + return ( + <> + + + + + + + + + + ); + }, + (prevProps, nextProps) => + deepEqual(prevProps.browserFields, nextProps.browserFields) && + deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) && + deepEqual(prevProps.data, nextProps.data) && + deepEqual(prevProps.excludedRowRendererIds, nextProps.excludedRowRendererIds) && + deepEqual(prevProps.sort, nextProps.sort) && + deepEqual(prevProps.selectedEventIds, nextProps.selectedEventIds) && + deepEqual(prevProps.loadingEventIds, nextProps.loadingEventIds) && + prevProps.id === nextProps.id && + prevProps.isEventViewer === nextProps.isEventViewer && + prevProps.isSelectAllChecked === nextProps.isSelectAllChecked && + prevProps.renderCellValue === nextProps.renderCellValue && + prevProps.rowRenderers === nextProps.rowRenderers && + prevProps.showCheckboxes === nextProps.showCheckboxes && + prevProps.tabType === nextProps.tabType +); + +BodyComponent.displayName = 'BodyComponent'; + +const makeMapStateToProps = () => { + const memoizedColumnHeaders: ( + headers: ColumnHeaderOptions[], + browserFields: BrowserFields + ) => ColumnHeaderOptions[] = memoizeOne(getColumnHeaders); + + const getTGrid = tGridSelectors.getTGridByIdSelector(); + const mapStateToProps = (state: TimelineState, { browserFields, id }: OwnProps) => { + const timeline: TGridModel = getTGrid(state, id); + const { + columns, + excludedRowRendererIds, + isSelectAllChecked, + loadingEventIds, + selectedEventIds, + showCheckboxes, + } = timeline; + + return { + columnHeaders: memoizedColumnHeaders(columns, browserFields), + excludedRowRendererIds, + isSelectAllChecked, + loadingEventIds, + id, + selectedEventIds, + showCheckboxes, + }; + }; + return mapStateToProps; +}; + +const mapDispatchToProps = { + clearSelected: tGridActions.clearSelected, + setSelected: tGridActions.setSelected, +}; + +const connector = connect(makeMapStateToProps, mapDispatchToProps); + +type PropsFromRedux = ConnectedProps; + +export const StatefulBody = connector(BodyComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/renderers/__snapshots__/plain_row_renderer.test.tsx.snap b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/__snapshots__/plain_row_renderer.test.tsx.snap new file mode 100644 index 00000000000000..66a1b293cf8b92 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/__snapshots__/plain_row_renderer.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`plain_row_renderer renders correctly against snapshot 1`] = ``; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/renderers/get_column_renderer.ts b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/get_column_renderer.ts new file mode 100644 index 00000000000000..78f7119124e0a6 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/get_column_renderer.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TimelineNonEcsData } from '../../../../../common/search_strategy'; +import type { ColumnRenderer } from '../../../../../common/types/timeline'; + +const unhandledColumnRenderer = (): never => { + throw new Error('Unhandled Column Renderer'); +}; + +export const getColumnRenderer = ( + columnName: string, + columnRenderers: ColumnRenderer[], + data: TimelineNonEcsData[] +): ColumnRenderer => { + const renderer = columnRenderers.find((columnRenderer) => + columnRenderer.isInstance(columnName, data) + ); + return renderer != null ? renderer : unhandledColumnRenderer(); +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/renderers/get_row_renderer.ts b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/get_row_renderer.ts new file mode 100644 index 00000000000000..eba694c935e853 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/get_row_renderer.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Ecs } from '../../../../../common/ecs'; +import type { RowRenderer } from '../../../../../common/types/timeline'; + +export const getRowRenderer = (ecs: Ecs, rowRenderers: RowRenderer[]): RowRenderer | null => + rowRenderers.find((rowRenderer) => rowRenderer.isInstance(ecs)) ?? null; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/renderers/plain_row_renderer.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/plain_row_renderer.test.tsx new file mode 100644 index 00000000000000..5cd709d2de3c77 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/plain_row_renderer.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount, shallow } from 'enzyme'; +import { cloneDeep } from 'lodash'; +import React from 'react'; +import { Ecs } from '../../../../../common/ecs'; +import { mockBrowserFields, mockTimelineData } from '../../../../mock'; + +import { plainRowRenderer } from './plain_row_renderer'; + +describe('plain_row_renderer', () => { + let mockDatum: Ecs; + beforeEach(() => { + mockDatum = cloneDeep(mockTimelineData[0].ecs); + }); + + test('renders correctly against snapshot', () => { + const children = plainRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockDatum, + timelineId: 'test', + }); + const wrapper = shallow({children}); + expect(wrapper).toMatchSnapshot(); + }); + + test('should always return isInstance true', () => { + expect(plainRowRenderer.isInstance(mockDatum)).toBe(true); + }); + + test('should render a plain row', () => { + const children = plainRowRenderer.renderRow({ + browserFields: mockBrowserFields, + data: mockDatum, + timelineId: 'test', + }); + const wrapper = mount({children}); + expect(wrapper.text()).toEqual(''); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/renderers/plain_row_renderer.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/plain_row_renderer.tsx new file mode 100644 index 00000000000000..8462da3c02fb5f --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/plain_row_renderer.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { RowRendererId } from '../../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { RowRenderer } from '../../../../../common/types/timeline'; + +const PlainRowRenderer = () => <>; + +PlainRowRenderer.displayName = 'PlainRowRenderer'; + +export const plainRowRenderer: RowRenderer = { + id: RowRendererId.plain, + isInstance: (_) => true, + renderRow: PlainRowRenderer, +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/renderers/row_renderer.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/row_renderer.tsx new file mode 100644 index 00000000000000..64f1338b11a585 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/renderers/row_renderer.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EventsTrSupplement } from '../../styles'; + +interface RowRendererContainerProps { + children: React.ReactNode; +} + +export const RowRendererContainer = React.memo(({ children }) => ( + + {children} + +)); +RowRendererContainer.displayName = 'RowRendererContainer'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/sort/__snapshots__/sort_indicator.test.tsx.snap b/x-pack/plugins/timelines/public/components/t_grid/body/sort/__snapshots__/sort_indicator.test.tsx.snap new file mode 100644 index 00000000000000..596a05c4c8ab40 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/sort/__snapshots__/sort_indicator.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SortIndicator rendering renders correctly against snapshot 1`] = ` + + + + +`; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/sort/index.ts b/x-pack/plugins/timelines/public/components/t_grid/body/sort/index.ts new file mode 100644 index 00000000000000..e4e02cd1886003 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/sort/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SortDirection } from '../../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { SortColumnTimeline } from '../../../../../common/types/timeline'; + +// TODO: Cleanup this type to match SortColumnTimeline +export { SortDirection }; + +/** Specifies which column the timeline is sorted on */ +export type Sort = SortColumnTimeline; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/sort/sort_indicator.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/sort/sort_indicator.test.tsx new file mode 100644 index 00000000000000..3812f44d95ccd4 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/sort/sort_indicator.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { Direction } from '../../../../../common/search_strategy'; + +import * as i18n from '../translations'; + +import { getDirection, SortIndicator } from './sort_indicator'; + +describe('SortIndicator', () => { + describe('rendering', () => { + test('renders correctly against snapshot', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders the expected sort indicator when direction is ascending', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( + 'sortUp' + ); + }); + + test('it renders the expected sort indicator when direction is descending', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( + 'sortDown' + ); + }); + + test('it renders the expected sort indicator when direction is `none`', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( + 'empty' + ); + }); + }); + + describe('getDirection', () => { + test('it returns the expected symbol when the direction is ascending', () => { + expect(getDirection(Direction.asc)).toEqual('sortUp'); + }); + + test('it returns the expected symbol when the direction is descending', () => { + expect(getDirection(Direction.desc)).toEqual('sortDown'); + }); + + test('it returns the expected symbol (undefined) when the direction is neither ascending, nor descending', () => { + expect(getDirection('none')).toEqual(undefined); + }); + }); + + describe('sort indicator tooltip', () => { + test('it returns the expected tooltip when the direction is ascending', () => { + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content + ).toEqual(i18n.SORTED_ASCENDING); + }); + + test('it returns the expected tooltip when the direction is descending', () => { + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content + ).toEqual(i18n.SORTED_DESCENDING); + }); + + test('it does NOT render a tooltip when sort direction is `none`', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="sort-indicator-tooltip"]').exists()).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/sort/sort_indicator.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/sort/sort_indicator.tsx new file mode 100644 index 00000000000000..3c7d8a35b90210 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/sort/sort_indicator.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import React from 'react'; + +import * as i18n from '../translations'; +import { SortNumber } from './sort_number'; + +import type { SortDirection } from '.'; +import { Direction } from '../../../../../common/search_strategy'; + +enum SortDirectionIndicatorEnum { + SORT_UP = 'sortUp', + SORT_DOWN = 'sortDown', +} + +export type SortDirectionIndicator = undefined | SortDirectionIndicatorEnum; + +/** Returns the symbol that corresponds to the specified `SortDirection` */ +export const getDirection = (sortDirection: SortDirection): SortDirectionIndicator => { + switch (sortDirection) { + case Direction.asc: + return SortDirectionIndicatorEnum.SORT_UP; + case Direction.desc: + return SortDirectionIndicatorEnum.SORT_DOWN; + case 'none': + return undefined; + default: + throw new Error('Unhandled sort direction'); + } +}; + +interface Props { + sortDirection: SortDirection; + sortNumber: number; +} + +/** Renders a sort indicator */ +export const SortIndicator = React.memo(({ sortDirection, sortNumber }) => { + const direction = getDirection(sortDirection); + + if (direction != null) { + return ( + + <> + + + + + ); + } else { + return ; + } +}); + +SortIndicator.displayName = 'SortIndicator'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/sort/sort_number.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/sort/sort_number.tsx new file mode 100644 index 00000000000000..3fdd31eae5c47a --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/sort/sort_number.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIcon, EuiNotificationBadge } from '@elastic/eui'; +import React from 'react'; + +interface Props { + sortNumber: number; +} + +export const SortNumber = React.memo(({ sortNumber }) => { + if (sortNumber >= 0) { + return ( + + {sortNumber + 1} + + ); + } else { + return ; + } +}); + +SortNumber.displayName = 'SortNumber'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts b/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts new file mode 100644 index 00000000000000..1a00a4eaf6bc6f --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts @@ -0,0 +1,229 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NOTES_TOOLTIP = i18n.translate( + 'xpack.timelines.timeline.body.notes.addOrViewNotesForThisEventTooltip', + { + defaultMessage: 'Add notes for this event', + } +); + +export const NOTES_DISABLE_TOOLTIP = i18n.translate( + 'xpack.timelines.timeline.body.notes.disableEventTooltip', + { + defaultMessage: 'Notes may not be added here while editing a template timeline', + } +); + +export const COPY_TO_CLIPBOARD = i18n.translate( + 'xpack.timelines.timeline.body.copyToClipboardButtonLabel', + { + defaultMessage: 'Copy to Clipboard', + } +); + +export const INVESTIGATE = i18n.translate( + 'xpack.timelines.timeline.body.actions.investigateLabel', + { + defaultMessage: 'Investigate', + } +); + +export const UNPINNED = i18n.translate('xpack.timelines.timeline.body.pinning.unpinnedTooltip', { + defaultMessage: 'Unpinned event', +}); + +export const PINNED = i18n.translate('xpack.timelines.timeline.body.pinning.pinnedTooltip', { + defaultMessage: 'Pinned event', +}); + +export const PINNED_WITH_NOTES = i18n.translate( + 'xpack.timelines.timeline.body.pinning.pinnnedWithNotesTooltip', + { + defaultMessage: 'This event cannot be unpinned because it has notes', + } +); + +export const SORTED_ASCENDING = i18n.translate( + 'xpack.timelines.timeline.body.sort.sortedAscendingTooltip', + { + defaultMessage: 'Sorted ascending', + } +); + +export const SORTED_DESCENDING = i18n.translate( + 'xpack.timelines.timeline.body.sort.sortedDescendingTooltip', + { + defaultMessage: 'Sorted descending', + } +); + +export const DISABLE_PIN = i18n.translate( + 'xpack.timelines.timeline.body.pinning.disablePinnnedTooltip', + { + defaultMessage: 'This event may not be pinned while editing a template timeline', + } +); + +export const VIEW_DETAILS = i18n.translate( + 'xpack.timelines.timeline.body.actions.viewDetailsAriaLabel', + { + defaultMessage: 'View details', + } +); + +export const VIEW_SUMMARY = i18n.translate( + 'xpack.timelines.timeline.body.actions.viewSummaryLabel', + { + defaultMessage: 'View summary', + } +); + +export const VIEW_DETAILS_FOR_ROW = ({ + ariaRowindex, + columnValues, +}: { + ariaRowindex: number; + columnValues: string; +}) => + i18n.translate('xpack.timelines.timeline.body.actions.viewDetailsForRowAriaLabel', { + values: { ariaRowindex, columnValues }, + defaultMessage: + 'View details for the alert or event in row {ariaRowindex}, with columns {columnValues}', + }); + +export const EXPAND_EVENT = i18n.translate( + 'xpack.timelines.timeline.body.actions.expandEventTooltip', + { + defaultMessage: 'View details', + } +); + +export const COLLAPSE = i18n.translate('xpack.timelines.timeline.body.actions.collapseAriaLabel', { + defaultMessage: 'Collapse', +}); + +export const ACTION_INVESTIGATE_IN_RESOLVER = i18n.translate( + 'xpack.timelines.timeline.body.actions.investigateInResolverTooltip', + { + defaultMessage: 'Analyze event', + } +); + +export const CHECKBOX_FOR_ROW = ({ + ariaRowindex, + columnValues, + checked, +}: { + ariaRowindex: number; + columnValues: string; + checked: boolean; +}) => + i18n.translate('xpack.timelines.timeline.body.actions.checkboxForRowAriaLabel', { + values: { ariaRowindex, checked, columnValues }, + defaultMessage: + '{checked, select, false {unchecked} true {checked}} checkbox for the alert or event in row {ariaRowindex}, with columns {columnValues}', + }); + +export const ACTION_INVESTIGATE_IN_RESOLVER_FOR_ROW = ({ + ariaRowindex, + columnValues, +}: { + ariaRowindex: number; + columnValues: string; +}) => + i18n.translate('xpack.timelines.timeline.body.actions.investigateInResolverForRowAriaLabel', { + values: { ariaRowindex, columnValues }, + defaultMessage: 'Analyze the alert or event in row {ariaRowindex}, with columns {columnValues}', + }); + +export const SEND_ALERT_TO_TIMELINE_FOR_ROW = ({ + ariaRowindex, + columnValues, +}: { + ariaRowindex: number; + columnValues: string; +}) => + i18n.translate('xpack.timelines.timeline.body.actions.sendAlertToTimelineForRowAriaLabel', { + values: { ariaRowindex, columnValues }, + defaultMessage: 'Send the alert in row {ariaRowindex} to timeline, with columns {columnValues}', + }); + +export const ADD_NOTES_FOR_ROW = ({ + ariaRowindex, + columnValues, +}: { + ariaRowindex: number; + columnValues: string; +}) => + i18n.translate('xpack.timelines.timeline.body.actions.addNotesForRowAriaLabel', { + values: { ariaRowindex, columnValues }, + defaultMessage: + 'Add notes for the event in row {ariaRowindex} to timeline, with columns {columnValues}', + }); + +export const PIN_EVENT_FOR_ROW = ({ + ariaRowindex, + columnValues, + isEventPinned, +}: { + ariaRowindex: number; + columnValues: string; + isEventPinned: boolean; +}) => + i18n.translate('xpack.timelines.timeline.body.actions.pinEventForRowAriaLabel', { + values: { ariaRowindex, columnValues, isEventPinned }, + defaultMessage: + '{isEventPinned, select, false {Pin} true {Unpin}} the event in row {ariaRowindex} to timeline, with columns {columnValues}', + }); + +export const TIMELINE_TOGGLE_BUTTON_ARIA_LABEL = ({ + isOpen, + title, +}: { + isOpen: boolean; + title: string; +}) => + i18n.translate('xpack.timelines.timeline.properties.timelineToggleButtonAriaLabel', { + values: { isOpen, title }, + defaultMessage: '{isOpen, select, false {Open} true {Close} other {Toggle}} timeline {title}', + }); + +export const ATTACH_ALERT_TO_CASE_FOR_ROW = ({ + ariaRowindex, + columnValues, +}: { + ariaRowindex: number; + columnValues: string; +}) => + i18n.translate('xpack.timelines.timeline.body.actions.attachAlertToCaseForRowAriaLabel', { + values: { ariaRowindex, columnValues }, + defaultMessage: + 'Attach the alert or event in row {ariaRowindex} to a case, with columns {columnValues}', + }); + +export const MORE_ACTIONS_FOR_ROW = ({ + ariaRowindex, + columnValues, +}: { + ariaRowindex: number; + columnValues: string; +}) => + i18n.translate('xpack.timelines.timeline.body.actions.moreActionsForRowAriaLabel', { + values: { ariaRowindex, columnValues }, + defaultMessage: + 'Select more actions for the alert or event in row {ariaRowindex}, with columns {columnValues}', + }); + +export const INVESTIGATE_IN_RESOLVER_DISABLED = i18n.translate( + 'xpack.timelines.timeline.body.actions.investigateInResolverDisabledTooltip', + { + defaultMessage: 'This event cannot be analyzed since it has incompatible field mappings', + } +); diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/footer/index.test.tsx new file mode 100644 index 00000000000000..fe57ab8d2d0f3d --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/index.test.tsx @@ -0,0 +1,259 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount, shallow } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../../mock/test_providers'; + +import { FooterComponent, PagingControlComponent } from './index'; + +describe('Footer Timeline Component', () => { + const loadMore = jest.fn(); + const updatedAt = 1546878704036; + const serverSideEventCount = 15546; + const itemsCount = 2; + + describe('rendering', () => { + test('it renders the default timeline footer', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('FooterContainer').exists()).toBeTruthy(); + }); + + test('it renders the loading panel at the beginning ', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="LoadingPanelTimeline"]').exists()).toBeTruthy(); + }); + + test('it renders the loadMore button if need to fetch more', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timeline-pagination"]').exists()).toBeTruthy(); + }); + + test('it renders the Loading... in the more load button when fetching new data', () => { + const wrapper = shallow( + + ); + + const loadButton = wrapper.text(); + expect(wrapper.find('[data-test-subj="LoadingPanelTimeline"]').exists()).toBeFalsy(); + expect(loadButton).toContain('Loading...'); + }); + + test('it renders the Pagination in the more load button when fetching new data', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('[data-test-subj="timeline-pagination"]').exists()).toBeTruthy(); + }); + + test('it does NOT render the loadMore button because there is nothing else to fetch', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timeline-pagination"]').exists()).toBeFalsy(); + }); + + test('it render popover to select new itemsPerPage in timeline', () => { + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="timelineSizeRowPopover"] button').first().simulate('click'); + expect(wrapper.find('[data-test-subj="timelinePickSizeRow"]').exists()).toBeTruthy(); + }); + }); + + describe('Events', () => { + test('should call loadmore when clicking on the button load more', () => { + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click'); + expect(loadMore).toBeCalled(); + }); + + // test('Should call onChangeItemsPerPage when you pick a new limit', () => { + // const wrapper = mount( + // + // + // + // ); + + // wrapper.find('[data-test-subj="timelineSizeRowPopover"] button').first().simulate('click'); + // wrapper.update(); + // wrapper.find('[data-test-subj="timelinePickSizeRow"] button').first().simulate('click'); + // expect(onChangeItemsPerPage).toBeCalled(); + // }); + + test('it does render the auto-refresh message instead of load more button when stream live is on', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="paging-control"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="is-live-on-message"]').exists()).toBeTruthy(); + }); + + test('it does render the load more button when stream live is off', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="paging-control"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="is-live-on-message"]').exists()).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx new file mode 100644 index 00000000000000..2978759b6d148f --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx @@ -0,0 +1,394 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBadge, + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiPopover, + EuiText, + EuiToolTip, + EuiPopoverProps, + EuiPagination, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC, useCallback, useEffect, useState, useMemo } from 'react'; +import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; + +import { EVENTS_COUNT_BUTTON_CLASS_NAME } from '../helpers'; + +import * as i18n from './translations'; +import { OnChangePage } from '../types'; +import { tGridActions, tGridSelectors } from '../../../store/t_grid'; +import { useDeepEqualSelector } from '../../../hooks/use_selector'; +import { LoadingPanel } from '../../loading'; +import { LastUpdatedAt } from '../../last_updated'; + +export const isCompactFooter = (width: number): boolean => width < 600; + +const FixedWidthLastUpdated = styled.div<{ compact?: boolean }>` + width: ${({ compact }) => (!compact ? 200 : 25)}px; + overflow: hidden; + text-align: end; +`; + +FixedWidthLastUpdated.displayName = 'FixedWidthLastUpdated'; + +interface HeightProp { + height: number; +} + +const FooterContainer = styled(EuiFlexGroup).attrs(({ height }) => ({ + style: { + height: `${height}px`, + }, +}))` + flex: 0 0 auto; +`; + +FooterContainer.displayName = 'FooterContainer'; + +const FooterFlexGroup = styled(EuiFlexGroup)` + height: 35px; + width: 100%; +`; + +FooterFlexGroup.displayName = 'FooterFlexGroup'; + +const LoadingPanelContainer = styled.div` + padding-top: 3px; +`; + +LoadingPanelContainer.displayName = 'LoadingPanelContainer'; + +const PopoverRowItems = styled((EuiPopover as unknown) as FC)< + EuiPopoverProps & { + className?: string; + id?: string; + } +>` + .euiButtonEmpty__content { + padding: 0px 0px; + } +`; + +PopoverRowItems.displayName = 'PopoverRowItems'; + +export const ServerSideEventCount = styled.div` + margin: 0 5px 0 5px; +`; + +ServerSideEventCount.displayName = 'ServerSideEventCount'; + +/** The height of the footer, exported for use in height calculations */ +export const footerHeight = 40; // px + +/** Displays the server-side count of events */ +export const EventsCountComponent = ({ + closePopover, + documentType, + footerText, + isOpen, + items, + itemsCount, + onClick, + serverSideEventCount, +}: { + closePopover: () => void; + documentType: string; + isOpen: boolean; + items: React.ReactElement[]; + itemsCount: number; + onClick: () => void; + serverSideEventCount: number; + footerText: string; +}) => { + const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ + serverSideEventCount, + ]); + return ( +
+ + + {itemsCount} + + + {` ${i18n.OF} `} + + } + isOpen={isOpen} + closePopover={closePopover} + panelPaddingSize="none" + > + + + + + + {totalCount} + {' '} + {documentType} + + +
+ ); +}; + +EventsCountComponent.displayName = 'EventsCountComponent'; + +export const EventsCount = React.memo(EventsCountComponent); + +EventsCount.displayName = 'EventsCount'; + +interface PagingControlProps { + activePage: number; + isLoading: boolean; + onPageClick: OnChangePage; + totalCount: number; + totalPages: number; +} + +const TimelinePaginationContainer = styled.div<{ hideLastPage: boolean }>` + ul.euiPagination__list { + li.euiPagination__item:last-child { + ${({ hideLastPage }) => `${hideLastPage ? 'display:none' : ''}`}; + } + } +`; + +export const PagingControlComponent: React.FC = ({ + activePage, + isLoading, + onPageClick, + totalCount, + totalPages, +}) => { + if (isLoading) { + return <>{`${i18n.LOADING}...`}; + } + + if (!totalPages) { + return null; + } + + return ( + 9999}> + + + ); +}; + +PagingControlComponent.displayName = 'PagingControlComponent'; + +export const PagingControl = React.memo(PagingControlComponent); + +PagingControl.displayName = 'PagingControl'; +interface FooterProps { + updatedAt: number; + activePage: number; + height: number; + id: string; + isLive: boolean; + isLoading: boolean; + itemsCount: number; + itemsPerPage: number; + itemsPerPageOptions: number[]; + onChangePage: OnChangePage; + totalCount: number; +} + +/** Renders a loading indicator and paging controls */ +export const FooterComponent = ({ + activePage, + updatedAt, + height, + id, + isLive, + isLoading, + itemsCount, + itemsPerPage, + itemsPerPageOptions, + onChangePage, + totalCount, +}: FooterProps) => { + const dispatch = useDispatch(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [paginationLoading, setPaginationLoading] = useState(false); + + const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); + const { documentType, loadingText, footerText } = useDeepEqualSelector((state) => + getManageTimeline(state, id) + ); + + const handleChangePageClick = useCallback( + (nextPage: number) => { + setPaginationLoading(true); + onChangePage(nextPage); + }, + [onChangePage] + ); + + const onButtonClick = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [ + isPopoverOpen, + setIsPopoverOpen, + ]); + + const closePopover = useCallback(() => setIsPopoverOpen(false), [setIsPopoverOpen]); + + const onChangeItemsPerPage = useCallback( + (itemsChangedPerPage) => + dispatch(tGridActions.updateItemsPerPage({ id, itemsPerPage: itemsChangedPerPage })), + [dispatch, id] + ); + + const rowItems = useMemo( + () => + itemsPerPageOptions && + itemsPerPageOptions.map((item) => ( + { + closePopover(); + onChangeItemsPerPage(item); + }} + > + {`${item} ${i18n.ROWS}`} + + )), + [closePopover, itemsPerPage, itemsPerPageOptions, onChangeItemsPerPage] + ); + + const totalPages = useMemo(() => Math.ceil(totalCount / itemsPerPage), [ + itemsPerPage, + totalCount, + ]); + + useEffect(() => { + if (paginationLoading && !isLoading) { + setPaginationLoading(false); + } + }, [isLoading, paginationLoading]); + + if (isLoading && !paginationLoading) { + return ( + + + + ); + } + + return ( + + + + + + + + + + {isLive ? ( + + + {i18n.AUTO_REFRESH_ACTIVE}{' '} + + } + type="iInCircle" + /> + + + ) : ( + + )} + + + + + + + + ); +}; + +FooterComponent.displayName = 'FooterComponent'; + +export const Footer = React.memo(FooterComponent); + +Footer.displayName = 'Footer'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/translations.ts b/x-pack/plugins/timelines/public/components/t_grid/footer/translations.ts new file mode 100644 index 00000000000000..e237ca39e10abc --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/translations.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const LOADING_TIMELINE_DATA = i18n.translate('xpack.timelines.footer.loadingTimelineData', { + defaultMessage: 'Loading Timeline data', +}); + +export const EVENTS = i18n.translate('xpack.timelines.footer.events', { + defaultMessage: 'Events', +}); + +export const OF = i18n.translate('xpack.timelines.footer.of', { + defaultMessage: 'of', +}); + +export const ROWS = i18n.translate('xpack.timelines.footer.rows', { + defaultMessage: 'rows', +}); + +export const LOADING = i18n.translate('xpack.timelines.footer.loadingLabel', { + defaultMessage: 'Loading', +}); + +export const TOTAL_COUNT_OF_EVENTS = i18n.translate('xpack.timelines.footer.totalCountOfEvents', { + defaultMessage: 'events', +}); + +export const AUTO_REFRESH_ACTIVE = i18n.translate( + 'xpack.timelines.footer.autoRefreshActiveDescription', + { + defaultMessage: 'Auto-Refresh Active', + } +); diff --git a/x-pack/plugins/timelines/public/components/t_grid/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/timelines/public/components/t_grid/header_section/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000000..d3d20c71835707 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/header_section/__snapshots__/index.test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderSection it renders 1`] = ` +
+ + + + + +

+ Test title +

+
+ +
+
+
+
+
+`; diff --git a/x-pack/plugins/timelines/public/components/t_grid/header_section/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/header_section/index.test.tsx new file mode 100644 index 00000000000000..c5b4e679fe9f8c --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/header_section/index.test.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { TestProviders } from '../../../mock'; + +import { HeaderSection } from './index'; + +describe('HeaderSection', () => { + test('it renders', () => { + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders the title', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="header-section-title"]').first().exists()).toBe(true); + }); + + test('it renders the subtitle when provided', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(true); + }); + + test('renders the subtitle when not provided (to prevent layout thrash)', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(true); + }); + + test('it renders supplements when children provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe( + true + ); + }); + + test('it DOES NOT render supplements when children not provided', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe( + false + ); + }); + + test('it applies border styles when border is true', () => { + const wrapper = mount( + + + + ); + const siemHeaderSection = wrapper.find('.siemHeaderSection').first(); + + expect(siemHeaderSection).toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(siemHeaderSection).toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); + }); + + test('it DOES NOT apply border styles when border is false', () => { + const wrapper = mount( + + + + ); + const siemHeaderSection = wrapper.find('.siemHeaderSection').first(); + + expect(siemHeaderSection).not.toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(siemHeaderSection).not.toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); + }); + + test('it splits the title and supplement areas evenly when split is true', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect( + wrapper + .find('.euiFlexItem--flexGrowZero[data-test-subj="header-section-supplements"]') + .first() + .exists() + ).toBe(false); + }); + + test('it DOES NOT split the title and supplement areas evenly when split is false', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect( + wrapper + .find('.euiFlexItem--flexGrowZero[data-test-subj="header-section-supplements"]') + .first() + .exists() + ).toBe(true); + }); + + test('it renders an inspect button when an `id` is provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true); + }); + + test('it does NOT an inspect button when an `id` is NOT provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/header_section/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/header_section/index.tsx new file mode 100644 index 00000000000000..3a6838f4d86400 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/header_section/index.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiTitle, EuiTitleSize } from '@elastic/eui'; +import React from 'react'; +import styled, { css } from 'styled-components'; +import { InspectQuery } from '../../../store/t_grid/inputs'; +import { InspectButton } from '../../inspect'; + +import { Subtitle } from '../subtitle'; + +interface HeaderProps { + border?: boolean; + height?: number; +} + +const Header = styled.header.attrs(() => ({ + className: 'siemHeaderSection', +}))` + ${({ height }) => + height && + css` + height: ${height}px; + `} + margin-bottom: ${({ height, theme }) => (height ? 0 : theme.eui.euiSizeL)}; + user-select: text; + + ${({ border }) => + border && + css` + border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; + padding-bottom: ${({ theme }) => theme.eui.paddingSizes.l}; + `} +`; +Header.displayName = 'Header'; + +export interface HeaderSectionProps extends HeaderProps { + children?: React.ReactNode; + height?: number; + id?: string; + inspect: InspectQuery | null; + loading: boolean; + split?: boolean; + subtitle?: string | React.ReactNode; + title: string | React.ReactNode; + titleSize?: EuiTitleSize; + tooltip?: string; + growLeftSplit?: boolean; +} + +const HeaderSectionComponent: React.FC = ({ + border, + children, + height, + id, + inspect, + loading, + split, + subtitle, + title, + titleSize = 'm', + tooltip, + growLeftSplit = true, +}) => ( +
+ + + + + +

+ {title} + {tooltip && ( + <> + {' '} + + + )} +

+
+ + +
+ + {id && ( + + + + )} +
+
+ + {children && ( + + {children} + + )} +
+
+); + +export const HeaderSection = React.memo(HeaderSectionComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/helpers.test.tsx new file mode 100644 index 00000000000000..0fa47b22e55055 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/helpers.test.tsx @@ -0,0 +1,578 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cloneDeep } from 'lodash/fp'; +import { esFilters, EsQueryConfig, Filter } from '../../../../../../src/plugins/data/public'; +import { DataProviderType } from '../../../common/types/timeline'; +import { mockBrowserFields, mockDataProviders, mockIndexPattern } from '../../mock'; + +import { buildGlobalQuery, combineQueries, resolverIsShowing, showGlobalFilters } from './helpers'; + +const cleanUpKqlQuery = (str: string) => str.replace(/\n/g, '').replace(/\s\s+/g, ' '); + +describe('Build KQL Query', () => { + test('Build KQL query with one data provider', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 1"'); + }); + + test('Build KQL query with one template data provider', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].type = DataProviderType.template; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name :*'); + }); + + test('Build KQL query with one disabled data provider', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].enabled = false; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual(''); + }); + + test('Build KQL query with one data provider as timestamp (string input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = '@timestamp'; + dataProviders[0].queryMatch.value = '2018-03-23T23:36:23.232Z'; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('@timestamp: 1521848183232'); + }); + + test('Buld KQL query with one data provider as timestamp (numeric input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = '@timestamp'; + dataProviders[0].queryMatch.value = 1521848183232; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('@timestamp: 1521848183232'); + }); + + test('Buld KQL query with one data provider as timestamp (numeric input as string)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = '@timestamp'; + dataProviders[0].queryMatch.value = '1521848183232'; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('@timestamp: 1521848183232'); + }); + + test('Build KQL query with one data provider as date type (string input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = 'event.end'; + dataProviders[0].queryMatch.value = '2018-03-23T23:36:23.232Z'; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('event.end: 1521848183232'); + }); + + test('Buld KQL query with one data provider as date type (numeric input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = 'event.end'; + dataProviders[0].queryMatch.value = 1521848183232; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('event.end: 1521848183232'); + }); + + test('Buld KQL query with one data provider as date type (numeric input as string)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = 'event.end'; + dataProviders[0].queryMatch.value = '1521848183232'; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('event.end: 1521848183232'); + }); + + test('Build KQL query with two data provider', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('(name : "Provider 1") or (name : "Provider 2")'); + }); + + test('Build KQL query with two data provider and first is disabled', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[0].enabled = false; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 2"'); + }); + + test('Build KQL query with two data provider and second is disabled', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[1].enabled = false; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 1"'); + }); + + test('Build KQL query with two data provider (first is template)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[0].type = DataProviderType.template; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('(name :*) or (name : "Provider 2")'); + }); + + test('Build KQL query with two data provider (second is template)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[1].type = DataProviderType.template; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('(name : "Provider 1") or (name :*)'); + }); + + test('Build KQL query with one data provider and one and', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(1, 2)); + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 1" and name : "Provider 2"'); + }); + + test('Build KQL query with one disabled data provider and one and', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].enabled = false; + dataProviders[0].and = cloneDeep(mockDataProviders.slice(1, 2)); + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 2"'); + }); + + test('Build KQL query with one data provider and one and as timestamp (string input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(1, 2)); + dataProviders[0].and[0].queryMatch.field = '@timestamp'; + dataProviders[0].and[0].queryMatch.value = '2018-03-23T23:36:23.232Z'; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 1" and @timestamp: 1521848183232'); + }); + + test('Build KQL query with one data provider and one and as timestamp (numeric input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(1, 2)); + dataProviders[0].and[0].queryMatch.field = '@timestamp'; + dataProviders[0].and[0].queryMatch.value = 1521848183232; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 1" and @timestamp: 1521848183232'); + }); + + test('Build KQL query with one data provider and one and as date type (string input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(1, 2)); + dataProviders[0].and[0].queryMatch.field = 'event.end'; + dataProviders[0].and[0].queryMatch.value = '2018-03-23T23:36:23.232Z'; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 1" and event.end: 1521848183232'); + }); + + test('Build KQL query with one data provider and one and as date type (numeric input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(1, 2)); + dataProviders[0].and[0].queryMatch.field = 'event.end'; + dataProviders[0].and[0].queryMatch.value = 1521848183232; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 1" and event.end: 1521848183232'); + }); + + test('Build KQL query with two data provider and multiple and', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(2, 4)); + dataProviders[1].and = cloneDeep(mockDataProviders.slice(4, 5)); + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual( + '(name : "Provider 1" and name : "Provider 3" and name : "Provider 4") or (name : "Provider 2" and name : "Provider 5")' + ); + }); + + test('Build KQL query with two data provider and multiple and and first data provider is disabled', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[0].enabled = false; + dataProviders[0].and = cloneDeep(mockDataProviders.slice(2, 4)); + dataProviders[1].and = cloneDeep(mockDataProviders.slice(4, 5)); + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual( + '(name : "Provider 3" and name : "Provider 4") or (name : "Provider 2" and name : "Provider 5")' + ); + }); + + test('Build KQL query with two data provider and multiple and and first and provider is disabled', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(2, 4)); + dataProviders[0].and[0].enabled = false; + dataProviders[1].and = cloneDeep(mockDataProviders.slice(4, 5)); + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual( + '(name : "Provider 1" and name : "Provider 4") or (name : "Provider 2" and name : "Provider 5")' + ); + }); + + test('Build KQL query with all data provider', () => { + const kqlQuery = buildGlobalQuery(mockDataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual( + '(name : "Provider 1") or (name : "Provider 2") or (name : "Provider 3") or (name : "Provider 4") or (name : "Provider 5") or (name : "Provider 6") or (name : "Provider 7") or (name : "Provider 8") or (name : "Provider 9") or (name : "Provider 10")' + ); + }); + + test('Build complex KQL query with and and or', () => { + const dataProviders = cloneDeep(mockDataProviders); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(2, 4)); + dataProviders[1].and = cloneDeep(mockDataProviders.slice(4, 5)); + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual( + '(name : "Provider 1" and name : "Provider 3" and name : "Provider 4") or (name : "Provider 2" and name : "Provider 5") or (name : "Provider 3") or (name : "Provider 4") or (name : "Provider 5") or (name : "Provider 6") or (name : "Provider 7") or (name : "Provider 8") or (name : "Provider 9") or (name : "Provider 10")' + ); + }); +}); + +describe('Combined Queries', () => { + const config: EsQueryConfig = { + allowLeadingWildcards: true, + queryStringOptions: {}, + ignoreFilterIfFieldNotInIndex: true, + dateFormatTZ: 'America/New_York', + }; + test('No Data Provider & No kqlQuery & and isEventViewer is false', () => { + expect( + combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + }) + ).toBeNull(); + }); + + test('No Data Provider & No kqlQuery & isEventViewer is true', () => { + const isEventViewer = true; + expect( + combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + isEventViewer, + }) + ).toEqual({ + filterQuery: '{"bool":{"must":[],"filter":[],"should":[],"must_not":[]}}', + }); + }); + + test('No Data Provider & No kqlQuery & with Filters', () => { + const isEventViewer = true; + expect( + combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [ + { + $state: { store: esFilters.FilterStateStore.APP_STATE }, + meta: { + alias: null, + disabled: false, + key: 'event.category', + negate: false, + params: { query: 'file' }, + type: 'phrase', + }, + query: { match_phrase: { 'event.category': 'file' } }, + }, + { + $state: { store: esFilters.FilterStateStore.APP_STATE }, + meta: { + alias: null, + disabled: false, + key: 'host.name', + negate: false, + type: 'exists', + value: 'exists', + }, + exists: { field: 'host.name' }, + } as Filter, + ], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + isEventViewer, + }) + ).toEqual({ + filterQuery: + '{"bool":{"must":[],"filter":[{"exists":{"field":"host.name"}}],"should":[],"must_not":[]}}', + }); + }); + + test('Only Data Provider', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + const { filterQuery } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + expect(filterQuery).toEqual( + '{"bool":{"must":[],"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}' + ); + }); + + test('Only Data Provider with timestamp (string input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = '@timestamp'; + dataProviders[0].queryMatch.value = '2018-03-23T23:36:23.232Z'; + const { filterQuery } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + expect(filterQuery).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"range\\":{\\"@timestamp\\":{\\"gte\\":\\"1521848183232\\",\\"lte\\":\\"1521848183232\\"}}}],\\"minimum_should_match\\":1}}],\\"should\\":[],\\"must_not\\":[]}}"` + ); + }); + + test('Only Data Provider with timestamp (numeric input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = '@timestamp'; + dataProviders[0].queryMatch.value = 1521848183232; + const { filterQuery } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + expect(filterQuery).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"range\\":{\\"@timestamp\\":{\\"gte\\":\\"1521848183232\\",\\"lte\\":\\"1521848183232\\"}}}],\\"minimum_should_match\\":1}}],\\"should\\":[],\\"must_not\\":[]}}"` + ); + }); + + test('Only Data Provider with a date type (string input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = 'event.end'; + dataProviders[0].queryMatch.value = '2018-03-23T23:36:23.232Z'; + const { filterQuery } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + expect(filterQuery).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"event.end\\":\\"1521848183232\\"}}],\\"minimum_should_match\\":1}}],\\"should\\":[],\\"must_not\\":[]}}"` + ); + }); + + test('Only Data Provider with date type (numeric input)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.field = 'event.end'; + dataProviders[0].queryMatch.value = 1521848183232; + const { filterQuery } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + expect(filterQuery).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"event.end\\":\\"1521848183232\\"}}],\\"minimum_should_match\\":1}}],\\"should\\":[],\\"must_not\\":[]}}"` + ); + }); + + test('Only KQL search/filter query', () => { + const { filterQuery } = combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'host.name: "host-1"', language: 'kuery' }, + kqlMode: 'search', + })!; + expect(filterQuery).toEqual( + '{"bool":{"must":[],"filter":[{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}' + ); + }); + + test('Data Provider & KQL search query', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + const { filterQuery } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'host.name: "host-1"', language: 'kuery' }, + kqlMode: 'search', + })!; + expect(filterQuery).toEqual( + '{"bool":{"must":[],"filter":[{"bool":{"should":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}' + ); + }); + + test('Data Provider & KQL filter query', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + const { filterQuery } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'host.name: "host-1"', language: 'kuery' }, + kqlMode: 'filter', + })!; + expect(filterQuery).toEqual( + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}]}}],"should":[],"must_not":[]}}' + ); + }); + + test('Data Provider & KQL search query multiple', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(2, 4)); + dataProviders[1].and = cloneDeep(mockDataProviders.slice(4, 5)); + const { filterQuery } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'host.name: "host-1"', language: 'kuery' }, + kqlMode: 'search', + })!; + expect(filterQuery).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 1\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 3\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 4\\"}}],\\"minimum_should_match\\":1}}]}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 2\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 5\\"}}],\\"minimum_should_match\\":1}}]}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"host.name\\":\\"host-1\\"}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}],\\"should\\":[],\\"must_not\\":[]}}"` + ); + }); + + test('Data Provider & KQL filter query multiple', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[0].and = cloneDeep(mockDataProviders.slice(2, 4)); + dataProviders[1].and = cloneDeep(mockDataProviders.slice(4, 5)); + const { filterQuery } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'host.name: "host-1"', language: 'kuery' }, + kqlMode: 'filter', + })!; + expect(filterQuery).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 1\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 3\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 4\\"}}],\\"minimum_should_match\\":1}}]}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 2\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 5\\"}}],\\"minimum_should_match\\":1}}]}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"host.name\\":\\"host-1\\"}}],\\"minimum_should_match\\":1}}]}}],\\"should\\":[],\\"must_not\\":[]}}"` + ); + }); + + test('Data Provider & kql filter query with nested field that exists', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + const query = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'nestedField.firstAttributes', + value: 'exists', + }, + exists: { + field: 'nestedField.firstAttributes', + }, + $state: { + store: esFilters.FilterStateStore.APP_STATE, + }, + } as Filter, + ], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'filter', + }); + const filterQuery = query && query.filterQuery; + expect(filterQuery).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 1\\"}}],\\"minimum_should_match\\":1}},{\\"exists\\":{\\"field\\":\\"nestedField.firstAttributes\\"}}],\\"should\\":[],\\"must_not\\":[]}}"` + ); + }); + + test('Data Provider & kql filter query with nested field of a particular value', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + const query = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [ + { + $state: { store: esFilters.FilterStateStore.APP_STATE }, + meta: { + alias: null, + disabled: false, + key: 'nestedField.secondAttributes', + negate: false, + params: { query: 'test' }, + type: 'phrase', + }, + query: { match_phrase: { 'nestedField.secondAttributes': 'test' } }, + }, + ], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'filter', + }); + const filterQuery = query && query.filterQuery; + expect(filterQuery).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"name\\":\\"Provider 1\\"}}],\\"minimum_should_match\\":1}},{\\"match_phrase\\":{\\"nestedField.secondAttributes\\":\\"test\\"}}],\\"should\\":[],\\"must_not\\":[]}}"` + ); + }); + + describe('resolverIsShowing', () => { + test('it returns true when graphEventId is NOT an empty string', () => { + expect(resolverIsShowing('a valid id')).toBe(true); + }); + + test('it returns false when graphEventId is undefined', () => { + expect(resolverIsShowing(undefined)).toBe(false); + }); + + test('it returns false when graphEventId is an empty string', () => { + expect(resolverIsShowing('')).toBe(false); + }); + }); + + describe('showGlobalFilters', () => { + test('it returns false when `globalFullScreen` is true and `graphEventId` is NOT an empty string, because Resolver IS showing', () => { + expect(showGlobalFilters({ globalFullScreen: true, graphEventId: 'a valid id' })).toBe(false); + }); + + test('it returns true when `globalFullScreen` is true and `graphEventId` is undefined, because Resolver is NOT showing', () => { + expect(showGlobalFilters({ globalFullScreen: true, graphEventId: undefined })).toBe(true); + }); + + test('it returns true when `globalFullScreen` is true and `graphEventId` is an empty string, because Resolver is NOT showing', () => { + expect(showGlobalFilters({ globalFullScreen: true, graphEventId: '' })).toBe(true); + }); + + test('it returns true when `globalFullScreen` is false and `graphEventId` is NOT an empty string, because Resolver IS showing', () => { + expect(showGlobalFilters({ globalFullScreen: false, graphEventId: 'a valid id' })).toBe(true); + }); + + test('it returns true when `globalFullScreen` is false and `graphEventId` is undefined, because Resolver is NOT showing', () => { + expect(showGlobalFilters({ globalFullScreen: false, graphEventId: undefined })).toBe(true); + }); + + test('it returns true when `globalFullScreen` is false and `graphEventId` is an empty string, because Resolver is NOT showing', () => { + expect(showGlobalFilters({ globalFullScreen: false, graphEventId: '' })).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/helpers.tsx b/x-pack/plugins/timelines/public/components/t_grid/helpers.tsx new file mode 100644 index 00000000000000..fc040522f3e15c --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/helpers.tsx @@ -0,0 +1,314 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty, get } from 'lodash/fp'; +import memoizeOne from 'memoize-one'; +import { + elementOrChildrenHasFocus, + getFocusedAriaColindexCell, + getTableSkipFocus, + handleSkipFocus, + stopPropagationAndPreventDefault, +} from '../../../common'; +import type { + EsQueryConfig, + Filter, + IIndexPattern, + Query, +} from '../../../../../../src/plugins/data/public'; +import type { BrowserFields } from '../../../common/search_strategy/index_fields'; +import { DataProviderType, EXISTS_OPERATOR } from '../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { DataProvider, DataProvidersAnd } from '../../../common/types/timeline'; +import { convertToBuildEsQuery, escapeQueryValue } from '../utils/keury'; + +import { EVENTS_TABLE_CLASS_NAME } from './styles'; + +const isNumber = (value: string | number) => !isNaN(Number(value)); + +const convertDateFieldToQuery = (field: string, value: string | number) => + `${field}: ${isNumber(value) ? value : new Date(value).valueOf()}`; + +const getBaseFields = memoizeOne((browserFields: BrowserFields): string[] => { + const baseFields = get('base', browserFields); + if (baseFields != null && baseFields.fields != null) { + return Object.keys(baseFields.fields); + } + return []; +}); + +const getBrowserFieldPath = (field: string, browserFields: BrowserFields) => { + const splitFields = field.split('.'); + const baseFields = getBaseFields(browserFields); + if (baseFields.includes(field)) { + return ['base', 'fields', field]; + } + return [splitFields[0], 'fields', field]; +}; + +const checkIfFieldTypeIsDate = (field: string, browserFields: BrowserFields) => { + const pathBrowserField = getBrowserFieldPath(field, browserFields); + const browserField = get(pathBrowserField, browserFields); + if (browserField != null && browserField.type === 'date') { + return true; + } + return false; +}; + +const convertNestedFieldToQuery = ( + field: string, + value: string | number, + browserFields: BrowserFields +) => { + const pathBrowserField = getBrowserFieldPath(field, browserFields); + const browserField = get(pathBrowserField, browserFields); + const nestedPath = browserField.subType.nested.path; + const key = field.replace(`${nestedPath}.`, ''); + return `${nestedPath}: { ${key}: ${browserField.type === 'date' ? `"${value}"` : value} }`; +}; + +const convertNestedFieldToExistQuery = (field: string, browserFields: BrowserFields) => { + const pathBrowserField = getBrowserFieldPath(field, browserFields); + const browserField = get(pathBrowserField, browserFields); + const nestedPath = browserField.subType.nested.path; + const key = field.replace(`${nestedPath}.`, ''); + return `${nestedPath}: { ${key}: * }`; +}; + +const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) => { + const pathBrowserField = getBrowserFieldPath(field, browserFields); + const browserField = get(pathBrowserField, browserFields); + if (browserField != null && browserField.subType && browserField.subType.nested) { + return true; + } + return false; +}; + +const buildQueryMatch = ( + dataProvider: DataProvider | DataProvidersAnd, + browserFields: BrowserFields +) => + `${dataProvider.excluded ? 'NOT ' : ''}${ + dataProvider.queryMatch.operator !== EXISTS_OPERATOR && + dataProvider.type !== DataProviderType.template + ? checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields) + ? convertNestedFieldToQuery( + dataProvider.queryMatch.field, + dataProvider.queryMatch.value, + browserFields + ) + : checkIfFieldTypeIsDate(dataProvider.queryMatch.field, browserFields) + ? convertDateFieldToQuery(dataProvider.queryMatch.field, dataProvider.queryMatch.value) + : `${dataProvider.queryMatch.field} : ${ + isNumber(dataProvider.queryMatch.value) + ? dataProvider.queryMatch.value + : escapeQueryValue(dataProvider.queryMatch.value) + }` + : checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields) + ? convertNestedFieldToExistQuery(dataProvider.queryMatch.field, browserFields) + : `${dataProvider.queryMatch.field} ${EXISTS_OPERATOR}` + }`.trim(); + +export const buildGlobalQuery = (dataProviders: DataProvider[], browserFields: BrowserFields) => + dataProviders + .reduce((queries: string[], dataProvider: DataProvider) => { + const flatDataProviders = [dataProvider, ...dataProvider.and]; + const activeDataProviders = flatDataProviders.filter( + (flatDataProvider) => flatDataProvider.enabled + ); + + if (!activeDataProviders.length) return queries; + + const activeDataProvidersQueries = activeDataProviders.map((activeDataProvider) => + buildQueryMatch(activeDataProvider, browserFields) + ); + + const activeDataProvidersQueryMatch = activeDataProvidersQueries.join(' and '); + + return [...queries, activeDataProvidersQueryMatch]; + }, []) + .filter((queriesItem) => !isEmpty(queriesItem)) + .reduce((globalQuery: string, queryMatch: string, index: number, queries: string[]) => { + if (queries.length <= 1) return queryMatch; + + return !index ? `(${queryMatch})` : `${globalQuery} or (${queryMatch})`; + }, ''); + +export const combineQueries = ({ + config, + dataProviders, + indexPattern, + browserFields, + filters = [], + kqlQuery, + kqlMode, + isEventViewer, +}: { + config: EsQueryConfig; + dataProviders: DataProvider[]; + indexPattern: IIndexPattern; + browserFields: BrowserFields; + filters: Filter[]; + kqlQuery: Query; + kqlMode: string; + isEventViewer?: boolean; +}): { filterQuery: string } | null => { + const kuery: Query = { query: '', language: kqlQuery.language }; + if (isEmpty(dataProviders) && isEmpty(kqlQuery.query) && isEmpty(filters) && !isEventViewer) { + return null; + } else if (isEmpty(dataProviders) && isEmpty(kqlQuery.query) && isEventViewer) { + return { + filterQuery: convertToBuildEsQuery({ config, queries: [kuery], indexPattern, filters }), + }; + } else if (isEmpty(dataProviders) && isEmpty(kqlQuery.query) && !isEmpty(filters)) { + return { + filterQuery: convertToBuildEsQuery({ config, queries: [kuery], indexPattern, filters }), + }; + } else if (isEmpty(dataProviders) && !isEmpty(kqlQuery.query)) { + kuery.query = `(${kqlQuery.query})`; + return { + filterQuery: convertToBuildEsQuery({ config, queries: [kuery], indexPattern, filters }), + }; + } else if (!isEmpty(dataProviders) && isEmpty(kqlQuery)) { + kuery.query = `(${buildGlobalQuery(dataProviders, browserFields)})`; + return { + filterQuery: convertToBuildEsQuery({ config, queries: [kuery], indexPattern, filters }), + }; + } + const operatorKqlQuery = kqlMode === 'filter' ? 'and' : 'or'; + const postpend = (q: string) => `${!isEmpty(q) ? ` ${operatorKqlQuery} (${q})` : ''}`; + kuery.query = `((${buildGlobalQuery(dataProviders, browserFields)})${postpend( + kqlQuery.query as string + )})`; + return { + filterQuery: convertToBuildEsQuery({ config, queries: [kuery], indexPattern, filters }), + }; +}; + +/** + * The CSS class name of a "stateful event", which appears in both + * the `Timeline` and the `Events Viewer` widget + */ +export const STATEFUL_EVENT_CSS_CLASS_NAME = 'event-column-view'; + +export const DEFAULT_ICON_BUTTON_WIDTH = 24; + +export const resolverIsShowing = (graphEventId: string | undefined): boolean => + graphEventId != null && graphEventId !== ''; + +export const showGlobalFilters = ({ + globalFullScreen, + graphEventId, +}: { + globalFullScreen: boolean; + graphEventId: string | undefined; +}): boolean => (globalFullScreen && resolverIsShowing(graphEventId) ? false : true); + +/** + * The `aria-colindex` of the Timeline actions column + */ +export const ACTIONS_COLUMN_ARIA_COL_INDEX = '1'; + +/** + * Every column index offset by `2`, because, per https://www.w3.org/TR/wai-aria-practices-1.1/examples/grid/dataGrids.html + * the `aria-colindex` attribute starts at `1`, and the "actions column" is always the first column + */ +export const ARIA_COLUMN_INDEX_OFFSET = 2; + +export const EVENTS_COUNT_BUTTON_CLASS_NAME = 'local-events-count-button'; + +/** Calculates the total number of pages in a (timeline) events view */ +export const calculateTotalPages = ({ + itemsCount, + itemsPerPage, +}: { + itemsCount: number; + itemsPerPage: number; +}): number => (itemsCount === 0 || itemsPerPage === 0 ? 0 : Math.ceil(itemsCount / itemsPerPage)); + +/** Returns true if the events table has focus */ +export const tableHasFocus = (containerElement: HTMLElement | null): boolean => + elementOrChildrenHasFocus( + containerElement?.querySelector(`.${EVENTS_TABLE_CLASS_NAME}`) + ); + +/** + * This function has a side effect. It will skip focus "after" or "before" + * Timeline's events table, with exceptions as noted below. + * + * If the currently-focused table cell has additional focusable children, + * i.e. action buttons, draggables, or always-open popover content, the + * browser's "natural" focus management will determine which element is + * focused next. + */ +export const onTimelineTabKeyPressed = ({ + containerElement, + keyboardEvent, + onSkipFocusBeforeEventsTable, + onSkipFocusAfterEventsTable, +}: { + containerElement: HTMLElement | null; + keyboardEvent: React.KeyboardEvent; + onSkipFocusBeforeEventsTable: () => void; + onSkipFocusAfterEventsTable: () => void; +}) => { + const { shiftKey } = keyboardEvent; + + const eventsTableSkipFocus = getTableSkipFocus({ + containerElement, + getFocusedCell: getFocusedAriaColindexCell, + shiftKey, + tableHasFocus, + tableClassName: EVENTS_TABLE_CLASS_NAME, + }); + + if (eventsTableSkipFocus !== 'SKIP_FOCUS_NOOP') { + stopPropagationAndPreventDefault(keyboardEvent); + handleSkipFocus({ + onSkipFocusBackwards: onSkipFocusBeforeEventsTable, + onSkipFocusForward: onSkipFocusAfterEventsTable, + skipFocus: eventsTableSkipFocus, + }); + } +}; + +export const ACTIVE_TIMELINE_BUTTON_CLASS_NAME = 'active-timeline-button'; +export const FLYOUT_BUTTON_BAR_CLASS_NAME = 'timeline-flyout-button-bar'; +export const FLYOUT_BUTTON_CLASS_NAME = 'timeline-flyout-button'; + +/** + * This function focuses the active timeline button on the next tick. Focus + * is updated on the next tick because this function is typically + * invoked in `onClick` handlers that also dispatch Redux actions (that + * in-turn update focus states). + */ +export const focusActiveTimelineButton = () => { + setTimeout(() => { + document + .querySelector( + `div.${FLYOUT_BUTTON_BAR_CLASS_NAME} .${ACTIVE_TIMELINE_BUTTON_CLASS_NAME}` + ) + ?.focus(); + }, 0); +}; + +/** + * Focuses the utility bar action contained by the provided `containerElement` + * when a valid container is provided + */ +export const focusUtilityBarAction = (containerElement: HTMLElement | null) => { + containerElement + ?.querySelector('div.siemUtilityBar__action:last-of-type button') + ?.focus(); +}; + +/** + * Resets keyboard focus on the page + */ +export const resetKeyboardFocus = () => { + document.querySelector('header.headerGlobalNav a.euiHeaderLogo')?.focus(); +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx new file mode 100644 index 00000000000000..d52174b02f88eb --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -0,0 +1,355 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; + +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { Direction } from '../../../../common/search_strategy'; +// eslint-disable-next-line no-duplicate-imports +import type { DocValueFields } from '../../../../common/search_strategy'; +import type { CoreStart } from '../../../../../../../src/core/public'; +import type { BrowserFields } from '../../../../common/search_strategy/index_fields'; +import { TimelineId, TimelineTabs } from '../../../../common/types/timeline'; +// eslint-disable-next-line no-duplicate-imports +import type { + CellValueElementProps, + ColumnHeaderOptions, + ControlColumnProps, + DataProvider, + RowRenderer, +} from '../../../../common/types/timeline'; +import { + esQuery, + Filter, + IIndexPattern, + Query, + DataPublicPluginStart, +} from '../../../../../../../src/plugins/data/public'; +import { useDeepEqualSelector } from '../../../hooks/use_selector'; +import { Refetch } from '../../../store/t_grid/inputs'; +import { defaultHeaders } from '../body/column_headers/default_headers'; +import { calculateTotalPages, combineQueries, resolverIsShowing } from '../helpers'; +import { tGridActions, tGridSelectors } from '../../../store/t_grid'; +import { useTimelineEvents } from '../../../container'; +import { HeaderSection } from '../header_section'; +import { StatefulBody } from '../body'; +import { Footer, footerHeight } from '../footer'; +import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../styles'; +import * as i18n from './translations'; +import { ExitFullScreen } from '../../exit_full_screen'; +import { Sort } from '../body/sort'; +import { InspectButtonContainer } from '../../inspect'; + +export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px +const UTILITY_BAR_HEIGHT = 19; // px +const COMPACT_HEADER_HEIGHT = EVENTS_VIEWER_HEADER_HEIGHT - UTILITY_BAR_HEIGHT; // px + +const UtilityBar = styled.div` + height: ${UTILITY_BAR_HEIGHT}px; +`; + +const TitleText = styled.span` + margin-right: 12px; +`; + +const StyledEuiPanel = styled(EuiPanel)<{ $isFullScreen: boolean }>` + display: flex; + flex-direction: column; + + ${({ $isFullScreen }) => + $isFullScreen && + ` + border: 0; + box-shadow: none; + padding-top: 0; + padding-bottom: 0; + `} +`; + +const TitleFlexGroup = styled(EuiFlexGroup)` + margin-top: 8px; +`; + +const EventsContainerLoading = styled.div.attrs(({ className = '' }) => ({ + className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`, +}))` + width: 100%; + overflow: hidden; + flex: 1; + display: flex; + flex-direction: column; +`; + +const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` + overflow: hidden; + margin: 0; + display: ${({ $visible }) => ($visible ? 'flex' : 'none')}; +`; + +const ScrollableFlexItem = styled(EuiFlexItem)` + overflow: auto; +`; + +/** + * Hides stateful headerFilterGroup implementations, but prevents the component + * from being unmounted, to preserve the state of the component + */ +const HeaderFilterGroupWrapper = styled.header<{ show: boolean }>` + ${({ show }) => (show ? '' : 'visibility: hidden;')} +`; + +export interface TGridIntegratedProps { + browserFields: BrowserFields; + columns: ColumnHeaderOptions[]; + dataProviders: DataProvider[]; + deletedEventIds: Readonly; + docValueFields: DocValueFields[]; + end: string; + filters: Filter[]; + globalFullScreen: boolean; + headerFilterGroup?: React.ReactNode; + height?: number; + id: TimelineId; + indexNames: string[]; + indexPattern: IIndexPattern; + isLive: boolean; + isLoadingIndexPattern: boolean; + itemsPerPage: number; + itemsPerPageOptions: number[]; + kqlMode: 'filter' | 'search'; + query: Query; + onRuleChange?: () => void; + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + rowRenderers: RowRenderer[]; + setGlobalFullScreen: (fullscreen: boolean) => void; + start: string; + sort: Sort[]; + utilityBar?: (refetch: Refetch, totalCount: number) => React.ReactNode; + // If truthy, the graph viewer (Resolver) is showing + graphEventId: string | undefined; + leadingControlColumns: ControlColumnProps[]; + trailingControlColumns: ControlColumnProps[]; + data?: DataPublicPluginStart; +} + +const TGridIntegratedComponent: React.FC = ({ + browserFields, + columns, + dataProviders, + deletedEventIds, + docValueFields, + end, + filters, + globalFullScreen, + headerFilterGroup, + id, + indexNames, + indexPattern, + isLive, + isLoadingIndexPattern, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + onRuleChange, + query, + renderCellValue, + rowRenderers, + setGlobalFullScreen, + start, + sort, + utilityBar, + graphEventId, + leadingControlColumns, + trailingControlColumns, + data, +}) => { + const dispatch = useDispatch(); + const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; + const { uiSettings } = useKibana().services; + const [isQueryLoading, setIsQueryLoading] = useState(false); + + const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); + const unit = useMemo(() => (n: number) => i18n.UNIT(n), []); + const { queryFields, title } = useDeepEqualSelector((state) => + getManageTimeline(state, id ?? '') + ); + + useEffect(() => { + dispatch(tGridActions.updateIsLoading({ id, isLoading: isQueryLoading })); + }, [dispatch, id, isQueryLoading]); + + const justTitle = useMemo(() => {title}, [title]); + const titleWithExitFullScreen = useMemo( + () => ( + + {justTitle} + + + + + ), + [globalFullScreen, justTitle, setGlobalFullScreen] + ); + + const combinedQueries = combineQueries({ + config: esQuery.getEsQueryConfig(uiSettings), + dataProviders, + indexPattern, + browserFields, + filters, + kqlQuery: query, + kqlMode, + isEventViewer: true, + }); + + const canQueryTimeline = useMemo( + () => + combinedQueries != null && + isLoadingIndexPattern != null && + !isLoadingIndexPattern && + !isEmpty(start) && + !isEmpty(end), + [isLoadingIndexPattern, combinedQueries, start, end] + ); + + const fields = useMemo(() => [...columnsHeader.map((c) => c.id), ...(queryFields ?? [])], [ + columnsHeader, + queryFields, + ]); + + const sortField = useMemo( + () => + sort.map(({ columnId, columnType, sortDirection }) => ({ + field: columnId, + type: columnType, + direction: sortDirection as Direction, + })), + [sort] + ); + + const [ + loading, + { events, updatedAt, loadPage, pageInfo, refetch, totalCount = 0, inspect }, + ] = useTimelineEvents({ + docValueFields, + fields, + filterQuery: combinedQueries!.filterQuery, + id, + indexNames, + limit: itemsPerPage, + sort: sortField, + startDate: start, + endDate: end, + skip: !canQueryTimeline, + data, + }); + + const totalCountMinusDeleted = useMemo( + () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), + [deletedEventIds.length, totalCount] + ); + + const subtitle = useMemo( + () => + `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${ + unit && unit(totalCountMinusDeleted) + }`, + [totalCountMinusDeleted, unit] + ); + + const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [ + deletedEventIds, + events, + ]); + + const HeaderSectionContent = useMemo( + () => + headerFilterGroup && ( + + {headerFilterGroup} + + ), + [headerFilterGroup, graphEventId] + ); + + useEffect(() => { + setIsQueryLoading(loading); + }, [loading]); + + return ( + + + {canQueryTimeline ? ( + <> + + {HeaderSectionContent} + + {utilityBar && !resolverIsShowing(graphEventId) && ( + {utilityBar?.(refetch, totalCountMinusDeleted)} + )} + + + + +