diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md new file mode 100644 index 0000000000000..4f582e746191f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) + +## OverlayFlyoutOpenOptions.maxWidth property + +Signature: + +```typescript +maxWidth?: boolean | number | string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md index 5945bca01f55f..6665ebde295bc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md @@ -18,5 +18,7 @@ export interface OverlayFlyoutOpenOptions | ["data-test-subj"](./kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md) | string | | | [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md) | string | | | [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | string | | +| [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | boolean | number | string | | | [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | boolean | | +| [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md) | EuiFlyoutSize | | diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.size.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.size.md new file mode 100644 index 0000000000000..3754242dc7c26 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.size.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md) + +## OverlayFlyoutOpenOptions.size property + +Signature: + +```typescript +size?: EuiFlyoutSize; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawid.md index e86d7cbb36435..a9dfd84cf0b42 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawid.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawid.md @@ -9,7 +9,7 @@ Given a saved object type and id, generates the compound id that is stored in th Signature: ```typescript -generateRawId(namespace: string | undefined, type: string, id?: string): string; +generateRawId(namespace: string | undefined, type: string, id: string): string; ``` ## Parameters diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.generateid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.generateid.md new file mode 100644 index 0000000000000..f095184484992 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.generateid.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) > [generateId](./kibana-plugin-core-server.savedobjectsutils.generateid.md) + +## SavedObjectsUtils.generateId() method + +Generates a random ID for a saved objects. + +Signature: + +```typescript +static generateId(): string; +``` +Returns: + +`string` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.israndomid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.israndomid.md new file mode 100644 index 0000000000000..7bfb1bcbd8cd7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.israndomid.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) > [isRandomId](./kibana-plugin-core-server.savedobjectsutils.israndomid.md) + +## SavedObjectsUtils.isRandomId() method + +Validates that a saved object ID matches UUID format. + +Signature: + +```typescript +static isRandomId(id: string | undefined): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | undefined | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md index 83831f65bd41a..7b774e14b640f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md @@ -19,3 +19,10 @@ export declare class SavedObjectsUtils | [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md) | static | (namespace?: string | undefined) => string | Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the undefined namespace ID (which has a namespace string of 'default'). | | [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md) | static | (namespace: string) => string | undefined | Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the 'default' namespace string (which has a namespace ID of undefined). | +## Methods + +| Method | Modifiers | Description | +| --- | --- | --- | +| [generateId()](./kibana-plugin-core-server.savedobjectsutils.generateid.md) | static | Generates a random ID for a saved objects. | +| [isRandomId(id)](./kibana-plugin-core-server.savedobjectsutils.israndomid.md) | static | Validates that a saved object ID matches UUID format. | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatterns.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatterns.md index 39c8b0a700c8a..4934672d75f31 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatterns.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatterns.md @@ -16,7 +16,6 @@ indexPatterns: { isFilterable: typeof isFilterable; isNestedField: typeof isNestedField; validate: typeof validateIndexPattern; - getFromSavedObject: typeof getFromSavedObject; flattenHitWrapper: typeof flattenHitWrapper; formatHitProvider: typeof formatHitProvider; } diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index bacd93f585adc..4b3512ae3056b 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -84,6 +84,14 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is creating a saved object. | `failure` | User is not authorized to create a saved object. +.2+| `connector_create` +| `unknown` | User is creating a connector. +| `failure` | User is not authorized to create a connector. + +.2+| `alert_create` +| `unknown` | User is creating an alert rule. +| `failure` | User is not authorized to create an alert rule. + 3+a| ====== Type: change @@ -108,6 +116,42 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is removing references to a saved object. | `failure` | User is not authorized to remove references to a saved object. +.2+| `connector_update` +| `unknown` | User is updating a connector. +| `failure` | User is not authorized to update a connector. + +.2+| `alert_update` +| `unknown` | User is updating an alert rule. +| `failure` | User is not authorized to update an alert rule. + +.2+| `alert_update_api_key` +| `unknown` | User is updating the API key of an alert rule. +| `failure` | User is not authorized to update the API key of an alert rule. + +.2+| `alert_enable` +| `unknown` | User is enabling an alert rule. +| `failure` | User is not authorized to enable an alert rule. + +.2+| `alert_disable` +| `unknown` | User is disabling an alert rule. +| `failure` | User is not authorized to disable an alert rule. + +.2+| `alert_mute` +| `unknown` | User is muting an alert rule. +| `failure` | User is not authorized to mute an alert rule. + +.2+| `alert_unmute` +| `unknown` | User is unmuting an alert rule. +| `failure` | User is not authorized to unmute an alert rule. + +.2+| `alert_instance_mute` +| `unknown` | User is muting an alert instance. +| `failure` | User is not authorized to mute an alert instance. + +.2+| `alert_instance_unmute` +| `unknown` | User is unmuting an alert instance. +| `failure` | User is not authorized to unmute an alert instance. + 3+a| ====== Type: deletion @@ -120,6 +164,14 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is deleting a saved object. | `failure` | User is not authorized to delete a saved object. +.2+| `connector_delete` +| `unknown` | User is deleting a connector. +| `failure` | User is not authorized to delete a connector. + +.2+| `alert_delete` +| `unknown` | User is deleting an alert rule. +| `failure` | User is not authorized to delete an alert rule. + 3+a| ====== Type: access @@ -135,6 +187,22 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | User has accessed a saved object as part of a search operation. | `failure` | User is not authorized to search for saved objects. +.2+| `connector_get` +| `success` | User has accessed a connector. +| `failure` | User is not authorized to access a connector. + +.2+| `connector_find` +| `success` | User has accessed a connector as part of a search operation. +| `failure` | User is not authorized to search for connectors. + +.2+| `alert_get` +| `success` | User has accessed an alert rule. +| `failure` | User is not authorized to access an alert rule. + +.2+| `alert_find` +| `success` | User has accessed an alert rule as part of a search operation. +| `failure` | User is not authorized to search for alert rules. + 3+a| ===== Category: web diff --git a/package.json b/package.json index 97f888b19b3d8..93a72553b4551 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "number": 8467, "sha": "6cb7fec4e154faa0a4a3fee4b33dfef91b9870d9" }, + "config": { + "puppeteer_skip_chromium_download": true + }, "homepage": "https://www.elastic.co/products/kibana", "bugs": { "url": "http://github.com/elastic/kibana/issues" @@ -107,7 +110,7 @@ "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "7.10.0", "@elastic/ems-client": "7.11.0", - "@elastic/eui": "30.2.0", + "@elastic/eui": "30.5.1", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/node-crypto": "1.2.1", @@ -266,8 +269,7 @@ "proper-lockfile": "^3.2.0", "proxy-from-env": "1.0.0", "puid": "1.0.7", - "puppeteer": "^2.1.1", - "puppeteer-core": "^1.19.0", + "puppeteer": "^5.5.0", "query-string": "^6.13.2", "raw-loader": "^3.1.0", "re2": "^1.15.4", @@ -349,7 +351,7 @@ "@cypress/webpack-preprocessor": "^5.4.10", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "24.2.0", + "@elastic/charts": "24.3.0", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", @@ -515,7 +517,7 @@ "@types/pretty-ms": "^5.0.0", "@types/prop-types": "^15.7.3", "@types/proper-lockfile": "^3.0.1", - "@types/puppeteer": "^1.20.1", + "@types/puppeteer": "^5.4.1", "@types/rbush": "^3.0.0", "@types/reach__router": "^1.2.6", "@types/react": "^16.9.36", @@ -582,6 +584,7 @@ "apollo-link": "^1.2.3", "apollo-link-error": "^1.1.7", "apollo-link-state": "^0.4.1", + "argsplit": "^1.0.5", "autoprefixer": "^9.7.4", "axe-core": "^4.0.2", "babel-eslint": "^10.0.3", diff --git a/packages/kbn-config/src/__mocks__/env.ts b/packages/kbn-config/src/__mocks__/env.ts index 8b7475680ecf5..e03e88b1ded02 100644 --- a/packages/kbn-config/src/__mocks__/env.ts +++ b/packages/kbn-config/src/__mocks__/env.ts @@ -33,7 +33,6 @@ export function getEnvOptions(options: DeepPartial = {}): EnvOptions quiet: false, silent: false, watch: false, - repl: false, basePath: false, disableOptimizer: true, cache: true, diff --git a/packages/kbn-config/src/__snapshots__/env.test.ts.snap b/packages/kbn-config/src/__snapshots__/env.test.ts.snap index 9236c83f9c921..fae14529a4af3 100644 --- a/packages/kbn-config/src/__snapshots__/env.test.ts.snap +++ b/packages/kbn-config/src/__snapshots__/env.test.ts.snap @@ -12,7 +12,6 @@ Env { "envName": "development", "oss": false, "quiet": false, - "repl": false, "runExamples": false, "silent": false, "watch": false, @@ -57,7 +56,6 @@ Env { "envName": "production", "oss": false, "quiet": false, - "repl": false, "runExamples": false, "silent": false, "watch": false, @@ -101,7 +99,6 @@ Env { "dist": false, "oss": false, "quiet": false, - "repl": false, "runExamples": false, "silent": false, "watch": false, @@ -145,7 +142,6 @@ Env { "dist": false, "oss": false, "quiet": false, - "repl": false, "runExamples": false, "silent": false, "watch": false, @@ -189,7 +185,6 @@ Env { "dist": false, "oss": false, "quiet": false, - "repl": false, "runExamples": false, "silent": false, "watch": false, @@ -233,7 +228,6 @@ Env { "dist": false, "oss": false, "quiet": false, - "repl": false, "runExamples": false, "silent": false, "watch": false, diff --git a/packages/kbn-config/src/env.ts b/packages/kbn-config/src/env.ts index 4ae8d7b7f9aa5..3b50be4b54bcb 100644 --- a/packages/kbn-config/src/env.ts +++ b/packages/kbn-config/src/env.ts @@ -36,7 +36,6 @@ export interface CliArgs { quiet: boolean; silent: boolean; watch: boolean; - repl: boolean; basePath: boolean; oss: boolean; /** @deprecated use disableOptimizer to know if the @kbn/optimizer is disabled in development */ diff --git a/packages/kbn-dev-utils/src/precommit_hook/cli.ts b/packages/kbn-dev-utils/src/precommit_hook/cli.ts index 28347f379150f..81b253a6ceae1 100644 --- a/packages/kbn-dev-utils/src/precommit_hook/cli.ts +++ b/packages/kbn-dev-utils/src/precommit_hook/cli.ts @@ -23,8 +23,9 @@ import { promisify } from 'util'; import { REPO_ROOT } from '@kbn/utils'; import { run } from '../run'; +import { createFailError } from '../run'; import { SCRIPT_SOURCE } from './script_source'; -import { getGitDir } from './get_git_dir'; +import { getGitDir, isCorrectGitVersionInstalled } from './git_utils'; const chmodAsync = promisify(chmod); const writeFileAsync = promisify(writeFile); @@ -32,6 +33,12 @@ const writeFileAsync = promisify(writeFile); run( async ({ log }) => { try { + if (!(await isCorrectGitVersionInstalled())) { + throw createFailError( + `We could not detect a git version in the required range. Please install a git version >= 2.5. Skipping Kibana pre-commit git hook installation.` + ); + } + const gitDir = await getGitDir(); const installPath = Path.resolve(REPO_ROOT, gitDir, 'hooks/pre-commit'); diff --git a/packages/kbn-dev-utils/src/precommit_hook/get_git_dir.ts b/packages/kbn-dev-utils/src/precommit_hook/git_utils.ts similarity index 71% rename from packages/kbn-dev-utils/src/precommit_hook/get_git_dir.ts rename to packages/kbn-dev-utils/src/precommit_hook/git_utils.ts index f75c86f510095..739e4d89f9fb7 100644 --- a/packages/kbn-dev-utils/src/precommit_hook/get_git_dir.ts +++ b/packages/kbn-dev-utils/src/precommit_hook/git_utils.ts @@ -30,3 +30,20 @@ export async function getGitDir() { }) ).stdout.trim(); } + +// Checks if a correct git version is installed +export async function isCorrectGitVersionInstalled() { + const rawGitVersionStr = ( + await execa('git', ['--version'], { + cwd: REPO_ROOT, + }) + ).stdout.trim(); + + const match = rawGitVersionStr.match(/[0-9]+(\.[0-9]+)+/); + if (!match) { + return false; + } + + const [major, minor] = match[0].split('.').map((n) => parseInt(n, 10)); + return major > 2 || (major === 2 && minor >= 5); +} diff --git a/packages/kbn-dev-utils/src/tooling_log/__snapshots__/log_levels.test.ts.snap b/packages/kbn-dev-utils/src/tooling_log/__snapshots__/log_levels.test.ts.snap index 56ad7de858849..472fcb601118a 100644 --- a/packages/kbn-dev-utils/src/tooling_log/__snapshots__/log_levels.test.ts.snap +++ b/packages/kbn-dev-utils/src/tooling_log/__snapshots__/log_levels.test.ts.snap @@ -7,6 +7,7 @@ Object { "error": true, "info": true, "silent": true, + "success": true, "verbose": false, "warning": true, }, @@ -21,6 +22,7 @@ Object { "error": true, "info": false, "silent": true, + "success": false, "verbose": false, "warning": false, }, @@ -35,6 +37,7 @@ Object { "error": true, "info": true, "silent": true, + "success": true, "verbose": false, "warning": true, }, @@ -49,6 +52,7 @@ Object { "error": false, "info": false, "silent": true, + "success": false, "verbose": false, "warning": false, }, @@ -63,6 +67,7 @@ Object { "error": true, "info": true, "silent": true, + "success": true, "verbose": true, "warning": true, }, @@ -77,6 +82,7 @@ Object { "error": true, "info": false, "silent": true, + "success": false, "verbose": false, "warning": true, }, @@ -84,8 +90,8 @@ Object { } `; -exports[`throws error for invalid levels: bar 1`] = `"Invalid log level \\"bar\\" (expected one of silent,error,warning,info,debug,verbose)"`; +exports[`throws error for invalid levels: bar 1`] = `"Invalid log level \\"bar\\" (expected one of silent,error,warning,success,info,debug,verbose)"`; -exports[`throws error for invalid levels: foo 1`] = `"Invalid log level \\"foo\\" (expected one of silent,error,warning,info,debug,verbose)"`; +exports[`throws error for invalid levels: foo 1`] = `"Invalid log level \\"foo\\" (expected one of silent,error,warning,success,info,debug,verbose)"`; -exports[`throws error for invalid levels: warn 1`] = `"Invalid log level \\"warn\\" (expected one of silent,error,warning,info,debug,verbose)"`; +exports[`throws error for invalid levels: warn 1`] = `"Invalid log level \\"warn\\" (expected one of silent,error,warning,success,info,debug,verbose)"`; diff --git a/packages/kbn-dev-utils/src/tooling_log/__snapshots__/tooling_log_text_writer.test.ts.snap b/packages/kbn-dev-utils/src/tooling_log/__snapshots__/tooling_log_text_writer.test.ts.snap index f5d084da6a4e0..7ff982acafbe4 100644 --- a/packages/kbn-dev-utils/src/tooling_log/__snapshots__/tooling_log_text_writer.test.ts.snap +++ b/packages/kbn-dev-utils/src/tooling_log/__snapshots__/tooling_log_text_writer.test.ts.snap @@ -170,7 +170,7 @@ exports[`level:warning/type:warning snapshots: output 1`] = ` " `; -exports[`throws error if created with invalid level 1`] = `"Invalid log level \\"foo\\" (expected one of silent,error,warning,info,debug,verbose)"`; +exports[`throws error if created with invalid level 1`] = `"Invalid log level \\"foo\\" (expected one of silent,error,warning,success,info,debug,verbose)"`; exports[`throws error if writeTo config is not defined or doesn't have a write method 1`] = `"ToolingLogTextWriter requires the \`writeTo\` option be set to a stream (like process.stdout)"`; diff --git a/packages/kbn-dev-utils/src/tooling_log/log_levels.ts b/packages/kbn-dev-utils/src/tooling_log/log_levels.ts index 9e7d7ffe45134..679c2aba47855 100644 --- a/packages/kbn-dev-utils/src/tooling_log/log_levels.ts +++ b/packages/kbn-dev-utils/src/tooling_log/log_levels.ts @@ -17,8 +17,8 @@ * under the License. */ -export type LogLevel = 'silent' | 'error' | 'warning' | 'info' | 'debug' | 'verbose'; -const LEVELS: LogLevel[] = ['silent', 'error', 'warning', 'info', 'debug', 'verbose']; +const LEVELS = ['silent', 'error', 'warning', 'success', 'info', 'debug', 'verbose'] as const; +export type LogLevel = typeof LEVELS[number]; export function pickLevelFromFlags( flags: Record, diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index c62b3f2afc14d..eb9b7a4a35dc7 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -8814,7 +8814,7 @@ module.exports = (chalk, temporary) => { */ Object.defineProperty(exports, "__esModule", { value: true }); exports.parseLogLevel = exports.pickLevelFromFlags = void 0; -const LEVELS = ['silent', 'error', 'warning', 'info', 'debug', 'verbose']; +const LEVELS = ['silent', 'error', 'warning', 'success', 'info', 'debug', 'verbose']; function pickLevelFromFlags(flags, options = {}) { if (flags.verbose) return 'verbose'; diff --git a/renovate.json5 b/renovate.json5 index 84f8da2a72456..1585627daa880 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -17,7 +17,7 @@ 'Team:Operations', 'renovate', 'v8.0.0', - 'v7.10.0', + 'v7.11.0', ], major: { labels: [ @@ -25,7 +25,7 @@ 'Team:Operations', 'renovate', 'v8.0.0', - 'v7.10.0', + 'v7.11.0', 'renovate:major', ], }, diff --git a/src/cli/cli.js b/src/cli/cli.js index 50a8d4c7f7f01..2c222f4961859 100644 --- a/src/cli/cli.js +++ b/src/cli/cli.js @@ -22,9 +22,7 @@ import { pkg } from '../core/server/utils'; import Command from './command'; import serveCommand from './serve/serve'; -const argv = process.env.kbnWorkerArgv - ? JSON.parse(process.env.kbnWorkerArgv) - : process.argv.slice(); +const argv = process.argv.slice(); const program = new Command('bin/kibana'); program diff --git a/src/cli/cluster/cluster.mock.ts b/src/cli/cluster/cluster.mock.ts deleted file mode 100644 index 85d16a79a467c..0000000000000 --- a/src/cli/cluster/cluster.mock.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -/* eslint-env jest */ - -// eslint-disable-next-line max-classes-per-file -import { EventEmitter } from 'events'; -import { assign, random } from 'lodash'; -import { delay } from 'bluebird'; - -class MockClusterFork extends EventEmitter { - public exitCode = 0; - - constructor(cluster: MockCluster) { - super(); - - let dead = true; - - function wait() { - return delay(random(10, 250)); - } - - assign(this, { - process: { - kill: jest.fn(() => { - (async () => { - await wait(); - this.emit('disconnect'); - await wait(); - dead = true; - this.emit('exit'); - cluster.emit('exit', this, this.exitCode || 0); - })(); - }), - }, - isDead: jest.fn(() => dead), - send: jest.fn(), - }); - - jest.spyOn(this as EventEmitter, 'on'); - jest.spyOn(this as EventEmitter, 'off'); - jest.spyOn(this as EventEmitter, 'emit'); - - (async () => { - await wait(); - dead = false; - this.emit('online'); - })(); - } -} - -export class MockCluster extends EventEmitter { - fork = jest.fn(() => new MockClusterFork(this)); - setupMaster = jest.fn(); -} diff --git a/src/cli/cluster/cluster_manager.test.mocks.ts b/src/cli/cluster/cluster_manager.test.mocks.ts deleted file mode 100644 index 53984fd12cbf1..0000000000000 --- a/src/cli/cluster/cluster_manager.test.mocks.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { MockCluster } from './cluster.mock'; -export const mockCluster = new MockCluster(); -jest.mock('cluster', () => mockCluster); diff --git a/src/cli/cluster/cluster_manager.test.ts b/src/cli/cluster/cluster_manager.test.ts deleted file mode 100644 index 1d2986e742527..0000000000000 --- a/src/cli/cluster/cluster_manager.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as Rx from 'rxjs'; - -import { mockCluster } from './cluster_manager.test.mocks'; - -jest.mock('readline', () => ({ - createInterface: jest.fn(() => ({ - on: jest.fn(), - prompt: jest.fn(), - setPrompt: jest.fn(), - })), -})); - -const mockConfig: any = {}; - -import { sample } from 'lodash'; - -import { ClusterManager, SomeCliArgs } from './cluster_manager'; -import { Worker } from './worker'; - -const CLI_ARGS: SomeCliArgs = { - disableOptimizer: true, - oss: false, - quiet: false, - repl: false, - runExamples: false, - silent: false, - watch: false, - cache: false, - dist: false, -}; - -describe('CLI cluster manager', () => { - beforeEach(() => { - mockCluster.fork.mockImplementation(() => { - return { - process: { - kill: jest.fn(), - }, - isDead: jest.fn().mockReturnValue(false), - off: jest.fn(), - on: jest.fn(), - send: jest.fn(), - } as any; - }); - }); - - afterEach(() => { - mockCluster.fork.mockReset(); - }); - - test('has two workers', () => { - const manager = new ClusterManager(CLI_ARGS, mockConfig); - - expect(manager.workers).toHaveLength(1); - for (const worker of manager.workers) { - expect(worker).toBeInstanceOf(Worker); - } - - expect(manager.server).toBeInstanceOf(Worker); - }); - - test('delivers broadcast messages to other workers', () => { - const manager = new ClusterManager(CLI_ARGS, mockConfig); - - for (const worker of manager.workers) { - Worker.prototype.start.call(worker); // bypass the debounced start method - worker.onOnline(); - } - - const football = {}; - const messenger = sample(manager.workers) as any; - - messenger.emit('broadcast', football); - for (const worker of manager.workers) { - if (worker === messenger) { - expect(worker.fork!.send).not.toHaveBeenCalled(); - } else { - expect(worker.fork!.send).toHaveBeenCalledTimes(1); - expect(worker.fork!.send).toHaveBeenCalledWith(football); - } - } - }); - - describe('interaction with BasePathProxy', () => { - test('correctly configures `BasePathProxy`.', async () => { - const basePathProxyMock = { start: jest.fn() }; - - new ClusterManager(CLI_ARGS, mockConfig, basePathProxyMock as any); - - expect(basePathProxyMock.start).toHaveBeenCalledWith({ - shouldRedirectFromOldBasePath: expect.any(Function), - delayUntil: expect.any(Function), - }); - }); - - describe('basePathProxy config', () => { - let clusterManager: ClusterManager; - let shouldRedirectFromOldBasePath: (path: string) => boolean; - let delayUntil: () => Rx.Observable; - - beforeEach(async () => { - const basePathProxyMock = { start: jest.fn() }; - clusterManager = new ClusterManager(CLI_ARGS, mockConfig, basePathProxyMock as any); - [[{ delayUntil, shouldRedirectFromOldBasePath }]] = basePathProxyMock.start.mock.calls; - }); - - describe('shouldRedirectFromOldBasePath()', () => { - test('returns `false` for unknown paths.', () => { - expect(shouldRedirectFromOldBasePath('')).toBe(false); - expect(shouldRedirectFromOldBasePath('some-path/')).toBe(false); - expect(shouldRedirectFromOldBasePath('some-other-path')).toBe(false); - }); - - test('returns `true` for `app` and other known paths.', () => { - expect(shouldRedirectFromOldBasePath('app/')).toBe(true); - expect(shouldRedirectFromOldBasePath('login')).toBe(true); - expect(shouldRedirectFromOldBasePath('logout')).toBe(true); - expect(shouldRedirectFromOldBasePath('status')).toBe(true); - }); - }); - - describe('delayUntil()', () => { - test('returns an observable which emits when the server and kbnOptimizer are ready and completes', async () => { - clusterManager.serverReady$.next(false); - clusterManager.kbnOptimizerReady$.next(false); - - const events: Array = []; - delayUntil().subscribe( - () => events.push('next'), - (error) => events.push(error), - () => events.push('complete') - ); - - clusterManager.serverReady$.next(true); - expect(events).toEqual([]); - - clusterManager.kbnOptimizerReady$.next(true); - expect(events).toEqual(['next', 'complete']); - }); - }); - }); - }); -}); diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts deleted file mode 100644 index b0f7cded938dd..0000000000000 --- a/src/cli/cluster/cluster_manager.ts +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'path'; -import Fs from 'fs'; - -import { REPO_ROOT } from '@kbn/utils'; -import { FSWatcher } from 'chokidar'; -import * as Rx from 'rxjs'; -import { startWith, mapTo, filter, map, take, tap } from 'rxjs/operators'; - -import { runKbnOptimizer } from './run_kbn_optimizer'; -import { CliArgs } from '../../core/server/config'; -import { LegacyConfig } from '../../core/server/legacy'; -import { BasePathProxyServer } from '../../core/server/http'; - -import { Log } from './log'; -import { Worker } from './worker'; - -export type SomeCliArgs = Pick< - CliArgs, - | 'quiet' - | 'silent' - | 'repl' - | 'disableOptimizer' - | 'watch' - | 'oss' - | 'runExamples' - | 'cache' - | 'dist' ->; - -const firstAllTrue = (...sources: Array>) => - Rx.combineLatest(sources).pipe( - filter((values) => values.every((v) => v === true)), - take(1), - mapTo(undefined) - ); - -export class ClusterManager { - public server: Worker; - public workers: Worker[]; - - private watcher: FSWatcher | null = null; - private basePathProxy: BasePathProxyServer | undefined; - private log: Log; - private addedCount = 0; - private inReplMode: boolean; - - // exposed for testing - public readonly serverReady$ = new Rx.ReplaySubject(1); - // exposed for testing - public readonly kbnOptimizerReady$ = new Rx.ReplaySubject(1); - - constructor(opts: SomeCliArgs, config: LegacyConfig, basePathProxy?: BasePathProxyServer) { - this.log = new Log(opts.quiet, opts.silent); - this.inReplMode = !!opts.repl; - this.basePathProxy = basePathProxy; - - if (!this.basePathProxy) { - this.log.warn( - '====================================================================================================' - ); - this.log.warn( - 'no-base-path', - 'Running Kibana in dev mode with --no-base-path disables several useful features and is not recommended' - ); - this.log.warn( - '====================================================================================================' - ); - } - - // run @kbn/optimizer and write it's state to kbnOptimizerReady$ - if (opts.disableOptimizer) { - this.kbnOptimizerReady$.next(true); - } else { - runKbnOptimizer(opts, config) - .pipe( - map(({ state }) => state.phase === 'success' || state.phase === 'issue'), - tap({ - error: (error) => { - this.log.bad('@kbn/optimizer error', error.stack); - process.exit(1); - }, - }) - ) - .subscribe(this.kbnOptimizerReady$); - } - - const serverArgv = []; - - if (this.basePathProxy) { - serverArgv.push( - `--server.port=${this.basePathProxy.targetPort}`, - `--server.basePath=${this.basePathProxy.basePath}`, - '--server.rewriteBasePath=true' - ); - } - - this.workers = [ - (this.server = new Worker({ - type: 'server', - log: this.log, - argv: serverArgv, - apmServiceName: 'kibana', - })), - ]; - - // write server status to the serverReady$ subject - Rx.merge( - Rx.fromEvent(this.server, 'starting').pipe(mapTo(false)), - Rx.fromEvent(this.server, 'listening').pipe(mapTo(true)), - Rx.fromEvent(this.server, 'crashed').pipe(mapTo(true)) - ) - .pipe(startWith(this.server.listening || this.server.crashed)) - .subscribe(this.serverReady$); - - // broker messages between workers - this.workers.forEach((worker) => { - worker.on('broadcast', (msg) => { - this.workers.forEach((to) => { - if (to !== worker && to.online) { - to.fork!.send(msg); - } - }); - }); - }); - - // When receive that event from server worker - // forward a reloadLoggingConfig message to master - // and all workers. This is only used by LogRotator service - // when the cluster mode is enabled - this.server.on('reloadLoggingConfigFromServerWorker', () => { - process.emit('message' as any, { reloadLoggingConfig: true } as any); - - this.workers.forEach((worker) => { - worker.fork!.send({ reloadLoggingConfig: true }); - }); - }); - - if (opts.watch) { - const pluginPaths = config.get('plugins.paths'); - const scanDirs = [ - ...config.get('plugins.scanDirs'), - resolve(REPO_ROOT, 'src/plugins'), - resolve(REPO_ROOT, 'x-pack/plugins'), - ]; - const extraPaths = [...pluginPaths, ...scanDirs]; - - const pluginInternalDirsIgnore = scanDirs - .map((scanDir) => resolve(scanDir, '*')) - .concat(pluginPaths) - .reduce( - (acc, path) => - acc.concat( - resolve(path, 'test/**'), - resolve(path, 'build/**'), - resolve(path, 'target/**'), - resolve(path, 'scripts/**'), - resolve(path, 'docs/**') - ), - [] as string[] - ); - - this.setupWatching(extraPaths, pluginInternalDirsIgnore); - } else this.startCluster(); - } - - startCluster() { - this.setupManualRestart(); - for (const worker of this.workers) { - worker.start(); - } - if (this.basePathProxy) { - this.basePathProxy.start({ - delayUntil: () => firstAllTrue(this.serverReady$, this.kbnOptimizerReady$), - - shouldRedirectFromOldBasePath: (path: string) => { - // strip `s/{id}` prefix when checking for need to redirect - if (path.startsWith('s/')) { - path = path.split('/').slice(2).join('/'); - } - - const isApp = path.startsWith('app/'); - const isKnownShortPath = ['login', 'logout', 'status'].includes(path); - return isApp || isKnownShortPath; - }, - }); - } - } - - setupWatching(extraPaths: string[], pluginInternalDirsIgnore: string[]) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const chokidar = require('chokidar'); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { fromRoot } = require('../../core/server/utils'); - - const watchPaths = Array.from( - new Set( - [ - fromRoot('src/core'), - fromRoot('src/legacy/server'), - fromRoot('src/legacy/ui'), - fromRoot('src/legacy/utils'), - fromRoot('config'), - ...extraPaths, - ].map((path) => resolve(path)) - ) - ); - - for (const watchPath of watchPaths) { - if (!Fs.existsSync(fromRoot(watchPath))) { - throw new Error( - `A watch directory [${watchPath}] does not exist, which will cause chokidar to fail. Either make sure the directory exists or remove it as a watch source in the ClusterManger` - ); - } - } - - const ignorePaths = [ - /[\\\/](\..*|node_modules|bower_components|target|public|__[a-z0-9_]+__|coverage)([\\\/]|$)/, - /\.test\.(js|tsx?)$/, - /\.md$/, - /debug\.log$/, - ...pluginInternalDirsIgnore, - fromRoot('x-pack/plugins/reporting/chromium'), - fromRoot('x-pack/plugins/security_solution/cypress'), - fromRoot('x-pack/plugins/apm/e2e'), - fromRoot('x-pack/plugins/apm/scripts'), - fromRoot('x-pack/plugins/canvas/canvas_plugin_src'), // prevents server from restarting twice for Canvas plugin changes, - fromRoot('x-pack/plugins/case/server/scripts'), - fromRoot('x-pack/plugins/lists/scripts'), - fromRoot('x-pack/plugins/lists/server/scripts'), - fromRoot('x-pack/plugins/security_solution/scripts'), - fromRoot('x-pack/plugins/security_solution/server/lib/detection_engine/scripts'), - ]; - - this.watcher = chokidar.watch(watchPaths, { - cwd: fromRoot('.'), - ignored: ignorePaths, - }) as FSWatcher; - - this.watcher.on('add', this.onWatcherAdd); - this.watcher.on('error', this.onWatcherError); - this.watcher.once('ready', () => { - // start sending changes to workers - this.watcher!.removeListener('add', this.onWatcherAdd); - this.watcher!.on('all', this.onWatcherChange); - - this.log.good('watching for changes', `(${this.addedCount} files)`); - this.startCluster(); - }); - } - - setupManualRestart() { - // If we're in REPL mode, the user can use the REPL to manually restart. - // The setupManualRestart method interferes with stdin/stdout, in a way - // that negatively affects the REPL. - if (this.inReplMode) { - return; - } - // eslint-disable-next-line @typescript-eslint/no-var-requires - const readline = require('readline'); - const rl = readline.createInterface(process.stdin, process.stdout); - - let nls = 0; - const clear = () => (nls = 0); - - let clearTimer: number | undefined; - const clearSoon = () => { - clearSoon.cancel(); - clearTimer = setTimeout(() => { - clearTimer = undefined; - clear(); - }); - }; - - clearSoon.cancel = () => { - clearTimeout(clearTimer); - clearTimer = undefined; - }; - - rl.setPrompt(''); - rl.prompt(); - - rl.on('line', () => { - nls = nls + 1; - - if (nls >= 2) { - clearSoon.cancel(); - clear(); - this.server.start(); - } else { - clearSoon(); - } - - rl.prompt(); - }); - - rl.on('SIGINT', () => { - rl.pause(); - process.kill(process.pid, 'SIGINT'); - }); - } - - onWatcherAdd = () => { - this.addedCount += 1; - }; - - onWatcherChange = (e: any, path: string) => { - for (const worker of this.workers) { - worker.onChange(path); - } - }; - - onWatcherError = (err: any) => { - this.log.bad('failed to watch files!\n', err.stack); - process.exit(1); - }; -} diff --git a/src/cli/cluster/run_kbn_optimizer.ts b/src/cli/cluster/run_kbn_optimizer.ts deleted file mode 100644 index 8196cad4a99c7..0000000000000 --- a/src/cli/cluster/run_kbn_optimizer.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Chalk from 'chalk'; -import moment from 'moment'; -import { REPO_ROOT } from '@kbn/utils'; -import { - ToolingLog, - pickLevelFromFlags, - ToolingLogTextWriter, - parseLogLevel, -} from '@kbn/dev-utils'; -import { runOptimizer, OptimizerConfig, logOptimizerState } from '@kbn/optimizer'; - -import { CliArgs } from '../../core/server/config'; -import { LegacyConfig } from '../../core/server/legacy'; - -type SomeCliArgs = Pick; - -export function runKbnOptimizer(opts: SomeCliArgs, config: LegacyConfig) { - const optimizerConfig = OptimizerConfig.create({ - repoRoot: REPO_ROOT, - watch: !!opts.watch, - includeCoreBundle: true, - cache: !!opts.cache, - dist: !!opts.dist, - oss: !!opts.oss, - examples: !!opts.runExamples, - pluginPaths: config.get('plugins.paths'), - }); - - const dim = Chalk.dim('np bld'); - const name = Chalk.magentaBright('@kbn/optimizer'); - const time = () => moment().format('HH:mm:ss.SSS'); - const level = (msgType: string) => { - switch (msgType) { - case 'info': - return Chalk.green(msgType); - case 'success': - return Chalk.cyan(msgType); - case 'debug': - return Chalk.gray(msgType); - default: - return msgType; - } - }; - const { flags: levelFlags } = parseLogLevel(pickLevelFromFlags(opts)); - const toolingLog = new ToolingLog(); - const has = (obj: T, x: any): x is keyof T => obj.hasOwnProperty(x); - - toolingLog.setWriters([ - { - write(msg) { - if (has(levelFlags, msg.type) && !levelFlags[msg.type]) { - return false; - } - - ToolingLogTextWriter.write( - process.stdout, - `${dim} log [${time()}] [${level(msg.type)}][${name}] `, - msg - ); - return true; - }, - }, - ]); - - return runOptimizer(optimizerConfig).pipe(logOptimizerState(toolingLog, optimizerConfig)); -} diff --git a/src/cli/cluster/worker.test.ts b/src/cli/cluster/worker.test.ts deleted file mode 100644 index e775f71442a77..0000000000000 --- a/src/cli/cluster/worker.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { mockCluster } from './cluster_manager.test.mocks'; - -import { Worker, ClusterWorker } from './worker'; - -import { Log } from './log'; - -const workersToShutdown: Worker[] = []; - -function assertListenerAdded(emitter: NodeJS.EventEmitter, event: any) { - expect(emitter.on).toHaveBeenCalledWith(event, expect.any(Function)); -} - -function assertListenerRemoved(emitter: NodeJS.EventEmitter, event: any) { - const [, onEventListener] = (emitter.on as jest.Mock).mock.calls.find(([eventName]) => { - return eventName === event; - }); - - expect(emitter.off).toHaveBeenCalledWith(event, onEventListener); -} - -function setup(opts = {}) { - const worker = new Worker({ - log: new Log(false, true), - ...opts, - baseArgv: [], - type: 'test', - }); - - workersToShutdown.push(worker); - return worker; -} - -describe('CLI cluster manager', () => { - afterEach(async () => { - while (workersToShutdown.length > 0) { - const worker = workersToShutdown.pop() as Worker; - // If `fork` exists we should set `exitCode` to the non-zero value to - // prevent worker from auto restart. - if (worker.fork) { - worker.fork.exitCode = 1; - } - - await worker.shutdown(); - } - - mockCluster.fork.mockClear(); - }); - - describe('#onChange', () => { - describe('opts.watch = true', () => { - test('restarts the fork', () => { - const worker = setup({ watch: true }); - jest.spyOn(worker, 'start').mockResolvedValue(); - worker.onChange('/some/path'); - expect(worker.changes).toEqual(['/some/path']); - expect(worker.start).toHaveBeenCalledTimes(1); - }); - }); - - describe('opts.watch = false', () => { - test('does not restart the fork', () => { - const worker = setup({ watch: false }); - jest.spyOn(worker, 'start').mockResolvedValue(); - worker.onChange('/some/path'); - expect(worker.changes).toEqual([]); - expect(worker.start).not.toHaveBeenCalled(); - }); - }); - }); - - describe('#shutdown', () => { - describe('after starting()', () => { - test('kills the worker and unbinds from message, online, and disconnect events', async () => { - const worker = setup(); - await worker.start(); - expect(worker).toHaveProperty('online', true); - const fork = worker.fork as ClusterWorker; - expect(fork!.process.kill).not.toHaveBeenCalled(); - assertListenerAdded(fork, 'message'); - assertListenerAdded(fork, 'online'); - assertListenerAdded(fork, 'disconnect'); - await worker.shutdown(); - expect(fork!.process.kill).toHaveBeenCalledTimes(1); - assertListenerRemoved(fork, 'message'); - assertListenerRemoved(fork, 'online'); - assertListenerRemoved(fork, 'disconnect'); - }); - }); - - describe('before being started', () => { - test('does nothing', () => { - const worker = setup(); - worker.shutdown(); - }); - }); - }); - - describe('#parseIncomingMessage()', () => { - describe('on a started worker', () => { - test(`is bound to fork's message event`, async () => { - const worker = setup(); - await worker.start(); - expect(worker.fork!.on).toHaveBeenCalledWith('message', expect.any(Function)); - }); - }); - - describe('do after', () => { - test('ignores non-array messages', () => { - const worker = setup(); - worker.parseIncomingMessage('some string thing'); - worker.parseIncomingMessage(0); - worker.parseIncomingMessage(null); - worker.parseIncomingMessage(undefined); - worker.parseIncomingMessage({ like: 'an object' }); - worker.parseIncomingMessage(/weird/); - }); - - test('calls #onMessage with message parts', () => { - const worker = setup(); - jest.spyOn(worker, 'onMessage').mockImplementation(() => {}); - worker.parseIncomingMessage(['event', 'some-data']); - expect(worker.onMessage).toHaveBeenCalledWith('event', 'some-data'); - }); - }); - }); - - describe('#onMessage', () => { - describe('when sent WORKER_BROADCAST message', () => { - test('emits the data to be broadcasted', () => { - const worker = setup(); - const data = {}; - jest.spyOn(worker, 'emit').mockImplementation(() => true); - worker.onMessage('WORKER_BROADCAST', data); - expect(worker.emit).toHaveBeenCalledWith('broadcast', data); - }); - }); - - describe('when sent WORKER_LISTENING message', () => { - test('sets the listening flag and emits the listening event', () => { - const worker = setup(); - jest.spyOn(worker, 'emit').mockImplementation(() => true); - expect(worker).toHaveProperty('listening', false); - worker.onMessage('WORKER_LISTENING'); - expect(worker).toHaveProperty('listening', true); - expect(worker.emit).toHaveBeenCalledWith('listening'); - }); - }); - - describe('when passed an unknown message', () => { - test('does nothing', () => { - const worker = setup(); - worker.onMessage('asdlfkajsdfahsdfiohuasdofihsdoif'); - }); - }); - }); - - describe('#start', () => { - describe('when not started', () => { - test('creates a fork and waits for it to come online', async () => { - const worker = setup(); - - jest.spyOn(worker, 'on'); - - await worker.start(); - - expect(mockCluster.fork).toHaveBeenCalledTimes(1); - expect(worker.on).toHaveBeenCalledWith('fork:online', expect.any(Function)); - }); - - test('listens for cluster and process "exit" events', async () => { - const worker = setup(); - - jest.spyOn(process, 'on'); - jest.spyOn(mockCluster, 'on'); - - await worker.start(); - - expect(mockCluster.on).toHaveBeenCalledTimes(1); - expect(mockCluster.on).toHaveBeenCalledWith('exit', expect.any(Function)); - expect(process.on).toHaveBeenCalledTimes(1); - expect(process.on).toHaveBeenCalledWith('exit', expect.any(Function)); - }); - }); - - describe('when already started', () => { - test('calls shutdown and waits for the graceful shutdown to cause a restart', async () => { - const worker = setup(); - await worker.start(); - - jest.spyOn(worker, 'shutdown'); - jest.spyOn(worker, 'on'); - - worker.start(); - - expect(worker.shutdown).toHaveBeenCalledTimes(1); - expect(worker.on).toHaveBeenCalledWith('online', expect.any(Function)); - }); - }); - }); -}); diff --git a/src/cli/cluster/worker.ts b/src/cli/cluster/worker.ts deleted file mode 100644 index 26b2a643e5373..0000000000000 --- a/src/cli/cluster/worker.ts +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import cluster from 'cluster'; -import { EventEmitter } from 'events'; - -import { BinderFor } from './binder_for'; -import { fromRoot } from '../../core/server/utils'; - -const cliPath = fromRoot('src/cli/dev'); -const baseArgs = _.difference(process.argv.slice(2), ['--no-watch']); -const baseArgv = [process.execPath, cliPath].concat(baseArgs); - -export type ClusterWorker = cluster.Worker & { - killed: boolean; - exitCode?: number; -}; - -cluster.setupMaster({ - exec: cliPath, - silent: false, -}); - -const dead = (fork: ClusterWorker) => { - return fork.isDead() || fork.killed; -}; - -interface WorkerOptions { - type: string; - log: any; // src/cli/log.js - argv?: string[]; - title?: string; - watch?: boolean; - baseArgv?: string[]; - apmServiceName?: string; -} - -export class Worker extends EventEmitter { - private readonly clusterBinder: BinderFor; - private readonly processBinder: BinderFor; - - private title: string; - private log: any; - private forkBinder: BinderFor | null = null; - private startCount: number; - private watch: boolean; - private env: Record; - - public fork: ClusterWorker | null = null; - public changes: string[]; - - // status flags - public online = false; // the fork can accept messages - public listening = false; // the fork is listening for connections - public crashed = false; // the fork crashed - - constructor(opts: WorkerOptions) { - super(); - - this.log = opts.log; - this.title = opts.title || opts.type; - this.watch = opts.watch !== false; - this.startCount = 0; - - this.changes = []; - - this.clusterBinder = new BinderFor(cluster as any); // lack the 'off' method - this.processBinder = new BinderFor(process); - - this.env = { - NODE_OPTIONS: process.env.NODE_OPTIONS || '', - isDevCliChild: 'true', - kbnWorkerArgv: JSON.stringify([...(opts.baseArgv || baseArgv), ...(opts.argv || [])]), - ELASTIC_APM_SERVICE_NAME: opts.apmServiceName || '', - }; - } - - onExit(fork: ClusterWorker, code: number) { - if (this.fork !== fork) return; - - // we have our fork's exit, so stop listening for others - this.clusterBinder.destroy(); - - // our fork is gone, clear our ref so we don't try to talk to it anymore - this.fork = null; - this.forkBinder = null; - - this.online = false; - this.listening = false; - this.emit('fork:exit'); - this.crashed = code > 0; - - if (this.crashed) { - this.emit('crashed'); - this.log.bad(`${this.title} crashed`, 'with status code', code); - if (!this.watch) process.exit(code); - } else { - // restart after graceful shutdowns - this.start(); - } - } - - onChange(path: string) { - if (!this.watch) return; - this.changes.push(path); - this.start(); - } - - async shutdown() { - if (this.fork && !dead(this.fork)) { - // kill the fork - this.fork.process.kill(); - this.fork.killed = true; - - // stop listening to the fork, it's just going to die - this.forkBinder!.destroy(); - - // we don't need to react to process.exit anymore - this.processBinder.destroy(); - - // wait until the cluster reports this fork has exited, then resolve - await new Promise((resolve) => this.once('fork:exit', resolve)); - } - } - - parseIncomingMessage(msg: any) { - if (!Array.isArray(msg)) { - return; - } - this.onMessage(msg[0], msg[1]); - } - - onMessage(type: string, data?: any) { - switch (type) { - case 'WORKER_BROADCAST': - this.emit('broadcast', data); - break; - case 'OPTIMIZE_STATUS': - this.emit('optimizeStatus', data); - break; - case 'WORKER_LISTENING': - this.listening = true; - this.emit('listening'); - break; - case 'RELOAD_LOGGING_CONFIG_FROM_SERVER_WORKER': - this.emit('reloadLoggingConfigFromServerWorker'); - break; - } - } - - onOnline() { - this.online = true; - this.emit('fork:online'); - this.crashed = false; - } - - onDisconnect() { - this.online = false; - this.listening = false; - } - - flushChangeBuffer() { - const files = _.uniq(this.changes.splice(0)); - const prefix = files.length > 1 ? '\n - ' : ''; - return files.reduce(function (list, file) { - return `${list || ''}${prefix}"${file}"`; - }, ''); - } - - async start() { - if (this.fork) { - // once "exit" event is received with 0 status, start() is called again - this.shutdown(); - await new Promise((cb) => this.once('online', cb)); - return; - } - - if (this.changes.length) { - this.log.warn(`restarting ${this.title}`, `due to changes in ${this.flushChangeBuffer()}`); - } else if (this.startCount++) { - this.log.warn(`restarting ${this.title}...`); - } - - this.fork = cluster.fork(this.env) as ClusterWorker; - this.emit('starting'); - this.forkBinder = new BinderFor(this.fork); - - // when the fork sends a message, comes online, or loses its connection, then react - this.forkBinder.on('message', (msg: any) => this.parseIncomingMessage(msg)); - this.forkBinder.on('online', () => this.onOnline()); - this.forkBinder.on('disconnect', () => this.onDisconnect()); - - // when the cluster says a fork has exited, check if it is ours - this.clusterBinder.on('exit', (fork: ClusterWorker, code: number) => this.onExit(fork, code)); - - // when the process exits, make sure we kill our workers - this.processBinder.on('exit', () => this.shutdown()); - - // wait for the fork to report it is online before resolving - await new Promise((cb) => this.once('fork:online', cb)); - } -} diff --git a/src/cli/repl/__snapshots__/repl.test.js.snap b/src/cli/repl/__snapshots__/repl.test.js.snap deleted file mode 100644 index 804898284491d..0000000000000 --- a/src/cli/repl/__snapshots__/repl.test.js.snap +++ /dev/null @@ -1,113 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`repl it allows print depth to be specified 1`] = ` -" { - '0': { '1': { '2': [Object] } }, - whoops: [Circular *1] -}" -`; - -exports[`repl it colorizes raw values 1`] = `"{ meaning: 42 }"`; - -exports[`repl it handles deep and recursive objects 1`] = ` -" { - '0': { - '1': { - '2': { '3': { '4': { '5': [Object] } } } - } - }, - whoops: [Circular *1] -}" -`; - -exports[`repl it handles undefined 1`] = `"undefined"`; - -exports[`repl it prints promise rejects 1`] = ` -Array [ - Array [ - "Waiting for promise...", - ], - Array [ - "Promise Rejected: -", - "'Dang, diggity!'", - ], -] -`; - -exports[`repl it prints promise resolves 1`] = ` -Array [ - Array [ - "Waiting for promise...", - ], - Array [ - "Promise Resolved: -", - "[ 1, 2, 3 ]", - ], -] -`; - -exports[`repl promises rejects only write to a specific depth 1`] = ` -Array [ - Array [ - "Waiting for promise...", - ], - Array [ - "Promise Rejected: -", - " { - '0': { - '1': { - '2': { '3': { '4': { '5': [Object] } } } - } - }, - whoops: [Circular *1] -}", - ], -] -`; - -exports[`repl promises resolves only write to a specific depth 1`] = ` -Array [ - Array [ - "Waiting for promise...", - ], - Array [ - "Promise Resolved: -", - " { - '0': { - '1': { - '2': { '3': { '4': { '5': [Object] } } } - } - }, - whoops: [Circular *1] -}", - ], -] -`; - -exports[`repl repl exposes a print object that lets you tailor depth 1`] = ` -Array [ - Array [ - "{ hello: { world: [Object] } }", - ], -] -`; - -exports[`repl repl exposes a print object that prints promises 1`] = ` -Array [ - Array [ - "", - ], - Array [ - "Waiting for promise...", - ], - Array [ - "Promise Resolved: -", - "{ hello: { world: [Object] } }", - ], -] -`; diff --git a/src/cli/repl/index.js b/src/cli/repl/index.js deleted file mode 100644 index 0b27fafcef84e..0000000000000 --- a/src/cli/repl/index.js +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import repl from 'repl'; -import util from 'util'; - -const PRINT_DEPTH = 5; - -/** - * Starts an interactive REPL with a global `server` object. - * - * @param {KibanaServer} kbnServer - */ -export function startRepl(kbnServer) { - const replServer = repl.start({ - prompt: 'Kibana> ', - useColors: true, - writer: promiseFriendlyWriter({ - displayPrompt: () => replServer.displayPrompt(), - getPrintDepth: () => replServer.context.repl.printDepth, - }), - }); - - const initializeContext = () => { - replServer.context.kbnServer = kbnServer; - replServer.context.server = kbnServer.server; - replServer.context.repl = { - printDepth: PRINT_DEPTH, - print(obj, depth = null) { - console.log( - promisePrint( - obj, - () => replServer.displayPrompt(), - () => depth - ) - ); - return ''; - }, - }; - }; - - initializeContext(); - replServer.on('reset', initializeContext); - - return replServer; -} - -function colorize(o, depth) { - return util.inspect(o, { colors: true, depth }); -} - -function prettyPrint(text, o, depth) { - console.log(text, colorize(o, depth)); -} - -// This lets us handle promises more gracefully than the default REPL, -// which doesn't show the results. -function promiseFriendlyWriter({ displayPrompt, getPrintDepth }) { - return (result) => promisePrint(result, displayPrompt, getPrintDepth); -} - -function promisePrint(result, displayPrompt, getPrintDepth) { - const depth = getPrintDepth(); - if (result && typeof result.then === 'function') { - // Bit of a hack to encourage the user to wait for the result of a promise - // by printing text out beside the current prompt. - Promise.resolve() - .then(() => console.log('Waiting for promise...')) - .then(() => result) - .then((o) => prettyPrint('Promise Resolved: \n', o, depth)) - .catch((err) => prettyPrint('Promise Rejected: \n', err, depth)) - .then(displayPrompt); - return ''; - } - return colorize(result, depth); -} diff --git a/src/cli/repl/repl.test.js b/src/cli/repl/repl.test.js deleted file mode 100644 index 3a032d415e5f2..0000000000000 --- a/src/cli/repl/repl.test.js +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock('repl', () => ({ start: (opts) => ({ opts, context: {} }) }), { virtual: true }); - -describe('repl', () => { - const originalConsoleLog = console.log; - let mockRepl; - - beforeEach(() => { - global.console.log = jest.fn(); - require('repl').start = (opts) => { - let resetHandler; - const replServer = { - opts, - context: {}, - on: jest.fn((eventName, handler) => { - expect(eventName).toBe('reset'); - resetHandler = handler; - }), - }; - - mockRepl = { - replServer, - clear() { - replServer.context = {}; - resetHandler(replServer.context); - }, - }; - return replServer; - }; - }); - - afterEach(() => { - global.console.log = originalConsoleLog; - }); - - test('it exposes the server object', () => { - const { startRepl } = require('.'); - const testServer = { - server: {}, - }; - const replServer = startRepl(testServer); - expect(replServer.context.server).toBe(testServer.server); - expect(replServer.context.kbnServer).toBe(testServer); - }); - - test('it prompts with Kibana>', () => { - const { startRepl } = require('.'); - expect(startRepl({}).opts.prompt).toBe('Kibana> '); - }); - - test('it colorizes raw values', () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - expect(replServer.opts.writer({ meaning: 42 })).toMatchSnapshot(); - }); - - test('it handles undefined', () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - expect(replServer.opts.writer()).toMatchSnapshot(); - }); - - test('it handles deep and recursive objects', () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - const splosion = {}; - let child = splosion; - for (let i = 0; i < 2000; ++i) { - child[i] = {}; - child = child[i]; - } - splosion.whoops = splosion; - expect(replServer.opts.writer(splosion)).toMatchSnapshot(); - }); - - test('it allows print depth to be specified', () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - const splosion = {}; - let child = splosion; - for (let i = 0; i < 2000; ++i) { - child[i] = {}; - child = child[i]; - } - splosion.whoops = splosion; - replServer.context.repl.printDepth = 2; - expect(replServer.opts.writer(splosion)).toMatchSnapshot(); - }); - - test('resets context to original when reset', () => { - const { startRepl } = require('.'); - const testServer = { - server: {}, - }; - const replServer = startRepl(testServer); - replServer.context.foo = 'bar'; - expect(replServer.context.server).toBe(testServer.server); - expect(replServer.context.kbnServer).toBe(testServer); - expect(replServer.context.foo).toBe('bar'); - mockRepl.clear(); - expect(replServer.context.server).toBe(testServer.server); - expect(replServer.context.kbnServer).toBe(testServer); - expect(replServer.context.foo).toBeUndefined(); - }); - - test('it prints promise resolves', async () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - const calls = await waitForPrompt(replServer, () => - replServer.opts.writer(Promise.resolve([1, 2, 3])) - ); - expect(calls).toMatchSnapshot(); - }); - - test('it prints promise rejects', async () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - const calls = await waitForPrompt(replServer, () => - replServer.opts.writer(Promise.reject('Dang, diggity!')) - ); - expect(calls).toMatchSnapshot(); - }); - - test('promises resolves only write to a specific depth', async () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - const splosion = {}; - let child = splosion; - for (let i = 0; i < 2000; ++i) { - child[i] = {}; - child = child[i]; - } - splosion.whoops = splosion; - const calls = await waitForPrompt(replServer, () => - replServer.opts.writer(Promise.resolve(splosion)) - ); - expect(calls).toMatchSnapshot(); - }); - - test('promises rejects only write to a specific depth', async () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - const splosion = {}; - let child = splosion; - for (let i = 0; i < 2000; ++i) { - child[i] = {}; - child = child[i]; - } - splosion.whoops = splosion; - const calls = await waitForPrompt(replServer, () => - replServer.opts.writer(Promise.reject(splosion)) - ); - expect(calls).toMatchSnapshot(); - }); - - test('repl exposes a print object that lets you tailor depth', () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - replServer.context.repl.print({ hello: { world: { nstuff: 'yo' } } }, 1); - expect(global.console.log.mock.calls).toMatchSnapshot(); - }); - - test('repl exposes a print object that prints promises', async () => { - const { startRepl } = require('.'); - const replServer = startRepl({}); - const promise = Promise.resolve({ hello: { world: { nstuff: 'yo' } } }); - const calls = await waitForPrompt(replServer, () => replServer.context.repl.print(promise, 1)); - expect(calls).toMatchSnapshot(); - }); - - async function waitForPrompt(replServer, fn) { - let resolveDone; - const done = new Promise((resolve) => (resolveDone = resolve)); - replServer.displayPrompt = () => { - resolveDone(); - }; - fn(); - await done; - return global.console.log.mock.calls; - } -}); diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 61f880d80633d..a070ba09207ad 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -42,11 +42,8 @@ function canRequire(path) { } } -const CLUSTER_MANAGER_PATH = resolve(__dirname, '../cluster/cluster_manager'); -const DEV_MODE_SUPPORTED = canRequire(CLUSTER_MANAGER_PATH); - -const REPL_PATH = resolve(__dirname, '../repl'); -const CAN_REPL = canRequire(REPL_PATH); +const DEV_MODE_PATH = resolve(__dirname, '../../dev/cli_dev_mode'); +const DEV_MODE_SUPPORTED = canRequire(DEV_MODE_PATH); const pathCollector = function () { const paths = []; @@ -176,10 +173,6 @@ export default function (program) { .option('--plugins ', 'an alias for --plugin-dir', pluginDirCollector) .option('--optimize', 'Deprecated, running the optimizer is no longer required'); - if (CAN_REPL) { - command.option('--repl', 'Run the server with a REPL prompt and access to the server object'); - } - if (!IS_KIBANA_DISTRIBUTABLE) { command .option('--oss', 'Start Kibana without X-Pack') @@ -225,7 +218,6 @@ export default function (program) { quiet: !!opts.quiet, silent: !!opts.silent, watch: !!opts.watch, - repl: !!opts.repl, runExamples: !!opts.runExamples, // We want to run without base path when the `--run-examples` flag is given so that we can use local // links in other documentation sources, like "View this tutorial [here](http://localhost:5601/app/tutorial/xyz)". @@ -241,7 +233,6 @@ export default function (program) { }, features: { isCliDevModeSupported: DEV_MODE_SUPPORTED, - isReplModeSupported: CAN_REPL, }, applyConfigOverrides: (rawConfig) => applyConfigOverrides(rawConfig, opts, unknownOptions), }); diff --git a/src/cli_encryption_keys/cli_encryption_keys.js b/src/cli_encryption_keys/cli_encryption_keys.js index 30114f533aa30..935bf09d93a04 100644 --- a/src/cli_encryption_keys/cli_encryption_keys.js +++ b/src/cli_encryption_keys/cli_encryption_keys.js @@ -23,9 +23,7 @@ import { EncryptionConfig } from './encryption_config'; import { generateCli } from './generate'; -const argv = process.env.kbnWorkerArgv - ? JSON.parse(process.env.kbnWorkerArgv) - : process.argv.slice(); +const argv = process.argv.slice(); const program = new Command('bin/kibana-encryption-keys'); program.version(pkg.version).description('A tool for managing encryption keys'); diff --git a/src/cli_keystore/cli_keystore.js b/src/cli_keystore/cli_keystore.js index 9fbea8f195122..d2a72a896c2d9 100644 --- a/src/cli_keystore/cli_keystore.js +++ b/src/cli_keystore/cli_keystore.js @@ -29,9 +29,7 @@ import { addCli } from './add'; import { removeCli } from './remove'; import { getKeystore } from './get_keystore'; -const argv = process.env.kbnWorkerArgv - ? JSON.parse(process.env.kbnWorkerArgv) - : process.argv.slice(); +const argv = process.argv.slice(); const program = new Command('bin/kibana-keystore'); program diff --git a/src/cli_plugin/cli.js b/src/cli_plugin/cli.js index e483385b5b9e8..d2ee99d380827 100644 --- a/src/cli_plugin/cli.js +++ b/src/cli_plugin/cli.js @@ -23,9 +23,7 @@ import { listCommand } from './list'; import { installCommand } from './install'; import { removeCommand } from './remove'; -const argv = process.env.kbnWorkerArgv - ? JSON.parse(process.env.kbnWorkerArgv) - : process.argv.slice(); +const argv = process.argv.slice(); const program = new Command('bin/kibana-plugin'); program diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index ee2fcbd5078af..16f48836cab54 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -4753,12 +4753,11 @@ exports[`Header renders 1`] = ` hasArrow={true} id="headerHelpMenu" isOpen={false} - ownFocus={true} + ownFocus={false} panelPaddingSize="m" repositionOnScroll={true} >
{ data-test-subj="helpMenuButton" id="headerHelpMenu" isOpen={this.state.isOpen} - ownFocus repositionOnScroll > diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index 444430175d4f2..85b31d48bd39e 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -19,7 +19,7 @@ /* eslint-disable max-classes-per-file */ -import { EuiFlyout } from '@elastic/eui'; +import { EuiFlyout, EuiFlyoutSize } from '@elastic/eui'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; @@ -93,6 +93,8 @@ export interface OverlayFlyoutOpenOptions { closeButtonAriaLabel?: string; ownFocus?: boolean; 'data-test-subj'?: string; + size?: EuiFlyoutSize; + maxWidth?: boolean | number | string; } interface StartDeps { diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 82e4a6dd07824..51fc65441b3b5 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -12,6 +12,7 @@ import { EnvironmentMode } from '@kbn/config'; import { EuiBreadcrumb } from '@elastic/eui'; import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; +import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; import { ExclusiveUnion } from '@elastic/eui'; import { History } from 'history'; @@ -885,7 +886,11 @@ export interface OverlayFlyoutOpenOptions { // (undocumented) closeButtonAriaLabel?: string; // (undocumented) + maxWidth?: boolean | number | string; + // (undocumented) ownFocus?: boolean; + // (undocumented) + size?: EuiFlyoutSize; } // @public diff --git a/src/core/server/bootstrap.ts b/src/core/server/bootstrap.ts index 6711a8b8987e5..f7dd2a4ea24f5 100644 --- a/src/core/server/bootstrap.ts +++ b/src/core/server/bootstrap.ts @@ -27,9 +27,6 @@ interface KibanaFeatures { // a child process together with optimizer "worker" processes that are // orchestrated by a parent process (dev mode only feature). isCliDevModeSupported: boolean; - - // Indicates whether we can run Kibana in REPL mode (dev mode only feature). - isReplModeSupported: boolean; } interface BootstrapArgs { @@ -50,10 +47,6 @@ export async function bootstrap({ applyConfigOverrides, features, }: BootstrapArgs) { - if (cliArgs.repl && !features.isReplModeSupported) { - onRootShutdown('Kibana REPL mode can only be run in development mode.'); - } - if (cliArgs.optimize) { // --optimize is deprecated and does nothing now, avoid starting up and just shutdown return; diff --git a/src/core/server/legacy/cluster_manager.js b/src/core/server/legacy/cli_dev_mode.js similarity index 91% rename from src/core/server/legacy/cluster_manager.js rename to src/core/server/legacy/cli_dev_mode.js index 3c51fd6869a09..05a13bc55f97e 100644 --- a/src/core/server/legacy/cluster_manager.js +++ b/src/core/server/legacy/cli_dev_mode.js @@ -17,4 +17,4 @@ * under the License. */ -export { ClusterManager } from '../../../cli/cluster/cluster_manager'; +export { CliDevMode } from '../../../dev/cli_dev_mode'; diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index fe19ef9d0a774..98532d720c310 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -18,13 +18,13 @@ */ jest.mock('../../../legacy/server/kbn_server'); -jest.mock('./cluster_manager'); +jest.mock('./cli_dev_mode'); import { BehaviorSubject, throwError } from 'rxjs'; import { REPO_ROOT } from '@kbn/dev-utils'; // @ts-expect-error js file to remove TS dependency on cli -import { ClusterManager as MockClusterManager } from './cluster_manager'; +import { CliDevMode as MockCliDevMode } from './cli_dev_mode'; import KbnServer from '../../../legacy/server/kbn_server'; import { Config, Env, ObjectToConfigAdapter } from '../config'; import { BasePathProxyServer } from '../http'; @@ -239,7 +239,7 @@ describe('once LegacyService is set up with connection info', () => { ); expect(MockKbnServer).not.toHaveBeenCalled(); - expect(MockClusterManager).not.toHaveBeenCalled(); + expect(MockCliDevMode).not.toHaveBeenCalled(); }); test('reconfigures logging configuration if new config is received.', async () => { @@ -355,7 +355,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { }); }); - test('creates ClusterManager without base path proxy.', async () => { + test('creates CliDevMode without base path proxy.', async () => { const devClusterLegacyService = new LegacyService({ coreId, env: Env.createDefault( @@ -373,8 +373,8 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { await devClusterLegacyService.setup(setupDeps); await devClusterLegacyService.start(startDeps); - expect(MockClusterManager).toHaveBeenCalledTimes(1); - expect(MockClusterManager).toHaveBeenCalledWith( + expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledTimes(1); + expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledWith( expect.objectContaining({ silent: true, basePath: false }), expect.objectContaining({ get: expect.any(Function), @@ -384,7 +384,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { ); }); - test('creates ClusterManager with base path proxy.', async () => { + test('creates CliDevMode with base path proxy.', async () => { const devClusterLegacyService = new LegacyService({ coreId, env: Env.createDefault( @@ -402,8 +402,8 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { await devClusterLegacyService.setup(setupDeps); await devClusterLegacyService.start(startDeps); - expect(MockClusterManager).toHaveBeenCalledTimes(1); - expect(MockClusterManager).toHaveBeenCalledWith( + expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledTimes(1); + expect(MockCliDevMode.fromCoreServices).toHaveBeenCalledWith( expect.objectContaining({ quiet: true, basePath: true }), expect.objectContaining({ get: expect.any(Function), diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 4ae6c9d437576..6da5d54869801 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -145,7 +145,7 @@ export class LegacyService implements CoreService { // Receive initial config and create kbnServer/ClusterManager. if (this.coreContext.env.isDevCliParent) { - await this.createClusterManager(this.legacyRawConfig!); + await this.setupCliDevMode(this.legacyRawConfig!); } else { this.kbnServer = await this.createKbnServer( this.settings!, @@ -170,7 +170,7 @@ export class LegacyService implements CoreService { } } - private async createClusterManager(config: LegacyConfig) { + private async setupCliDevMode(config: LegacyConfig) { const basePathProxy$ = this.coreContext.env.cliArgs.basePath ? combineLatest([this.devConfig$, this.httpConfig$]).pipe( first(), @@ -182,8 +182,8 @@ export class LegacyService implements CoreService { : EMPTY; // eslint-disable-next-line @typescript-eslint/no-var-requires - const { ClusterManager } = require('./cluster_manager'); - return new ClusterManager( + const { CliDevMode } = require('./cli_dev_mode'); + CliDevMode.fromCoreServices( this.coreContext.env.cliArgs, config, await basePathProxy$.toPromise() @@ -310,12 +310,6 @@ export class LegacyService implements CoreService { logger: this.coreContext.logger, }); - // Prevent the repl from being started multiple times in different processes. - if (this.coreContext.env.cliArgs.repl && process.env.isDevCliChild) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('./cli').startRepl(kbnServer); - } - const { autoListen } = await this.httpConfig$.pipe(first()).toPromise(); if (autoListen) { diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index e5f0e8abd3b71..561f9bc001e30 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -573,24 +573,10 @@ describe('#savedObjectToRaw', () => { }); describe('single-namespace type without a namespace', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = singleNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - attributes: { bar: true }, - } as any); - - const v2 = singleNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespace`, () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', + id: 'mock-saved-object-id', attributes: {}, } as any); @@ -599,23 +585,6 @@ describe('#savedObjectToRaw', () => { }); describe('single-namespace type with a namespace', () => { - test('generates an id prefixed with namespace and type, if no id is specified', () => { - const v1 = singleNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - const v2 = singleNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^bar\:foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`it copies namespace to _source.namespace`, () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: 'foo', @@ -628,23 +597,6 @@ describe('#savedObjectToRaw', () => { }); describe('single-namespace type with namespaces', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespaces`, () => { const actual = namespaceAgnosticSerializer.savedObjectToRaw({ type: 'foo', @@ -657,23 +609,6 @@ describe('#savedObjectToRaw', () => { }); describe('namespace-agnostic type with a namespace', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespace`, () => { const actual = namespaceAgnosticSerializer.savedObjectToRaw({ type: 'foo', @@ -686,23 +621,6 @@ describe('#savedObjectToRaw', () => { }); describe('namespace-agnostic type with namespaces', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespaces`, () => { const actual = namespaceAgnosticSerializer.savedObjectToRaw({ type: 'foo', @@ -715,23 +633,6 @@ describe('#savedObjectToRaw', () => { }); describe('multi-namespace type with a namespace', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = multiNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - const v2 = multiNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespace`, () => { const actual = multiNamespaceSerializer.savedObjectToRaw({ type: 'foo', @@ -744,23 +645,6 @@ describe('#savedObjectToRaw', () => { }); describe('multi-namespace type with namespaces', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = multiNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - const v2 = multiNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`it copies namespaces to _source.namespaces`, () => { const actual = multiNamespaceSerializer.savedObjectToRaw({ type: 'foo', @@ -1064,11 +948,6 @@ describe('#isRawSavedObject', () => { describe('#generateRawId', () => { describe('single-namespace type without a namespace', () => { - test('generates an id if none is specified', () => { - const id = singleNamespaceSerializer.generateRawId('', 'goodbye'); - expect(id).toMatch(/^goodbye\:[\w-]+$/); - }); - test('uses the id that is specified', () => { const id = singleNamespaceSerializer.generateRawId('', 'hello', 'world'); expect(id).toEqual('hello:world'); @@ -1076,11 +955,6 @@ describe('#generateRawId', () => { }); describe('single-namespace type with a namespace', () => { - test('generates an id if none is specified and prefixes namespace', () => { - const id = singleNamespaceSerializer.generateRawId('foo', 'goodbye'); - expect(id).toMatch(/^foo:goodbye\:[\w-]+$/); - }); - test('uses the id that is specified and prefixes the namespace', () => { const id = singleNamespaceSerializer.generateRawId('foo', 'hello', 'world'); expect(id).toEqual('foo:hello:world'); @@ -1088,11 +962,6 @@ describe('#generateRawId', () => { }); describe('namespace-agnostic type with a namespace', () => { - test(`generates an id if none is specified and doesn't prefix namespace`, () => { - const id = namespaceAgnosticSerializer.generateRawId('foo', 'goodbye'); - expect(id).toMatch(/^goodbye\:[\w-]+$/); - }); - test(`uses the id that is specified and doesn't prefix the namespace`, () => { const id = namespaceAgnosticSerializer.generateRawId('foo', 'hello', 'world'); expect(id).toEqual('hello:world'); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 145dd286c1ca8..82999eeceb887 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -17,7 +17,6 @@ * under the License. */ -import uuid from 'uuid'; import { decodeVersion, encodeVersion } from '../version'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { SavedObjectsRawDoc, SavedObjectSanitizedDoc } from './types'; @@ -127,10 +126,10 @@ export class SavedObjectsSerializer { * @param {string} type - The saved object type * @param {string} id - The id of the saved object */ - public generateRawId(namespace: string | undefined, type: string, id?: string) { + public generateRawId(namespace: string | undefined, type: string, id: string) { const namespacePrefix = namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; - return `${namespacePrefix}${type}:${id || uuid.v1()}`; + return `${namespacePrefix}${type}:${id}`; } private trimIdPrefix(namespace: string | undefined, type: string, id: string) { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 8b3eebceb2c5a..e59b1a68e1ad1 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -50,7 +50,7 @@ export interface SavedObjectsRawDocSource { */ interface SavedObjectDoc { attributes: T; - id?: string; // NOTE: SavedObjectDoc is used for uncreated objects where `id` is optional + id: string; type: string; namespace?: string; namespaces?: string[]; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 6a3defb9556f5..a19b4cc01db8e 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -1831,21 +1831,16 @@ describe('SavedObjectsRepository', () => { }; describe('client calls', () => { - it(`should use the ES create action if ID is undefined and overwrite=true`, async () => { + it(`should use the ES index action if overwrite=true`, async () => { await createSuccess(type, attributes, { overwrite: true }); - expect(client.create).toHaveBeenCalled(); + expect(client.index).toHaveBeenCalled(); }); - it(`should use the ES create action if ID is undefined and overwrite=false`, async () => { + it(`should use the ES create action if overwrite=false`, async () => { await createSuccess(type, attributes); expect(client.create).toHaveBeenCalled(); }); - it(`should use the ES index action if ID is defined and overwrite=true`, async () => { - await createSuccess(type, attributes, { id, overwrite: true }); - expect(client.index).toHaveBeenCalled(); - }); - it(`should use the ES index with version if ID and version are defined and overwrite=true`, async () => { await createSuccess(type, attributes, { id, overwrite: true, version: mockVersion }); expect(client.index).toHaveBeenCalled(); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index dae6a8d19dae2..587a0e51ef9b9 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -18,7 +18,6 @@ */ import { omit, isObject } from 'lodash'; -import uuid from 'uuid'; import { ElasticsearchClient, DeleteDocumentResponse, @@ -245,7 +244,7 @@ export class SavedObjectsRepository { options: SavedObjectsCreateOptions = {} ): Promise> { const { - id, + id = SavedObjectsUtils.generateId(), migrationVersion, overwrite = false, references = [], @@ -366,7 +365,9 @@ export class SavedObjectsRepository { const method = object.id && overwrite ? 'index' : 'create'; const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); - if (object.id == null) object.id = uuid.v1(); + if (object.id == null) { + object.id = SavedObjectsUtils.generateId(); + } return { tag: 'Right' as 'Right', diff --git a/src/core/server/saved_objects/service/lib/utils.test.ts b/src/core/server/saved_objects/service/lib/utils.test.ts index ac06ca9275783..062a68e2dca28 100644 --- a/src/core/server/saved_objects/service/lib/utils.test.ts +++ b/src/core/server/saved_objects/service/lib/utils.test.ts @@ -17,11 +17,22 @@ * under the License. */ +import uuid from 'uuid'; import { SavedObjectsFindOptions } from '../../types'; import { SavedObjectsUtils } from './utils'; +jest.mock('uuid', () => ({ + v1: jest.fn().mockReturnValue('mock-uuid'), +})); + describe('SavedObjectsUtils', () => { - const { namespaceIdToString, namespaceStringToId, createEmptyFindResponse } = SavedObjectsUtils; + const { + namespaceIdToString, + namespaceStringToId, + createEmptyFindResponse, + generateId, + isRandomId, + } = SavedObjectsUtils; describe('#namespaceIdToString', () => { it('converts `undefined` to default namespace string', () => { @@ -77,4 +88,20 @@ describe('SavedObjectsUtils', () => { expect(createEmptyFindResponse(options).per_page).toEqual(42); }); }); + + describe('#generateId', () => { + it('returns a valid uuid', () => { + expect(generateId()).toBe('mock-uuid'); + expect(uuid.v1).toHaveBeenCalled(); + }); + }); + + describe('#isRandomId', () => { + it('validates uuid correctly', () => { + expect(isRandomId('c4d82f66-3046-11eb-adc1-0242ac120002')).toBe(true); + expect(isRandomId('invalid')).toBe(false); + expect(isRandomId('')).toBe(false); + expect(isRandomId(undefined)).toBe(false); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index 69abc37089218..b59829cb4978a 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -17,6 +17,7 @@ * under the License. */ +import uuid from 'uuid'; import { SavedObjectsFindOptions } from '../../types'; import { SavedObjectsFindResponse } from '..'; @@ -24,6 +25,7 @@ export const DEFAULT_NAMESPACE_STRING = 'default'; export const ALL_NAMESPACES_STRING = '*'; export const FIND_DEFAULT_PAGE = 1; export const FIND_DEFAULT_PER_PAGE = 20; +const UUID_REGEX = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i; /** * @public @@ -69,4 +71,21 @@ export class SavedObjectsUtils { total: 0, saved_objects: [], }); + + /** + * Generates a random ID for a saved objects. + */ + public static generateId() { + return uuid.v1(); + } + + /** + * Validates that a saved object ID has been randomly generated. + * + * @param {string} id The ID of a saved object. + * @todo Use `uuid.validate` once upgraded to v5.3+ + */ + public static isRandomId(id: string | undefined) { + return typeof id === 'string' && UUID_REGEX.test(id); + } } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index d877fc36d114b..770048d2cff13 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2518,7 +2518,7 @@ export interface SavedObjectsResolveImportErrorsOptions { export class SavedObjectsSerializer { // @internal constructor(registry: ISavedObjectTypeRegistry); - generateRawId(namespace: string | undefined, type: string, id?: string): string; + generateRawId(namespace: string | undefined, type: string, id: string): string; isRawSavedObject(rawDoc: SavedObjectsRawDoc): boolean; rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc; savedObjectToRaw(savedObj: SavedObjectSanitizedDoc): SavedObjectsRawDoc; @@ -2600,6 +2600,8 @@ export interface SavedObjectsUpdateResponse extends Omit({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; + static generateId(): string; + static isRandomId(id: string | undefined): boolean; static namespaceIdToString: (namespace?: string | undefined) => string; static namespaceStringToId: (namespace: string) => string | undefined; } diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 3161420b94d22..4ff845596f741 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -73,7 +73,6 @@ export function createRootWithSettings( quiet: false, silent: false, watch: false, - repl: false, basePath: false, runExamples: false, oss: true, diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index b0ace3c63d82e..710e504e58868 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -33,7 +33,6 @@ export const CopySource: Task = { '!src/**/{target,__tests__,__snapshots__,__mocks__}/**', '!src/test_utils/**', '!src/fixtures/**', - '!src/cli/cluster/**', '!src/cli/repl/**', '!src/cli/dev.js', '!src/functional_test_runner/**', diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index 4580b95423d3d..0e554162bca86 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -25,12 +25,19 @@ export const CreateDebPackage: Task = { description: 'Creating deb package', async run(config, log, build) { - await runFpm(config, log, build, 'deb', [ + await runFpm(config, log, build, 'deb', 'x64', [ '--architecture', 'amd64', '--deb-priority', 'optional', ]); + + await runFpm(config, log, build, 'deb', 'arm64', [ + '--architecture', + 'arm64', + '--deb-priority', + 'optional', + ]); }, }; @@ -38,7 +45,18 @@ export const CreateRpmPackage: Task = { description: 'Creating rpm package', async run(config, log, build) { - await runFpm(config, log, build, 'rpm', ['--architecture', 'x86_64', '--rpm-os', 'linux']); + await runFpm(config, log, build, 'rpm', 'x64', [ + '--architecture', + 'x86_64', + '--rpm-os', + 'linux', + ]); + await runFpm(config, log, build, 'rpm', 'arm64', [ + '--architecture', + 'aarch64', + '--rpm-os', + 'linux', + ]); }, }; diff --git a/src/dev/build/tasks/os_packages/run_fpm.ts b/src/dev/build/tasks/os_packages/run_fpm.ts index def0289f53641..15606e40259c6 100644 --- a/src/dev/build/tasks/os_packages/run_fpm.ts +++ b/src/dev/build/tasks/os_packages/run_fpm.ts @@ -28,9 +28,10 @@ export async function runFpm( log: ToolingLog, build: Build, type: 'rpm' | 'deb', + architecture: 'arm64' | 'x64', pkgSpecificFlags: string[] ) { - const linux = config.getPlatform('linux', 'x64'); + const linux = config.getPlatform('linux', architecture); const version = config.getBuildVersion(); const resolveWithTrailingSlash = (...paths: string[]) => `${resolve(...paths)}/`; diff --git a/src/dev/cli_dev_mode/README.md b/src/dev/cli_dev_mode/README.md new file mode 100644 index 0000000000000..397017027a52f --- /dev/null +++ b/src/dev/cli_dev_mode/README.md @@ -0,0 +1,33 @@ +# `CliDevMode` + +A class that manages the alternate behavior of the Kibana cli when using the `--dev` flag. This mode provides several useful features in a single CLI for a nice developer experience: + + - automatic server restarts when code changes + - runs the `@kbn/optimizer` to build browser bundles + - runs a base path proxy which helps developers test that they are writing code which is compatible with custom basePath settings while they work + - pauses requests when the server or optimizer are not ready to handle requests so that when users load Kibana in the browser it's always using the code as it exists on disk + +To accomplish this, and to make it easier to test, the `CliDevMode` class manages several objects: + +## `Watcher` + +The `Watcher` manages a [chokidar](https://github.com/paulmillr/chokidar) instance to watch the server files, logs about file changes observed and provides an observable to the `DevServer` via its `serverShouldRestart$()` method. + +## `DevServer` + +The `DevServer` object is responsible for everything related to running and restarting the Kibana server process: + - listens to restart notifications from the `Watcher` object, sending `SIGKILL` to the existing server and launching a new instance with the current code + - writes the stdout/stderr logs from the Kibana server to the parent process + - gracefully kills the process if the SIGINT signal is sent + - kills the server if the SIGTERM signal is sent, process.exit() is used, a second SIGINT is sent, or the gracefull shutdown times out + - proxies SIGHUP notifications to the child process, though the core team is working on migrating this functionality to the KP and making this unnecessary + +## `Optimizer` + +The `Optimizer` object manages a `@kbn/optimizer` instance, adapting its configuration and logging to the data available to the CLI. + +## `BasePathProxyServer` (currently passed from core) + +The `BasePathProxyServer` is passed to the `CliDevMode` from core when the dev mode is trigged by the `--dev` flag. This proxy injects a random three character base path in the URL that Kibana is served from to help ensure that Kibana features are written to adapt to custom base path configurations from users. + +The basePathProxy also has another important job, ensuring that requests don't fail because the server is restarting and that the browser receives front-end assets containing all saved changes. We accomplish this by observing the ready state of the `Optimizer` and `DevServer` objects and pausing all requests through the proxy until both objects report that they aren't building/restarting based on recently saved changes. \ No newline at end of file diff --git a/src/dev/cli_dev_mode/cli_dev_mode.test.ts b/src/dev/cli_dev_mode/cli_dev_mode.test.ts new file mode 100644 index 0000000000000..b86100d161bd3 --- /dev/null +++ b/src/dev/cli_dev_mode/cli_dev_mode.test.ts @@ -0,0 +1,403 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; + +import { + REPO_ROOT, + createAbsolutePathSerializer, + createAnyInstanceSerializer, +} from '@kbn/dev-utils'; +import * as Rx from 'rxjs'; + +import { TestLog } from './log'; +import { CliDevMode } from './cli_dev_mode'; + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); +expect.addSnapshotSerializer(createAnyInstanceSerializer(Rx.Observable, 'Rx.Observable')); +expect.addSnapshotSerializer(createAnyInstanceSerializer(TestLog)); + +jest.mock('./watcher'); +const { Watcher } = jest.requireMock('./watcher'); + +jest.mock('./optimizer'); +const { Optimizer } = jest.requireMock('./optimizer'); + +jest.mock('./dev_server'); +const { DevServer } = jest.requireMock('./dev_server'); + +jest.mock('./get_server_watch_paths', () => ({ + getServerWatchPaths: jest.fn(() => ({ + watchPaths: [''], + ignorePaths: [''], + })), +})); + +beforeEach(() => { + process.argv = ['node', './script', 'foo', 'bar', 'baz']; + jest.clearAllMocks(); +}); + +const log = new TestLog(); + +const mockBasePathProxy = { + targetPort: 9999, + basePath: '/foo/bar', + start: jest.fn(), + stop: jest.fn(), +}; + +const defaultOptions = { + cache: true, + disableOptimizer: false, + dist: true, + oss: true, + pluginPaths: [], + pluginScanDirs: [Path.resolve(REPO_ROOT, 'src/plugins')], + quiet: false, + silent: false, + runExamples: false, + watch: true, + log, +}; + +afterEach(() => { + log.messages.length = 0; +}); + +it('passes correct args to sub-classes', () => { + new CliDevMode(defaultOptions); + + expect(DevServer.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "argv": Array [ + "foo", + "bar", + "baz", + ], + "gracefulTimeout": 5000, + "log": , + "script": /scripts/kibana, + "watcher": Watcher { + "serverShouldRestart$": [MockFunction], + }, + }, + ], + ] + `); + expect(Optimizer.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "cache": true, + "dist": true, + "enabled": true, + "oss": true, + "pluginPaths": Array [], + "quiet": false, + "repoRoot": , + "runExamples": false, + "silent": false, + "watch": true, + }, + ], + ] + `); + expect(Watcher.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "cwd": , + "enabled": true, + "ignore": Array [ + "", + ], + "log": , + "paths": Array [ + "", + ], + }, + ], + ] + `); + expect(log.messages).toMatchInlineSnapshot(`Array []`); +}); + +it('disables the optimizer', () => { + new CliDevMode({ + ...defaultOptions, + disableOptimizer: true, + }); + + expect(Optimizer.mock.calls[0][0]).toHaveProperty('enabled', false); +}); + +it('disables the watcher', () => { + new CliDevMode({ + ...defaultOptions, + watch: false, + }); + + expect(Optimizer.mock.calls[0][0]).toHaveProperty('watch', false); + expect(Watcher.mock.calls[0][0]).toHaveProperty('enabled', false); +}); + +it('overrides the basePath of the server when basePathProxy is defined', () => { + new CliDevMode({ + ...defaultOptions, + basePathProxy: mockBasePathProxy as any, + }); + + expect(DevServer.mock.calls[0][0].argv).toMatchInlineSnapshot(` + Array [ + "foo", + "bar", + "baz", + "--server.port=9999", + "--server.basePath=/foo/bar", + "--server.rewriteBasePath=true", + ] + `); +}); + +describe('#start()/#stop()', () => { + let optimizerRun$: Rx.Subject; + let optimizerReady$: Rx.Subject; + let watcherRun$: Rx.Subject; + let devServerRun$: Rx.Subject; + let devServerReady$: Rx.Subject; + let processExitMock: jest.SpyInstance; + + beforeAll(() => { + processExitMock = jest.spyOn(process, 'exit').mockImplementation( + // @ts-expect-error process.exit isn't supposed to return + () => {} + ); + }); + + beforeEach(() => { + Optimizer.mockImplementation(() => { + optimizerRun$ = new Rx.Subject(); + optimizerReady$ = new Rx.Subject(); + return { + isReady$: jest.fn(() => optimizerReady$), + run$: optimizerRun$, + }; + }); + Watcher.mockImplementation(() => { + watcherRun$ = new Rx.Subject(); + return { + run$: watcherRun$, + }; + }); + DevServer.mockImplementation(() => { + devServerRun$ = new Rx.Subject(); + devServerReady$ = new Rx.Subject(); + return { + isReady$: jest.fn(() => devServerReady$), + run$: devServerRun$, + }; + }); + }); + + afterEach(() => { + Optimizer.mockReset(); + Watcher.mockReset(); + DevServer.mockReset(); + }); + + afterAll(() => { + processExitMock.mockRestore(); + }); + + it('logs a warning if basePathProxy is not passed', () => { + new CliDevMode({ + ...defaultOptions, + }).start(); + + expect(log.messages).toMatchInlineSnapshot(` + Array [ + Object { + "args": Array [ + "no-base-path", + "====================================================================================================", + ], + "type": "warn", + }, + Object { + "args": Array [ + "no-base-path", + "Running Kibana in dev mode with --no-base-path disables several useful features and is not recommended", + ], + "type": "warn", + }, + Object { + "args": Array [ + "no-base-path", + "====================================================================================================", + ], + "type": "warn", + }, + ] + `); + }); + + it('calls start on BasePathProxy if enabled', () => { + const basePathProxy: any = { + start: jest.fn(), + }; + + new CliDevMode({ + ...defaultOptions, + basePathProxy, + }).start(); + + expect(basePathProxy.start.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "delayUntil": [Function], + "shouldRedirectFromOldBasePath": [Function], + }, + ], + ] + `); + }); + + it('subscribes to Optimizer#run$, Watcher#run$, and DevServer#run$', () => { + new CliDevMode(defaultOptions).start(); + + expect(optimizerRun$.observers).toHaveLength(1); + expect(watcherRun$.observers).toHaveLength(1); + expect(devServerRun$.observers).toHaveLength(1); + }); + + it('logs an error and exits the process if Optimizer#run$ errors', () => { + new CliDevMode({ + ...defaultOptions, + basePathProxy: mockBasePathProxy as any, + }).start(); + + expect(processExitMock).not.toHaveBeenCalled(); + optimizerRun$.error({ stack: 'Error: foo bar' }); + expect(log.messages).toMatchInlineSnapshot(` + Array [ + Object { + "args": Array [ + "[@kbn/optimizer] fatal error", + "Error: foo bar", + ], + "type": "bad", + }, + ] + `); + expect(processExitMock.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 1, + ], + ] + `); + }); + + it('logs an error and exits the process if Watcher#run$ errors', () => { + new CliDevMode({ + ...defaultOptions, + basePathProxy: mockBasePathProxy as any, + }).start(); + + expect(processExitMock).not.toHaveBeenCalled(); + watcherRun$.error({ stack: 'Error: foo bar' }); + expect(log.messages).toMatchInlineSnapshot(` + Array [ + Object { + "args": Array [ + "[watcher] fatal error", + "Error: foo bar", + ], + "type": "bad", + }, + ] + `); + expect(processExitMock.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 1, + ], + ] + `); + }); + + it('logs an error and exits the process if DevServer#run$ errors', () => { + new CliDevMode({ + ...defaultOptions, + basePathProxy: mockBasePathProxy as any, + }).start(); + + expect(processExitMock).not.toHaveBeenCalled(); + devServerRun$.error({ stack: 'Error: foo bar' }); + expect(log.messages).toMatchInlineSnapshot(` + Array [ + Object { + "args": Array [ + "[dev server] fatal error", + "Error: foo bar", + ], + "type": "bad", + }, + ] + `); + expect(processExitMock.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 1, + ], + ] + `); + }); + + it('throws if start() has already been called', () => { + expect(() => { + const devMode = new CliDevMode({ + ...defaultOptions, + basePathProxy: mockBasePathProxy as any, + }); + + devMode.start(); + devMode.start(); + }).toThrowErrorMatchingInlineSnapshot(`"CliDevMode already started"`); + }); + + it('unsubscribes from all observables and stops basePathProxy when stopped', () => { + const devMode = new CliDevMode({ + ...defaultOptions, + basePathProxy: mockBasePathProxy as any, + }); + + devMode.start(); + devMode.stop(); + + expect(optimizerRun$.observers).toHaveLength(0); + expect(watcherRun$.observers).toHaveLength(0); + expect(devServerRun$.observers).toHaveLength(0); + expect(mockBasePathProxy.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/dev/cli_dev_mode/cli_dev_mode.ts b/src/dev/cli_dev_mode/cli_dev_mode.ts new file mode 100644 index 0000000000000..3cb97b08b75c2 --- /dev/null +++ b/src/dev/cli_dev_mode/cli_dev_mode.ts @@ -0,0 +1,214 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; + +import { REPO_ROOT } from '@kbn/dev-utils'; +import * as Rx from 'rxjs'; +import { mapTo, filter, take } from 'rxjs/operators'; + +import { CliArgs } from '../../core/server/config'; +import { LegacyConfig } from '../../core/server/legacy'; +import { BasePathProxyServer } from '../../core/server/http'; + +import { Log, CliLog } from './log'; +import { Optimizer } from './optimizer'; +import { DevServer } from './dev_server'; +import { Watcher } from './watcher'; +import { shouldRedirectFromOldBasePath } from './should_redirect_from_old_base_path'; +import { getServerWatchPaths } from './get_server_watch_paths'; + +// timeout where the server is allowed to exit gracefully +const GRACEFUL_TIMEOUT = 5000; + +export type SomeCliArgs = Pick< + CliArgs, + 'quiet' | 'silent' | 'disableOptimizer' | 'watch' | 'oss' | 'runExamples' | 'cache' | 'dist' +>; + +export interface CliDevModeOptions { + basePathProxy?: BasePathProxyServer; + log?: Log; + + // cli flags + dist: boolean; + oss: boolean; + runExamples: boolean; + pluginPaths: string[]; + pluginScanDirs: string[]; + disableOptimizer: boolean; + quiet: boolean; + silent: boolean; + watch: boolean; + cache: boolean; +} + +const firstAllTrue = (...sources: Array>) => + Rx.combineLatest(sources).pipe( + filter((values) => values.every((v) => v === true)), + take(1), + mapTo(undefined) + ); + +/** + * setup and manage the parent process of the dev server: + * + * - runs the Kibana server in a child process + * - watches for changes to the server source code, restart the server on changes. + * - run the kbn/optimizer + * - run the basePathProxy + * - delay requests received by the basePathProxy when either the server isn't ready + * or the kbn/optimizer isn't ready + * + */ +export class CliDevMode { + static fromCoreServices( + cliArgs: SomeCliArgs, + config: LegacyConfig, + basePathProxy?: BasePathProxyServer + ) { + new CliDevMode({ + quiet: !!cliArgs.quiet, + silent: !!cliArgs.silent, + cache: !!cliArgs.cache, + disableOptimizer: !!cliArgs.disableOptimizer, + dist: !!cliArgs.dist, + oss: !!cliArgs.oss, + runExamples: !!cliArgs.runExamples, + pluginPaths: config.get('plugins.paths'), + pluginScanDirs: config.get('plugins.scanDirs'), + watch: !!cliArgs.watch, + basePathProxy, + }).start(); + } + private readonly log: Log; + private readonly basePathProxy?: BasePathProxyServer; + private readonly watcher: Watcher; + private readonly devServer: DevServer; + private readonly optimizer: Optimizer; + + private subscription?: Rx.Subscription; + + constructor(options: CliDevModeOptions) { + this.basePathProxy = options.basePathProxy; + this.log = options.log || new CliLog(!!options.quiet, !!options.silent); + + const { watchPaths, ignorePaths } = getServerWatchPaths({ + pluginPaths: options.pluginPaths ?? [], + pluginScanDirs: [ + ...(options.pluginScanDirs ?? []), + Path.resolve(REPO_ROOT, 'src/plugins'), + Path.resolve(REPO_ROOT, 'x-pack/plugins'), + ], + }); + + this.watcher = new Watcher({ + enabled: !!options.watch, + log: this.log, + cwd: REPO_ROOT, + paths: watchPaths, + ignore: ignorePaths, + }); + + this.devServer = new DevServer({ + log: this.log, + watcher: this.watcher, + gracefulTimeout: GRACEFUL_TIMEOUT, + + script: Path.resolve(REPO_ROOT, 'scripts/kibana'), + argv: [ + ...process.argv.slice(2).filter((v) => v !== '--no-watch'), + ...(options.basePathProxy + ? [ + `--server.port=${options.basePathProxy.targetPort}`, + `--server.basePath=${options.basePathProxy.basePath}`, + '--server.rewriteBasePath=true', + ] + : []), + ], + }); + + this.optimizer = new Optimizer({ + enabled: !options.disableOptimizer, + repoRoot: REPO_ROOT, + oss: options.oss, + pluginPaths: options.pluginPaths, + runExamples: options.runExamples, + cache: options.cache, + dist: options.dist, + quiet: options.quiet, + silent: options.silent, + watch: options.watch, + }); + } + + public start() { + const { basePathProxy } = this; + + if (this.subscription) { + throw new Error('CliDevMode already started'); + } + + this.subscription = new Rx.Subscription(); + + if (basePathProxy) { + const delay$ = firstAllTrue(this.devServer.isReady$(), this.optimizer.isReady$()); + + basePathProxy.start({ + delayUntil: () => delay$, + shouldRedirectFromOldBasePath, + }); + + this.subscription.add(() => basePathProxy.stop()); + } else { + this.log.warn('no-base-path', '='.repeat(100)); + this.log.warn( + 'no-base-path', + 'Running Kibana in dev mode with --no-base-path disables several useful features and is not recommended' + ); + this.log.warn('no-base-path', '='.repeat(100)); + } + + this.subscription.add(this.optimizer.run$.subscribe(this.observer('@kbn/optimizer'))); + this.subscription.add(this.watcher.run$.subscribe(this.observer('watcher'))); + this.subscription.add(this.devServer.run$.subscribe(this.observer('dev server'))); + } + + public stop() { + if (!this.subscription) { + throw new Error('CliDevMode has not been started'); + } + + this.subscription.unsubscribe(); + this.subscription = undefined; + } + + private observer = (title: string): Rx.Observer => ({ + next: () => { + // noop + }, + error: (error) => { + this.log.bad(`[${title}] fatal error`, error.stack); + process.exit(1); + }, + complete: () => { + // noop + }, + }); +} diff --git a/src/dev/cli_dev_mode/dev_server.test.ts b/src/dev/cli_dev_mode/dev_server.test.ts new file mode 100644 index 0000000000000..792125f4f85b1 --- /dev/null +++ b/src/dev/cli_dev_mode/dev_server.test.ts @@ -0,0 +1,319 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EventEmitter } from 'events'; +import { PassThrough } from 'stream'; + +import * as Rx from 'rxjs'; + +import { extendedEnvSerializer } from './test_helpers'; +import { DevServer, Options } from './dev_server'; +import { TestLog } from './log'; + +class MockProc extends EventEmitter { + public readonly signalsSent: string[] = []; + + stdout = new PassThrough(); + stderr = new PassThrough(); + + kill = jest.fn((signal) => { + this.signalsSent.push(signal); + }); + + mockExit(code: number) { + this.emit('exit', code, undefined); + // close stdio streams + this.stderr.end(); + this.stdout.end(); + } + + mockListening() { + this.emit('message', ['SERVER_LISTENING'], undefined); + } +} + +jest.mock('execa'); +const execa = jest.requireMock('execa'); + +let currentProc: MockProc | undefined; +execa.node.mockImplementation(() => { + const proc = new MockProc(); + currentProc = proc; + return proc; +}); +function isProc(proc: MockProc | undefined): asserts proc is MockProc { + expect(proc).toBeInstanceOf(MockProc); +} + +const restart$ = new Rx.Subject(); +const mockWatcher = { + enabled: true, + serverShouldRestart$: jest.fn(() => restart$), +}; + +const processExit$ = new Rx.Subject(); +const sigint$ = new Rx.Subject(); +const sigterm$ = new Rx.Subject(); + +const log = new TestLog(); +const defaultOptions: Options = { + log, + watcher: mockWatcher as any, + script: 'some/script', + argv: ['foo', 'bar'], + gracefulTimeout: 100, + processExit$, + sigint$, + sigterm$, +}; + +expect.addSnapshotSerializer(extendedEnvSerializer); + +beforeEach(() => { + jest.clearAllMocks(); + log.messages.length = 0; + currentProc = undefined; +}); + +const subscriptions: Rx.Subscription[] = []; +const run = (server: DevServer) => { + const subscription = server.run$.subscribe({ + error(e) { + throw e; + }, + }); + subscriptions.push(subscription); + return subscription; +}; + +afterEach(() => { + if (currentProc) { + currentProc.removeAllListeners(); + currentProc = undefined; + } + + for (const sub of subscriptions) { + sub.unsubscribe(); + } + subscriptions.length = 0; +}); + +describe('#run$', () => { + it('starts the dev server with the right options', () => { + run(new DevServer(defaultOptions)).unsubscribe(); + + expect(execa.node.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "some/script", + Array [ + "foo", + "bar", + "--logging.json=false", + ], + Object { + "env": Object { + "": true, + "ELASTIC_APM_SERVICE_NAME": "kibana", + "isDevCliChild": "true", + }, + "nodeOptions": Array [], + "stdio": "pipe", + }, + ], + ] + `); + }); + + it('writes stdout and stderr lines to logger', () => { + run(new DevServer(defaultOptions)); + isProc(currentProc); + + currentProc.stdout.write('hello '); + currentProc.stderr.write('something '); + currentProc.stdout.write('world\n'); + currentProc.stderr.write('went wrong\n'); + expect(log.messages).toMatchInlineSnapshot(` + Array [ + Object { + "args": Array [ + "hello world", + ], + "type": "write", + }, + Object { + "args": Array [ + "something went wrong", + ], + "type": "write", + }, + ] + `); + }); + + it('is ready when message sends SERVER_LISTENING message', () => { + const server = new DevServer(defaultOptions); + run(server); + isProc(currentProc); + + let ready; + subscriptions.push( + server.isReady$().subscribe((_ready) => { + ready = _ready; + }) + ); + + expect(ready).toBe(false); + currentProc.mockListening(); + expect(ready).toBe(true); + }); + + it('is not ready when process exits', () => { + const server = new DevServer(defaultOptions); + run(server); + isProc(currentProc); + + const ready$ = new Rx.BehaviorSubject(undefined); + subscriptions.push(server.isReady$().subscribe(ready$)); + + currentProc.mockListening(); + expect(ready$.getValue()).toBe(true); + currentProc.mockExit(0); + expect(ready$.getValue()).toBe(false); + }); + + it('logs about crashes when process exits with non-zero code', () => { + const server = new DevServer(defaultOptions); + run(server); + isProc(currentProc); + + currentProc.mockExit(1); + expect(log.messages).toMatchInlineSnapshot(` + Array [ + Object { + "args": Array [ + "server crashed", + "with status code", + 1, + ], + "type": "bad", + }, + ] + `); + }); + + it('does not restart the server when process exits with 0 and stdio streams complete', async () => { + const server = new DevServer(defaultOptions); + run(server); + isProc(currentProc); + const initialProc = currentProc; + + const ready$ = new Rx.BehaviorSubject(undefined); + subscriptions.push(server.isReady$().subscribe(ready$)); + + currentProc.mockExit(0); + + expect(ready$.getValue()).toBe(false); + expect(initialProc).toBe(currentProc); // no restart or the proc would have been updated + }); + + it('kills server and restarts when watcher says to', () => { + run(new DevServer(defaultOptions)); + + const initialProc = currentProc; + isProc(initialProc); + + restart$.next(); + expect(initialProc.signalsSent).toEqual(['SIGKILL']); + + isProc(currentProc); + expect(currentProc).not.toBe(initialProc); + }); + + it('subscribes to sigint$, sigterm$, and processExit$ options', () => { + run(new DevServer(defaultOptions)); + + expect(sigint$.observers).toHaveLength(1); + expect(sigterm$.observers).toHaveLength(1); + expect(processExit$.observers).toHaveLength(1); + }); + + it('kills the server on sigint$ before listening', () => { + run(new DevServer(defaultOptions)); + isProc(currentProc); + + expect(currentProc.signalsSent).toEqual([]); + sigint$.next(); + expect(currentProc.signalsSent).toEqual(['SIGKILL']); + }); + + it('kills the server on processExit$', () => { + run(new DevServer(defaultOptions)); + isProc(currentProc); + + expect(currentProc.signalsSent).toEqual([]); + processExit$.next(); + expect(currentProc.signalsSent).toEqual(['SIGKILL']); + }); + + it('kills the server on sigterm$', () => { + run(new DevServer(defaultOptions)); + isProc(currentProc); + + expect(currentProc.signalsSent).toEqual([]); + sigterm$.next(); + expect(currentProc.signalsSent).toEqual(['SIGKILL']); + }); + + it('sends SIGINT to child process on sigint$ after listening', () => { + run(new DevServer(defaultOptions)); + isProc(currentProc); + + currentProc.mockListening(); + + expect(currentProc.signalsSent).toEqual([]); + sigint$.next(); + expect(currentProc.signalsSent).toEqual(['SIGINT']); + }); + + it('sends SIGKILL to child process on double sigint$ after listening', () => { + run(new DevServer(defaultOptions)); + isProc(currentProc); + + currentProc.mockListening(); + + expect(currentProc.signalsSent).toEqual([]); + sigint$.next(); + sigint$.next(); + expect(currentProc.signalsSent).toEqual(['SIGINT', 'SIGKILL']); + }); + + it('kills the server after sending SIGINT and gracefulTimeout is passed after listening', async () => { + run(new DevServer(defaultOptions)); + isProc(currentProc); + + currentProc.mockListening(); + + expect(currentProc.signalsSent).toEqual([]); + sigint$.next(); + expect(currentProc.signalsSent).toEqual(['SIGINT']); + await new Promise((resolve) => setTimeout(resolve, 1000)); + expect(currentProc.signalsSent).toEqual(['SIGINT', 'SIGKILL']); + }); +}); diff --git a/src/dev/cli_dev_mode/dev_server.ts b/src/dev/cli_dev_mode/dev_server.ts new file mode 100644 index 0000000000000..da64c680a3c2d --- /dev/null +++ b/src/dev/cli_dev_mode/dev_server.ts @@ -0,0 +1,222 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EventEmitter } from 'events'; + +import * as Rx from 'rxjs'; +import { + map, + tap, + take, + share, + mergeMap, + switchMap, + takeUntil, + ignoreElements, +} from 'rxjs/operators'; +import { observeLines } from '@kbn/dev-utils'; + +import { usingServerProcess } from './using_server_process'; +import { Watcher } from './watcher'; +import { Log } from './log'; + +export interface Options { + log: Log; + watcher: Watcher; + script: string; + argv: string[]; + gracefulTimeout: number; + processExit$?: Rx.Observable; + sigint$?: Rx.Observable; + sigterm$?: Rx.Observable; +} + +export class DevServer { + private readonly log: Log; + private readonly watcher: Watcher; + + private readonly processExit$: Rx.Observable; + private readonly sigint$: Rx.Observable; + private readonly sigterm$: Rx.Observable; + private readonly ready$ = new Rx.BehaviorSubject(false); + + private readonly script: string; + private readonly argv: string[]; + private readonly gracefulTimeout: number; + + constructor(options: Options) { + this.log = options.log; + this.watcher = options.watcher; + + this.script = options.script; + this.argv = options.argv; + this.gracefulTimeout = options.gracefulTimeout; + this.processExit$ = options.processExit$ ?? Rx.fromEvent(process as EventEmitter, 'exit'); + this.sigint$ = options.sigint$ ?? Rx.fromEvent(process as EventEmitter, 'SIGINT'); + this.sigterm$ = options.sigterm$ ?? Rx.fromEvent(process as EventEmitter, 'SIGTERM'); + } + + isReady$() { + return this.ready$.asObservable(); + } + + /** + * Run the Kibana server + * + * The observable will error if the child process failes to spawn for some reason, but if + * the child process is successfully spawned then the server will be run until it completes + * and restart when the watcher indicates it should. In order to restart the server as + * quickly as possible we kill it with SIGKILL and spawn the process again. + * + * While the process is running we also observe SIGINT signals and forward them to the child + * process. If the process doesn't exit within options.gracefulTimeout we kill the process + * with SIGKILL and complete our observable which should allow the parent process to exit. + * + * When the global 'exit' event or SIGTERM is observed we send the SIGKILL signal to the + * child process to make sure that it's immediately gone. + */ + run$ = new Rx.Observable((subscriber) => { + // listen for SIGINT and forward to process if it's running, otherwise unsub + const gracefulShutdown$ = new Rx.Subject(); + subscriber.add( + this.sigint$ + .pipe( + map((_, index) => { + if (this.ready$.getValue() && index === 0) { + gracefulShutdown$.next(); + } else { + subscriber.complete(); + } + }) + ) + .subscribe({ + error(error) { + subscriber.error(error); + }, + }) + ); + + // force unsubscription/kill on process.exit or SIGTERM + subscriber.add( + Rx.merge(this.processExit$, this.sigterm$).subscribe(() => { + subscriber.complete(); + }) + ); + + const runServer = () => + usingServerProcess(this.script, this.argv, (proc) => { + // observable which emits devServer states containing lines + // logged to stdout/stderr, completes when stdio streams complete + const log$ = Rx.merge(observeLines(proc.stdout!), observeLines(proc.stderr!)).pipe( + tap((line) => { + this.log.write(line); + }) + ); + + // observable which emits exit states and is the switch which + // ends all other merged observables + const exit$ = Rx.fromEvent<[number]>(proc, 'exit').pipe( + tap(([code]) => { + this.ready$.next(false); + + if (code != null && code !== 0) { + if (this.watcher.enabled) { + this.log.bad(`server crashed`, 'with status code', code); + } else { + throw new Error(`server crashed with exit code [${code}]`); + } + } + }), + take(1), + share() + ); + + // throw errors if spawn fails + const error$ = Rx.fromEvent(proc, 'error').pipe( + map((error) => { + throw error; + }), + takeUntil(exit$) + ); + + // handles messages received from the child process + const msg$ = Rx.fromEvent<[any]>(proc, 'message').pipe( + tap(([received]) => { + if (!Array.isArray(received)) { + return; + } + + const msg = received[0]; + + if (msg === 'SERVER_LISTENING') { + this.ready$.next(true); + } + + // TODO: remove this once Pier is done migrating log rotation to KP + if (msg === 'RELOAD_LOGGING_CONFIG_FROM_SERVER_WORKER') { + // When receive that event from server worker + // forward a reloadLoggingConfig message to parent + // and child proc. This is only used by LogRotator service + // when the cluster mode is enabled + process.emit('message' as any, { reloadLoggingConfig: true } as any); + proc.send({ reloadLoggingConfig: true }); + } + }), + takeUntil(exit$) + ); + + // handle graceful shutdown requests + const triggerGracefulShutdown$ = gracefulShutdown$.pipe( + mergeMap(() => { + // signal to the process that it should exit + proc.kill('SIGINT'); + + // if the timer fires before exit$ we will send SIGINT + return Rx.timer(this.gracefulTimeout).pipe( + tap(() => { + this.log.warn( + `server didnt exit`, + `sent [SIGINT] to the server but it didn't exit within ${this.gracefulTimeout}ms, killing with SIGKILL` + ); + + proc.kill('SIGKILL'); + }) + ); + }), + + // if exit$ emits before the gracefulTimeout then this + // will unsub and cancel the timer + takeUntil(exit$) + ); + + return Rx.merge(log$, exit$, error$, msg$, triggerGracefulShutdown$); + }); + + subscriber.add( + Rx.concat([undefined], this.watcher.serverShouldRestart$()) + .pipe( + // on each tick unsubscribe from the previous server process + // causing it to be SIGKILL-ed, then setup a new one + switchMap(runServer), + ignoreElements() + ) + .subscribe(subscriber) + ); + }); +} diff --git a/src/cli/cluster/binder_for.ts b/src/dev/cli_dev_mode/get_active_inspect_flag.ts similarity index 58% rename from src/cli/cluster/binder_for.ts rename to src/dev/cli_dev_mode/get_active_inspect_flag.ts index e3eabc8d91fa5..219c05647b2dc 100644 --- a/src/cli/cluster/binder_for.ts +++ b/src/dev/cli_dev_mode/get_active_inspect_flag.ts @@ -17,14 +17,27 @@ * under the License. */ -import { BinderBase, Emitter } from './binder'; +import getopts from 'getopts'; +// @ts-expect-error no types available, very simple module https://github.com/evanlucas/argsplit +import argsplit from 'argsplit'; -export class BinderFor extends BinderBase { - constructor(private readonly emitter: Emitter) { - super(); +const execOpts = getopts(process.execArgv); +const envOpts = getopts(process.env.NODE_OPTIONS ? argsplit(process.env.NODE_OPTIONS) : []); + +export function getActiveInspectFlag() { + if (execOpts.inspect) { + return '--inspect'; + } + + if (execOpts['inspect-brk']) { + return '--inspect-brk'; + } + + if (envOpts.inspect) { + return '--inspect'; } - public on(...args: any[]) { - super.on(this.emitter, ...args); + if (envOpts['inspect-brk']) { + return '--inspect-brk'; } } diff --git a/src/dev/cli_dev_mode/get_server_watch_paths.test.ts b/src/dev/cli_dev_mode/get_server_watch_paths.test.ts new file mode 100644 index 0000000000000..ec0d5d013a782 --- /dev/null +++ b/src/dev/cli_dev_mode/get_server_watch_paths.test.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; + +import { REPO_ROOT, createAbsolutePathSerializer } from '@kbn/dev-utils'; + +import { getServerWatchPaths } from './get_server_watch_paths'; + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); + +it('produces the right watch and ignore list', () => { + const { watchPaths, ignorePaths } = getServerWatchPaths({ + pluginPaths: [Path.resolve(REPO_ROOT, 'x-pack/test/plugin_functional/plugins/resolver_test')], + pluginScanDirs: [ + Path.resolve(REPO_ROOT, 'src/plugins'), + Path.resolve(REPO_ROOT, 'test/plugin_functional/plugins'), + Path.resolve(REPO_ROOT, 'x-pack/plugins'), + ], + }); + + expect(watchPaths).toMatchInlineSnapshot(` + Array [ + /src/core, + /src/legacy/server, + /src/legacy/ui, + /src/legacy/utils, + /config, + /x-pack/test/plugin_functional/plugins/resolver_test, + /src/plugins, + /test/plugin_functional/plugins, + /x-pack/plugins, + ] + `); + + expect(ignorePaths).toMatchInlineSnapshot(` + Array [ + /\\[\\\\\\\\\\\\/\\]\\(\\\\\\.\\.\\*\\|node_modules\\|bower_components\\|target\\|public\\|__\\[a-z0-9_\\]\\+__\\|coverage\\)\\(\\[\\\\\\\\\\\\/\\]\\|\\$\\)/, + /\\\\\\.test\\\\\\.\\(js\\|tsx\\?\\)\\$/, + /\\\\\\.\\(md\\|sh\\|txt\\)\\$/, + /debug\\\\\\.log\\$/, + /src/plugins/*/test/**, + /src/plugins/*/build/**, + /src/plugins/*/target/**, + /src/plugins/*/scripts/**, + /src/plugins/*/docs/**, + /test/plugin_functional/plugins/*/test/**, + /test/plugin_functional/plugins/*/build/**, + /test/plugin_functional/plugins/*/target/**, + /test/plugin_functional/plugins/*/scripts/**, + /test/plugin_functional/plugins/*/docs/**, + /x-pack/plugins/*/test/**, + /x-pack/plugins/*/build/**, + /x-pack/plugins/*/target/**, + /x-pack/plugins/*/scripts/**, + /x-pack/plugins/*/docs/**, + /x-pack/test/plugin_functional/plugins/resolver_test/test/**, + /x-pack/test/plugin_functional/plugins/resolver_test/build/**, + /x-pack/test/plugin_functional/plugins/resolver_test/target/**, + /x-pack/test/plugin_functional/plugins/resolver_test/scripts/**, + /x-pack/test/plugin_functional/plugins/resolver_test/docs/**, + /x-pack/plugins/reporting/chromium, + /x-pack/plugins/security_solution/cypress, + /x-pack/plugins/apm/e2e, + /x-pack/plugins/apm/scripts, + /x-pack/plugins/canvas/canvas_plugin_src, + /x-pack/plugins/case/server/scripts, + /x-pack/plugins/lists/scripts, + /x-pack/plugins/lists/server/scripts, + /x-pack/plugins/security_solution/scripts, + /x-pack/plugins/security_solution/server/lib/detection_engine/scripts, + ] + `); +}); diff --git a/src/dev/cli_dev_mode/get_server_watch_paths.ts b/src/dev/cli_dev_mode/get_server_watch_paths.ts new file mode 100644 index 0000000000000..7fe05c649b738 --- /dev/null +++ b/src/dev/cli_dev_mode/get_server_watch_paths.ts @@ -0,0 +1,94 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; +import Fs from 'fs'; + +import { REPO_ROOT } from '@kbn/dev-utils'; + +interface Options { + pluginPaths: string[]; + pluginScanDirs: string[]; +} + +export type WatchPaths = ReturnType; + +export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) { + const fromRoot = (p: string) => Path.resolve(REPO_ROOT, p); + + const pluginInternalDirsIgnore = pluginScanDirs + .map((scanDir) => Path.resolve(scanDir, '*')) + .concat(pluginPaths) + .reduce( + (acc: string[], path) => [ + ...acc, + Path.resolve(path, 'test/**'), + Path.resolve(path, 'build/**'), + Path.resolve(path, 'target/**'), + Path.resolve(path, 'scripts/**'), + Path.resolve(path, 'docs/**'), + ], + [] + ); + + const watchPaths = Array.from( + new Set( + [ + fromRoot('src/core'), + fromRoot('src/legacy/server'), + fromRoot('src/legacy/ui'), + fromRoot('src/legacy/utils'), + fromRoot('config'), + ...pluginPaths, + ...pluginScanDirs, + ].map((path) => Path.resolve(path)) + ) + ); + + for (const watchPath of watchPaths) { + if (!Fs.existsSync(fromRoot(watchPath))) { + throw new Error( + `A watch directory [${watchPath}] does not exist, which will cause chokidar to fail. Either make sure the directory exists or remove it as a watch source in the ClusterManger` + ); + } + } + + const ignorePaths = [ + /[\\\/](\..*|node_modules|bower_components|target|public|__[a-z0-9_]+__|coverage)([\\\/]|$)/, + /\.test\.(js|tsx?)$/, + /\.(md|sh|txt)$/, + /debug\.log$/, + ...pluginInternalDirsIgnore, + fromRoot('x-pack/plugins/reporting/chromium'), + fromRoot('x-pack/plugins/security_solution/cypress'), + fromRoot('x-pack/plugins/apm/e2e'), + fromRoot('x-pack/plugins/apm/scripts'), + fromRoot('x-pack/plugins/canvas/canvas_plugin_src'), // prevents server from restarting twice for Canvas plugin changes, + fromRoot('x-pack/plugins/case/server/scripts'), + fromRoot('x-pack/plugins/lists/scripts'), + fromRoot('x-pack/plugins/lists/server/scripts'), + fromRoot('x-pack/plugins/security_solution/scripts'), + fromRoot('x-pack/plugins/security_solution/server/lib/detection_engine/scripts'), + ]; + + return { + watchPaths, + ignorePaths, + }; +} diff --git a/src/core/server/legacy/cli.js b/src/dev/cli_dev_mode/index.ts similarity index 93% rename from src/core/server/legacy/cli.js rename to src/dev/cli_dev_mode/index.ts index 28e14d28eecd3..92714c3740e9a 100644 --- a/src/core/server/legacy/cli.js +++ b/src/dev/cli_dev_mode/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { startRepl } from '../../../cli/repl'; +export * from './cli_dev_mode'; +export * from './log'; diff --git a/src/cli/cluster/log.ts b/src/dev/cli_dev_mode/log.ts similarity index 64% rename from src/cli/cluster/log.ts rename to src/dev/cli_dev_mode/log.ts index af73059c0758e..f349026ca9cab 100644 --- a/src/cli/cluster/log.ts +++ b/src/dev/cli_dev_mode/log.ts @@ -17,9 +17,18 @@ * under the License. */ +/* eslint-disable max-classes-per-file */ + import Chalk from 'chalk'; -export class Log { +export interface Log { + good(label: string, ...args: any[]): void; + warn(label: string, ...args: any[]): void; + bad(label: string, ...args: any[]): void; + write(label: string, ...args: any[]): void; +} + +export class CliLog implements Log { constructor(private readonly quiet: boolean, private readonly silent: boolean) {} good(label: string, ...args: any[]) { @@ -54,3 +63,35 @@ export class Log { console.log(` ${label.trim()} `, ...args); } } + +export class TestLog implements Log { + public readonly messages: Array<{ type: string; args: any[] }> = []; + + bad(label: string, ...args: any[]) { + this.messages.push({ + type: 'bad', + args: [label, ...args], + }); + } + + good(label: string, ...args: any[]) { + this.messages.push({ + type: 'good', + args: [label, ...args], + }); + } + + warn(label: string, ...args: any[]) { + this.messages.push({ + type: 'warn', + args: [label, ...args], + }); + } + + write(label: string, ...args: any[]) { + this.messages.push({ + type: 'write', + args: [label, ...args], + }); + } +} diff --git a/src/dev/cli_dev_mode/optimizer.test.ts b/src/dev/cli_dev_mode/optimizer.test.ts new file mode 100644 index 0000000000000..8a82012499b33 --- /dev/null +++ b/src/dev/cli_dev_mode/optimizer.test.ts @@ -0,0 +1,214 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PassThrough } from 'stream'; + +import * as Rx from 'rxjs'; +import { toArray } from 'rxjs/operators'; +import { OptimizerUpdate } from '@kbn/optimizer'; +import { observeLines, createReplaceSerializer } from '@kbn/dev-utils'; +import { firstValueFrom } from '@kbn/std'; + +import { Optimizer, Options } from './optimizer'; + +jest.mock('@kbn/optimizer'); +const realOptimizer = jest.requireActual('@kbn/optimizer'); +const { runOptimizer, OptimizerConfig, logOptimizerState } = jest.requireMock('@kbn/optimizer'); + +logOptimizerState.mockImplementation(realOptimizer.logOptimizerState); + +class MockOptimizerConfig {} + +const mockOptimizerUpdate = (phase: OptimizerUpdate['state']['phase']) => { + return { + state: { + compilerStates: [], + durSec: 0, + offlineBundles: [], + onlineBundles: [], + phase, + startTime: 100, + }, + }; +}; + +const defaultOptions: Options = { + enabled: true, + cache: true, + dist: true, + oss: true, + pluginPaths: ['/some/dir'], + quiet: true, + silent: true, + repoRoot: '/app', + runExamples: true, + watch: true, +}; + +function setup(options: Options = defaultOptions) { + const update$ = new Rx.Subject(); + + OptimizerConfig.create.mockImplementation(() => new MockOptimizerConfig()); + runOptimizer.mockImplementation(() => update$); + + const optimizer = new Optimizer(options); + + return { optimizer, update$ }; +} + +const subscriptions: Rx.Subscription[] = []; + +expect.addSnapshotSerializer(createReplaceSerializer(/\[\d\d:\d\d:\d\d\.\d\d\d\]/, '[timestamp]')); + +afterEach(() => { + for (const sub of subscriptions) { + sub.unsubscribe(); + } + subscriptions.length = 0; + + jest.clearAllMocks(); +}); + +it('uses options to create valid OptimizerConfig', () => { + setup(); + setup({ + ...defaultOptions, + cache: false, + dist: false, + runExamples: false, + oss: false, + pluginPaths: [], + repoRoot: '/foo/bar', + watch: false, + }); + + expect(OptimizerConfig.create.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "cache": true, + "dist": true, + "examples": true, + "includeCoreBundle": true, + "oss": true, + "pluginPaths": Array [ + "/some/dir", + ], + "repoRoot": "/app", + "watch": true, + }, + ], + Array [ + Object { + "cache": false, + "dist": false, + "examples": false, + "includeCoreBundle": true, + "oss": false, + "pluginPaths": Array [], + "repoRoot": "/foo/bar", + "watch": false, + }, + ], + ] + `); +}); + +it('is ready when optimizer phase is success or issue and logs in familiar format', async () => { + const writeLogTo = new PassThrough(); + const linesPromise = firstValueFrom(observeLines(writeLogTo).pipe(toArray())); + + const { update$, optimizer } = setup({ + ...defaultOptions, + quiet: false, + silent: false, + writeLogTo, + }); + + const history: any[] = ['']; + subscriptions.push( + optimizer.isReady$().subscribe({ + next(ready) { + history.push(`ready: ${ready}`); + }, + error(error) { + throw error; + }, + complete() { + history.push(`complete`); + }, + }) + ); + + subscriptions.push( + optimizer.run$.subscribe({ + error(error) { + throw error; + }, + }) + ); + + history.push(''); + update$.next(mockOptimizerUpdate('success')); + + history.push(''); + update$.next(mockOptimizerUpdate('running')); + + history.push(''); + update$.next(mockOptimizerUpdate('issue')); + + update$.complete(); + + expect(history).toMatchInlineSnapshot(` + Array [ + "", + "", + "ready: true", + "", + "ready: false", + "", + "ready: true", + ] + `); + + writeLogTo.end(); + const lines = await linesPromise; + expect(lines).toMatchInlineSnapshot(` + Array [ + "np bld log [timestamp] [success][@kbn/optimizer] 0 bundles compiled successfully after 0 sec", + "np bld log [timestamp] [error][@kbn/optimizer] webpack compile errors", + ] + `); +}); + +it('completes immedately and is immediately ready when disabled', () => { + const ready$ = new Rx.BehaviorSubject(undefined); + + const { optimizer, update$ } = setup({ + ...defaultOptions, + enabled: false, + }); + + subscriptions.push(optimizer.isReady$().subscribe(ready$)); + + expect(update$.observers).toHaveLength(0); + expect(runOptimizer).not.toHaveBeenCalled(); + expect(ready$).toHaveProperty('isStopped', true); + expect(ready$.getValue()).toBe(true); +}); diff --git a/src/dev/cli_dev_mode/optimizer.ts b/src/dev/cli_dev_mode/optimizer.ts new file mode 100644 index 0000000000000..9aac414f02b29 --- /dev/null +++ b/src/dev/cli_dev_mode/optimizer.ts @@ -0,0 +1,128 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Chalk from 'chalk'; +import moment from 'moment'; +import { Writable } from 'stream'; +import { tap } from 'rxjs/operators'; +import { + ToolingLog, + pickLevelFromFlags, + ToolingLogTextWriter, + parseLogLevel, +} from '@kbn/dev-utils'; +import * as Rx from 'rxjs'; +import { ignoreElements } from 'rxjs/operators'; +import { runOptimizer, OptimizerConfig, logOptimizerState } from '@kbn/optimizer'; + +export interface Options { + enabled: boolean; + repoRoot: string; + quiet: boolean; + silent: boolean; + watch: boolean; + cache: boolean; + dist: boolean; + oss: boolean; + runExamples: boolean; + pluginPaths: string[]; + writeLogTo?: Writable; +} + +export class Optimizer { + public readonly run$: Rx.Observable; + private readonly ready$ = new Rx.ReplaySubject(1); + + constructor(options: Options) { + if (!options.enabled) { + this.run$ = Rx.EMPTY; + this.ready$.next(true); + this.ready$.complete(); + return; + } + + const config = OptimizerConfig.create({ + repoRoot: options.repoRoot, + watch: options.watch, + includeCoreBundle: true, + cache: options.cache, + dist: options.dist, + oss: options.oss, + examples: options.runExamples, + pluginPaths: options.pluginPaths, + }); + + const dim = Chalk.dim('np bld'); + const name = Chalk.magentaBright('@kbn/optimizer'); + const time = () => moment().format('HH:mm:ss.SSS'); + const level = (msgType: string) => { + switch (msgType) { + case 'info': + return Chalk.green(msgType); + case 'success': + return Chalk.cyan(msgType); + case 'debug': + return Chalk.gray(msgType); + case 'warning': + return Chalk.yellowBright(msgType); + default: + return msgType; + } + }; + + const { flags: levelFlags } = parseLogLevel( + pickLevelFromFlags({ + quiet: options.quiet, + silent: options.silent, + }) + ); + + const log = new ToolingLog(); + const has = (obj: T, x: any): x is keyof T => obj.hasOwnProperty(x); + + log.setWriters([ + { + write(msg) { + if (has(levelFlags, msg.type) && !levelFlags[msg.type]) { + return false; + } + + ToolingLogTextWriter.write( + options.writeLogTo ?? process.stdout, + `${dim} log [${time()}] [${level(msg.type)}][${name}] `, + msg + ); + return true; + }, + }, + ]); + + this.run$ = runOptimizer(config).pipe( + logOptimizerState(log, config), + tap(({ state }) => { + this.ready$.next(state.phase === 'success' || state.phase === 'issue'); + }), + ignoreElements() + ); + } + + isReady$() { + return this.ready$.asObservable(); + } +} diff --git a/src/cli/cluster/binder.ts b/src/dev/cli_dev_mode/should_redirect_from_old_base_path.test.ts similarity index 57% rename from src/cli/cluster/binder.ts rename to src/dev/cli_dev_mode/should_redirect_from_old_base_path.test.ts index 55577e3a69e2b..f51b3743e0210 100644 --- a/src/cli/cluster/binder.ts +++ b/src/dev/cli_dev_mode/should_redirect_from_old_base_path.test.ts @@ -17,27 +17,23 @@ * under the License. */ -export interface Emitter { - on: (...args: any[]) => void; - off: (...args: any[]) => void; - addListener: Emitter['on']; - removeListener: Emitter['off']; -} - -export class BinderBase { - private disposal: Array<() => void> = []; - - public on(emitter: Emitter, ...args: any[]) { - const on = emitter.on || emitter.addListener; - const off = emitter.off || emitter.removeListener; - - on.apply(emitter, args); - this.disposal.push(() => off.apply(emitter, args)); +import { shouldRedirectFromOldBasePath } from './should_redirect_from_old_base_path'; +it.each([ + ['app/foo'], + ['app/bar'], + ['login'], + ['logout'], + ['status'], + ['s/1/status'], + ['s/2/app/foo'], +])('allows %s', (path) => { + if (!shouldRedirectFromOldBasePath(path)) { + throw new Error(`expected [${path}] to be redirected from old base path`); } +}); - public destroy() { - const destroyers = this.disposal; - this.disposal = []; - destroyers.forEach((fn) => fn()); +it.each([['api/foo'], ['v1/api/bar'], ['bundles/foo/foo.bundle.js']])('blocks %s', (path) => { + if (shouldRedirectFromOldBasePath(path)) { + throw new Error(`expected [${path}] to NOT be redirected from old base path`); } -} +}); diff --git a/src/plugins/data/common/index_patterns/lib/get_from_saved_object.ts b/src/dev/cli_dev_mode/should_redirect_from_old_base_path.ts similarity index 56% rename from src/plugins/data/common/index_patterns/lib/get_from_saved_object.ts rename to src/dev/cli_dev_mode/should_redirect_from_old_base_path.ts index 1630a4547b7a1..ba13932e60231 100644 --- a/src/plugins/data/common/index_patterns/lib/get_from_saved_object.ts +++ b/src/dev/cli_dev_mode/should_redirect_from_old_base_path.ts @@ -17,20 +17,19 @@ * under the License. */ -import { SavedObject } from 'src/core/public'; -import { get } from 'lodash'; -import { IIndexPattern, IndexPatternAttributes } from '../..'; - -export function getFromSavedObject( - savedObject: SavedObject -): IIndexPattern | undefined { - if (get(savedObject, 'attributes.fields') === undefined) { - return; +/** + * Determine which requested paths should be redirected from one basePath + * to another. We only do this for a supset of the paths so that people don't + * think that specifying a random three character string at the beginning of + * a URL will work. + */ +export function shouldRedirectFromOldBasePath(path: string) { + // strip `s/{id}` prefix when checking for need to redirect + if (path.startsWith('s/')) { + path = path.split('/').slice(2).join('/'); } - return { - id: savedObject.id, - fields: JSON.parse(savedObject.attributes.fields!), - title: savedObject.attributes.title, - }; + const isApp = path.startsWith('app/'); + const isKnownShortPath = ['login', 'logout', 'status'].includes(path); + return isApp || isKnownShortPath; } diff --git a/src/dev/cli_dev_mode/test_helpers.ts b/src/dev/cli_dev_mode/test_helpers.ts new file mode 100644 index 0000000000000..1e320de83588b --- /dev/null +++ b/src/dev/cli_dev_mode/test_helpers.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const extendedEnvSerializer: jest.SnapshotSerializerPlugin = { + test: (v) => + typeof v === 'object' && + v !== null && + typeof v.env === 'object' && + v.env !== null && + !v.env[''], + + serialize(val, config, indentation, depth, refs, printer) { + const customizations: Record = { + '': true, + }; + for (const [key, value] of Object.entries(val.env)) { + if (process.env[key] !== value) { + customizations[key] = value; + } + } + + return printer( + { + ...val, + env: customizations, + }, + config, + indentation, + depth, + refs + ); + }, +}; diff --git a/src/dev/cli_dev_mode/using_server_process.ts b/src/dev/cli_dev_mode/using_server_process.ts new file mode 100644 index 0000000000000..23423fcacb2fc --- /dev/null +++ b/src/dev/cli_dev_mode/using_server_process.ts @@ -0,0 +1,67 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import execa from 'execa'; +import * as Rx from 'rxjs'; + +import { getActiveInspectFlag } from './get_active_inspect_flag'; + +const ACTIVE_INSPECT_FLAG = getActiveInspectFlag(); + +interface ProcResource extends Rx.Unsubscribable { + proc: execa.ExecaChildProcess; + unsubscribe(): void; +} + +export function usingServerProcess( + script: string, + argv: string[], + fn: (proc: execa.ExecaChildProcess) => Rx.Observable +) { + return Rx.using( + (): ProcResource => { + const proc = execa.node(script, [...argv, '--logging.json=false'], { + stdio: 'pipe', + nodeOptions: [ + ...process.execArgv, + ...(ACTIVE_INSPECT_FLAG ? [`${ACTIVE_INSPECT_FLAG}=${process.debugPort + 1}`] : []), + ].filter((arg) => !arg.includes('inspect')), + env: { + ...process.env, + NODE_OPTIONS: process.env.NODE_OPTIONS, + isDevCliChild: 'true', + ELASTIC_APM_SERVICE_NAME: 'kibana', + ...(process.stdout.isTTY ? { FORCE_COLOR: 'true' } : {}), + }, + }); + + return { + proc, + unsubscribe() { + proc.kill('SIGKILL'); + }, + }; + }, + + (resource) => { + const { proc } = resource as ProcResource; + return fn(proc); + } + ); +} diff --git a/src/dev/cli_dev_mode/watcher.test.ts b/src/dev/cli_dev_mode/watcher.test.ts new file mode 100644 index 0000000000000..59dbab52a0cf6 --- /dev/null +++ b/src/dev/cli_dev_mode/watcher.test.ts @@ -0,0 +1,219 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EventEmitter } from 'events'; + +import * as Rx from 'rxjs'; +import { materialize, toArray } from 'rxjs/operators'; +import { firstValueFrom } from '@kbn/std'; + +import { TestLog } from './log'; +import { Watcher, Options } from './watcher'; + +class MockChokidar extends EventEmitter { + close = jest.fn(); +} + +let mockChokidar: MockChokidar | undefined; +jest.mock('chokidar'); +const chokidar = jest.requireMock('chokidar'); +function isMock(mock: MockChokidar | undefined): asserts mock is MockChokidar { + expect(mock).toBeInstanceOf(MockChokidar); +} + +chokidar.watch.mockImplementation(() => { + mockChokidar = new MockChokidar(); + return mockChokidar; +}); + +const subscriptions: Rx.Subscription[] = []; +const run = (watcher: Watcher) => { + const subscription = watcher.run$.subscribe({ + error(e) { + throw e; + }, + }); + subscriptions.push(subscription); + return subscription; +}; + +const log = new TestLog(); +const defaultOptions: Options = { + enabled: true, + log, + paths: ['foo.js', 'bar.js'], + ignore: [/^f/], + cwd: '/app/repo', +}; + +afterEach(() => { + jest.clearAllMocks(); + + if (mockChokidar) { + mockChokidar.removeAllListeners(); + mockChokidar = undefined; + } + + for (const sub of subscriptions) { + sub.unsubscribe(); + } + + subscriptions.length = 0; + log.messages.length = 0; +}); + +it('completes restart streams immediately when disabled', () => { + const watcher = new Watcher({ + ...defaultOptions, + enabled: false, + }); + + const restart$ = new Rx.BehaviorSubject(undefined); + subscriptions.push(watcher.serverShouldRestart$().subscribe(restart$)); + + run(watcher); + expect(restart$.isStopped).toBe(true); +}); + +it('calls chokidar.watch() with expected arguments', () => { + const watcher = new Watcher(defaultOptions); + expect(chokidar.watch).not.toHaveBeenCalled(); + run(watcher); + expect(chokidar.watch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Array [ + "foo.js", + "bar.js", + ], + Object { + "cwd": "/app/repo", + "ignored": Array [ + /\\^f/, + ], + }, + ], + ] + `); +}); + +it('closes chokidar watcher when unsubscribed', () => { + const sub = run(new Watcher(defaultOptions)); + isMock(mockChokidar); + expect(mockChokidar.close).not.toHaveBeenCalled(); + sub.unsubscribe(); + expect(mockChokidar.close).toHaveBeenCalledTimes(1); +}); + +it('rethrows chokidar errors', async () => { + const watcher = new Watcher(defaultOptions); + const promise = firstValueFrom(watcher.run$.pipe(materialize(), toArray())); + + isMock(mockChokidar); + mockChokidar.emit('error', new Error('foo bar')); + + const notifications = await promise; + expect(notifications).toMatchInlineSnapshot(` + Array [ + Notification { + "error": [Error: foo bar], + "hasValue": false, + "kind": "E", + "value": undefined, + }, + ] + `); +}); + +it('logs the count of add events after the ready event', () => { + run(new Watcher(defaultOptions)); + isMock(mockChokidar); + + mockChokidar.emit('add'); + mockChokidar.emit('add'); + mockChokidar.emit('add'); + mockChokidar.emit('add'); + mockChokidar.emit('ready'); + + expect(log.messages).toMatchInlineSnapshot(` + Array [ + Object { + "args": Array [ + "watching for changes", + "(4 files)", + ], + "type": "good", + }, + ] + `); +}); + +it('buffers subsequent changes before logging and notifying serverShouldRestart$', async () => { + const watcher = new Watcher(defaultOptions); + + const history: any[] = []; + subscriptions.push( + watcher + .serverShouldRestart$() + .pipe(materialize()) + .subscribe((n) => history.push(n)) + ); + + run(watcher); + expect(history).toMatchInlineSnapshot(`Array []`); + + isMock(mockChokidar); + mockChokidar.emit('ready'); + mockChokidar.emit('all', ['add', 'foo.js']); + mockChokidar.emit('all', ['add', 'bar.js']); + mockChokidar.emit('all', ['delete', 'bar.js']); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(log.messages).toMatchInlineSnapshot(` + Array [ + Object { + "args": Array [ + "watching for changes", + "(0 files)", + ], + "type": "good", + }, + Object { + "args": Array [ + "restarting server", + "due to changes in + - \\"foo.js\\" + - \\"bar.js\\"", + ], + "type": "warn", + }, + ] + `); + + expect(history).toMatchInlineSnapshot(` + Array [ + Notification { + "error": undefined, + "hasValue": true, + "kind": "N", + "value": undefined, + }, + ] + `); +}); diff --git a/src/dev/cli_dev_mode/watcher.ts b/src/dev/cli_dev_mode/watcher.ts new file mode 100644 index 0000000000000..95cf86d2c332d --- /dev/null +++ b/src/dev/cli_dev_mode/watcher.ts @@ -0,0 +1,122 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as Rx from 'rxjs'; +import { + map, + tap, + takeUntil, + count, + share, + buffer, + debounceTime, + ignoreElements, +} from 'rxjs/operators'; +import Chokidar from 'chokidar'; + +import { Log } from './log'; + +export interface Options { + enabled: boolean; + log: Log; + paths: string[]; + ignore: Array; + cwd: string; +} + +export class Watcher { + public readonly enabled: boolean; + + private readonly log: Log; + private readonly paths: string[]; + private readonly ignore: Array; + private readonly cwd: string; + + private readonly restart$ = new Rx.Subject(); + + constructor(options: Options) { + this.enabled = !!options.enabled; + this.log = options.log; + this.paths = options.paths; + this.ignore = options.ignore; + this.cwd = options.cwd; + } + + run$ = new Rx.Observable((subscriber) => { + if (!this.enabled) { + this.restart$.complete(); + subscriber.complete(); + return; + } + + const chokidar = Chokidar.watch(this.paths, { + cwd: this.cwd, + ignored: this.ignore, + }); + + subscriber.add(() => { + chokidar.close(); + }); + + const error$ = Rx.fromEvent(chokidar, 'error').pipe( + map((error) => { + throw error; + }) + ); + + const init$ = Rx.fromEvent(chokidar, 'add').pipe( + takeUntil(Rx.fromEvent(chokidar, 'ready')), + count(), + tap((fileCount) => { + this.log.good('watching for changes', `(${fileCount} files)`); + }) + ); + + const change$ = Rx.fromEvent<[string, string]>(chokidar, 'all').pipe( + map(([, path]) => path), + share() + ); + + subscriber.add( + Rx.merge( + error$, + Rx.concat( + init$, + change$.pipe( + buffer(change$.pipe(debounceTime(50))), + map((changes) => { + const paths = Array.from(new Set(changes)); + const prefix = paths.length > 1 ? '\n - ' : ' '; + const fileList = paths.reduce((list, file) => `${list || ''}${prefix}"${file}"`, ''); + + this.log.warn(`restarting server`, `due to changes in${fileList}`); + this.restart$.next(); + }) + ) + ) + ) + .pipe(ignoreElements()) + .subscribe(subscriber) + ); + }); + + serverShouldRestart$() { + return this.restart$.asObservable(); + } +} diff --git a/src/legacy/server/kbn_server.js b/src/legacy/server/kbn_server.js index 85d75b4e18772..14f083acd42c2 100644 --- a/src/legacy/server/kbn_server.js +++ b/src/legacy/server/kbn_server.js @@ -122,7 +122,7 @@ export default class KbnServer { if (process.env.isDevCliChild) { // help parent process know when we are ready - process.send(['WORKER_LISTENING']); + process.send(['SERVER_LISTENING']); } server.log( diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap index dac84c87faf97..4fb061ec816ad 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -304,7 +304,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` url="/plugins/home/assets/welcome_graphic_light_2x.png" >
{ expect(indexPatterns.refreshFields).toBeCalled(); }); + test('find', async () => { + const search = 'kibana*'; + const size = 10; + await indexPatterns.find('kibana*', size); + + expect(savedObjectsClient.find).lastCalledWith({ + type: 'index-pattern', + fields: ['title'], + search, + searchFields: ['title'], + perPage: size, + }); + }); + test('createAndSave', async () => { const title = 'kibana-*'; indexPatterns.createSavedObject = jest.fn(); diff --git a/src/plugins/data/common/index_patterns/lib/index.ts b/src/plugins/data/common/index_patterns/lib/index.ts index d9eccb6685ded..46dc49a95d204 100644 --- a/src/plugins/data/common/index_patterns/lib/index.ts +++ b/src/plugins/data/common/index_patterns/lib/index.ts @@ -19,7 +19,6 @@ export { IndexPatternMissingIndices } from './errors'; export { getTitle } from './get_title'; -export { getFromSavedObject } from './get_from_saved_object'; export { isDefault } from './is_default'; export * from './types'; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 1c07b4b99e4c0..9eced777a8e36 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -235,7 +235,6 @@ import { ILLEGAL_CHARACTERS, isDefault, validateIndexPattern, - getFromSavedObject, flattenHitWrapper, formatHitProvider, } from './index_patterns'; @@ -252,7 +251,6 @@ export const indexPatterns = { isFilterable, isNestedField, validate: validateIndexPattern, - getFromSavedObject, flattenHitWrapper, formatHitProvider, }; diff --git a/src/plugins/data/public/index_patterns/index.ts b/src/plugins/data/public/index_patterns/index.ts index 9cd5e5a4736f1..6c39457599c74 100644 --- a/src/plugins/data/public/index_patterns/index.ts +++ b/src/plugins/data/public/index_patterns/index.ts @@ -23,7 +23,6 @@ export { ILLEGAL_CHARACTERS_VISIBLE, ILLEGAL_CHARACTERS, validateIndexPattern, - getFromSavedObject, isDefault, } from '../../common/index_patterns/lib'; export { flattenHitWrapper, formatHitProvider, onRedirectNoIndexPattern } from './index_patterns'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 339a014b9d731..a0c25c2ce2a38 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -28,6 +28,7 @@ import { EuiBreadcrumb } from '@elastic/eui'; import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiComboBoxProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; +import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; import { EventEmitter } from 'events'; import { ExclusiveUnion } from '@elastic/eui'; @@ -78,7 +79,6 @@ import { Required } from '@kbn/utility-types'; import * as Rx from 'rxjs'; import { SavedObject } from 'kibana/server'; import { SavedObject as SavedObject_2 } from 'src/core/server'; -import { SavedObject as SavedObject_3 } from 'src/core/public'; import { SavedObjectReference } from 'src/core/types'; import { SavedObjectsClientContract } from 'src/core/public'; import { SavedObjectsFindResponse } from 'kibana/server'; @@ -1336,7 +1336,6 @@ export const indexPatterns: { isFilterable: typeof isFilterable; isNestedField: typeof isNestedField; validate: typeof validateIndexPattern; - getFromSavedObject: typeof getFromSavedObject; flattenHitWrapper: typeof flattenHitWrapper; formatHitProvider: typeof formatHitProvider; }; @@ -2436,27 +2435,26 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:220:23 - (ae-forgotten-export) The symbol "datatableToCSV" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:428:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:429:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:432:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:433:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:436:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:245:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:245:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:245:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:245:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:245:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:407:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:407:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:407:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:407:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:426:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:430:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:431:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:434:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:45:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/search/session/session_service.ts:46:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx index 780986e02be93..bc710637b8f84 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx @@ -110,7 +110,6 @@ function FilterBarUI(props: Props) { closePopover={() => setIsAddFilterPopoverOpen(false)} anchorPosition="downLeft" panelPaddingSize="none" - ownFocus={true} initialFocus=".filterEditor__hiddenItem" repositionOnScroll > diff --git a/src/plugins/data/public/ui/query_string_input/fetch_index_patterns.ts b/src/plugins/data/public/ui/query_string_input/fetch_index_patterns.ts index 127dc0f1f41d3..7d6b4dd7acaf2 100644 --- a/src/plugins/data/public/ui/query_string_input/fetch_index_patterns.ts +++ b/src/plugins/data/public/ui/query_string_input/fetch_index_patterns.ts @@ -17,39 +17,26 @@ * under the License. */ import { isEmpty } from 'lodash'; -import { IUiSettingsClient, SavedObjectsClientContract } from 'src/core/public'; -import { indexPatterns, IndexPatternAttributes } from '../..'; +import { IndexPatternsContract } from '../..'; export async function fetchIndexPatterns( - savedObjectsClient: SavedObjectsClientContract, - indexPatternStrings: string[], - uiSettings: IUiSettingsClient + indexPatternsService: IndexPatternsContract, + indexPatternStrings: string[] ) { if (!indexPatternStrings || isEmpty(indexPatternStrings)) { return []; } const searchString = indexPatternStrings.map((string) => `"${string}"`).join(' | '); - const indexPatternsFromSavedObjects = await savedObjectsClient.find({ - type: 'index-pattern', - fields: ['title', 'fields'], - search: searchString, - searchFields: ['title'], - }); - const exactMatches = indexPatternsFromSavedObjects.savedObjects.filter((savedObject) => { - return indexPatternStrings.includes(savedObject.attributes.title); - }); - - const defaultIndex = uiSettings.get('defaultIndex'); + const exactMatches = (await indexPatternsService.find(searchString)).filter((ip) => + indexPatternStrings.includes(ip.title) + ); const allMatches = exactMatches.length === indexPatternStrings.length ? exactMatches - : [ - ...exactMatches, - await savedObjectsClient.get('index-pattern', defaultIndex), - ]; + : [...exactMatches, await indexPatternsService.getDefault()]; - return allMatches.map(indexPatterns.getFromSavedObject); + return allMatches; } diff --git a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx index 3957e59388acf..63bf47671a84a 100644 --- a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx +++ b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx @@ -71,7 +71,6 @@ export function QueryLanguageSwitcher(props: Props) { { }); it('Should accept index pattern strings and fetch the full object', () => { + const patternStrings = ['logstash-*']; mockFetchIndexPatterns.mockClear(); mount( wrapQueryStringInputInContext({ query: kqlQuery, onSubmit: noop, - indexPatterns: ['logstash-*'], + indexPatterns: patternStrings, disableAutoFocus: true, }) ); - - expect(mockFetchIndexPatterns).toHaveBeenCalledWith( - startMock.savedObjects.client, - ['logstash-*'], - startMock.uiSettings - ); + expect(mockFetchIndexPatterns.mock.calls[0][1]).toStrictEqual(patternStrings); }); }); diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index a6d22ce3eb473..ad6c60550c01e 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -138,9 +138,8 @@ export default class QueryStringInputUI extends Component { const currentAbortController = this.fetchIndexPatternsAbortController; const objectPatternsFromStrings = (await fetchIndexPatterns( - this.services.savedObjects!.client, - stringPatterns, - this.services.uiSettings! + this.services.data.indexPatterns, + stringPatterns )) as IIndexPattern[]; if (!currentAbortController.signal.aborted) { diff --git a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx index 8582f4a12fa38..536a79899c5ee 100644 --- a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx +++ b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx @@ -206,7 +206,6 @@ export function SavedQueryManagementComponent({ anchorPosition="downLeft" panelPaddingSize="none" buffer={-8} - ownFocus repositionOnScroll >
setPopoverIsOpen(false)} display="block" panelPaddingSize="s" - ownFocus >
diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index c10d1fba5ad62..f95e512dfb66e 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -197,7 +197,6 @@ export function DiscoverField({ return ( { popover = component.find(EuiPopover); expect(popover.prop('isOpen')).toBe(false); }); - - test('click outside popover should close popover', () => { - const triggerDocumentMouseDown: EventHandler = (e: ReactMouseEvent) => { - const event = new Event('mousedown'); - // @ts-ignore - event.euiGeneratedBy = e.nativeEvent.euiGeneratedBy; - document.dispatchEvent(event); - }; - const triggerDocumentMouseUp: EventHandler = (e: ReactMouseEvent) => { - const event = new Event('mouseup'); - // @ts-ignore - event.euiGeneratedBy = e.nativeEvent.euiGeneratedBy; - document.dispatchEvent(event); - }; - const component = mountWithIntl( -
- -
- ); - const btn = findTestSubject(component, 'toggleFieldFilterButton'); - btn.simulate('click'); - let popover = component.find(EuiPopover); - expect(popover.length).toBe(1); - expect(popover.prop('isOpen')).toBe(true); - component.find('#wrapperId').simulate('mousedown'); - component.find('#wrapperId').simulate('mouseup'); - popover = component.find(EuiPopover); - expect(popover.prop('isOpen')).toBe(false); - }); }); diff --git a/src/plugins/discover/public/application/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx index 2874e2483275b..59ab032e6098d 100644 --- a/src/plugins/discover/public/application/components/table/table.test.tsx +++ b/src/plugins/discover/public/application/components/table/table.test.tsx @@ -174,18 +174,6 @@ describe('DocViewTable at Discover', () => { }); } }); - - (['noMappingWarning'] as const).forEach((element) => { - const elementExist = check[element]; - - if (typeof elementExist === 'boolean') { - const el = findTestSubject(rowComponent, element); - - it(`renders ${element} for '${check._property}' correctly`, () => { - expect(el.length).toBe(elementExist ? 1 : 0); - }); - } - }); }); }); diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index d57447eab9e26..9c136e94a3d2a 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -19,7 +19,7 @@ import React, { useState } from 'react'; import { escapeRegExp } from 'lodash'; import { DocViewTableRow } from './table_row'; -import { arrayContainsObjects, trimAngularSpan } from './table_helper'; +import { trimAngularSpan } from './table_helper'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; const COLLAPSE_LINE_LENGTH = 350; @@ -72,11 +72,7 @@ export function DocViewTable({ } } : undefined; - const isArrayOfObjects = - Array.isArray(flattened[field]) && arrayContainsObjects(flattened[field]); const displayUnderscoreWarning = !mapping(field) && field.indexOf('_') === 0; - const displayNoMappingWarning = - !mapping(field) && !displayUnderscoreWarning && !isArrayOfObjects; // Discover doesn't flatten arrays of objects, so for documents with an `object` or `nested` field that // contains an array, Discover will only detect the top level root field. We want to detect when those @@ -128,7 +124,6 @@ export function DocViewTable({ fieldMapping={mapping(field)} fieldType={String(fieldType)} displayUnderscoreWarning={displayUnderscoreWarning} - displayNoMappingWarning={displayNoMappingWarning} isCollapsed={isCollapsed} isCollapsible={isCollapsible} isColumnActive={Array.isArray(columns) && columns.includes(field)} diff --git a/src/plugins/discover/public/application/components/table/table_row.tsx b/src/plugins/discover/public/application/components/table/table_row.tsx index 3ebf3c435916b..e7d663158acc0 100644 --- a/src/plugins/discover/public/application/components/table/table_row.tsx +++ b/src/plugins/discover/public/application/components/table/table_row.tsx @@ -24,7 +24,6 @@ import { DocViewTableRowBtnFilterRemove } from './table_row_btn_filter_remove'; import { DocViewTableRowBtnToggleColumn } from './table_row_btn_toggle_column'; import { DocViewTableRowBtnCollapse } from './table_row_btn_collapse'; import { DocViewTableRowBtnFilterExists } from './table_row_btn_filter_exists'; -import { DocViewTableRowIconNoMapping } from './table_row_icon_no_mapping'; import { DocViewTableRowIconUnderscore } from './table_row_icon_underscore'; import { FieldName } from '../field_name/field_name'; @@ -32,7 +31,6 @@ export interface Props { field: string; fieldMapping?: FieldMapping; fieldType: string; - displayNoMappingWarning: boolean; displayUnderscoreWarning: boolean; isCollapsible: boolean; isColumnActive: boolean; @@ -48,7 +46,6 @@ export function DocViewTableRow({ field, fieldMapping, fieldType, - displayNoMappingWarning, displayUnderscoreWarning, isCollapsible, isCollapsed, @@ -80,7 +77,6 @@ export function DocViewTableRow({ )} {displayUnderscoreWarning && } - {displayNoMappingWarning && }
Index Patterns page', - } - ); - return ( - - ); -} diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index c1db6e98e54de..a839004828e4b 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -28,6 +28,7 @@ import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiComboBoxProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; import { EventEmitter } from 'events'; import { ExclusiveUnion } from '@elastic/eui'; diff --git a/src/plugins/home/public/application/components/recently_accessed.js b/src/plugins/home/public/application/components/recently_accessed.js index 181968a2e063a..80119a5063f14 100644 --- a/src/plugins/home/public/application/components/recently_accessed.js +++ b/src/plugins/home/public/application/components/recently_accessed.js @@ -99,7 +99,6 @@ export class RecentlyAccessed extends Component { return (
{ return ( ( ({ + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, +})); + const defaultKibanaIndex = '.kibana'; const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const scopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); const actionExecutor = actionExecutorMock.create(); const authorization = actionsAuthorizationMock.create(); const executionEnqueuer = jest.fn(); -const request = {} as KibanaRequest; +const request = httpServerMock.createKibanaRequest(); +const auditLogger = auditServiceMock.create().asScoped(request); const mockTaskManager = taskManagerMock.createSetup(); @@ -68,6 +76,7 @@ beforeEach(() => { executionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, + auditLogger, }); }); @@ -142,6 +151,95 @@ describe('create()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when creating a connector', async () => { + const savedObjectCreateResult = { + id: '1', + type: 'action', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }; + actionTypeRegistry.register({ + id: savedObjectCreateResult.attributes.actionTypeId, + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + + await actionsClient.create({ + action: { + ...savedObjectCreateResult.attributes, + secrets: {}, + }, + }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_create', + outcome: 'unknown', + }), + kibana: { saved_object: { id: 'mock-saved-object-id', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to create a connector', async () => { + const savedObjectCreateResult = { + id: '1', + type: 'action', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }; + actionTypeRegistry.register({ + id: savedObjectCreateResult.attributes.actionTypeId, + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + async () => + await actionsClient.create({ + action: { + ...savedObjectCreateResult.attributes, + secrets: {}, + }, + }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_create', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: 'mock-saved-object-id', + type: 'action', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); + test('creates an action with all given properties', async () => { const savedObjectCreateResult = { id: '1', @@ -185,6 +283,9 @@ describe('create()', () => { "name": "my name", "secrets": Object {}, }, + Object { + "id": "mock-saved-object-id", + }, ] `); }); @@ -289,6 +390,9 @@ describe('create()', () => { "name": "my name", "secrets": Object {}, }, + Object { + "id": "mock-saved-object-id", + }, ] `); }); @@ -440,7 +544,7 @@ describe('get()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); }); - test('throws when user is not authorised to create the type of action', async () => { + test('throws when user is not authorised to get the type of action', async () => { unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'type', @@ -463,7 +567,7 @@ describe('get()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); }); - test('throws when user is not authorised to create preconfigured of action', async () => { + test('throws when user is not authorised to get preconfigured of action', async () => { actionsClient = new ActionsClient({ actionTypeRegistry, unsecuredSavedObjectsClient, @@ -501,6 +605,61 @@ describe('get()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when getting a connector', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'type', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }); + + await actionsClient.get({ id: '1' }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_get', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to get a connector', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'type', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }); + + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.get({ id: '1' })).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_get', + outcome: 'failure', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('calls unsecuredSavedObjectsClient with id', async () => { unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -632,6 +791,64 @@ describe('getAll()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when searching connectors', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: { + name: 'test', + config: { + foo: 'bar', + }, + }, + score: 1, + references: [], + }, + ], + }); + scopedClusterClient.callAsInternalUser.mockResolvedValueOnce({ + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + }); + + await actionsClient.getAll(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to search connectors', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.getAll()).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'failure', + }), + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('calls unsecuredSavedObjectsClient with parameters', async () => { const expectedResult = { total: 1, @@ -773,6 +990,62 @@ describe('getBulk()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when bulk getting connectors', async () => { + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: 'action', + attributes: { + actionTypeId: 'test', + name: 'test', + config: { + foo: 'bar', + }, + }, + references: [], + }, + ], + }); + scopedClusterClient.callAsInternalUser.mockResolvedValueOnce({ + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + }); + + await actionsClient.getBulk(['1']); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_get', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to bulk get connectors', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.getBulk(['1'])).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_get', + outcome: 'failure', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('calls getBulk unsecuredSavedObjectsClient with parameters', async () => { unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -864,6 +1137,39 @@ describe('delete()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when deleting a connector', async () => { + await actionsClient.delete({ id: '1' }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_delete', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to delete a connector', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.delete({ id: '1' })).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_delete', + outcome: 'failure', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('calls unsecuredSavedObjectsClient with id', async () => { const expectedResult = Symbol(); unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); @@ -880,42 +1186,43 @@ describe('delete()', () => { }); describe('update()', () => { + function updateOperation(): ReturnType { + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'my-action', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + name: 'my name', + config: {}, + secrets: {}, + }, + references: [], + }); + return actionsClient.update({ + id: 'my-action', + action: { + name: 'my name', + config: {}, + secrets: {}, + }, + }); + } + describe('authorization', () => { - function updateOperation(): ReturnType { - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - executor, - }); - unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ - id: '1', - type: 'action', - attributes: { - actionTypeId: 'my-action-type', - }, - references: [], - }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: 'my-action', - type: 'action', - attributes: { - actionTypeId: 'my-action-type', - name: 'my name', - config: {}, - secrets: {}, - }, - references: [], - }); - return actionsClient.update({ - id: 'my-action', - action: { - name: 'my name', - config: {}, - secrets: {}, - }, - }); - } test('ensures user is authorised to update actions', async () => { await updateOperation(); expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); @@ -934,6 +1241,39 @@ describe('update()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when updating a connector', async () => { + await updateOperation(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_update', + outcome: 'unknown', + }), + kibana: { saved_object: { id: 'my-action', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to update a connector', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(updateOperation()).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_update', + outcome: 'failure', + }), + kibana: { saved_object: { id: 'my-action', type: 'action' } }, + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('updates an action with all given properties', async () => { actionTypeRegistry.register({ id: 'my-action-type', diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 0d41b520501ad..ab693dc340c92 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -4,16 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ import Boom from '@hapi/boom'; + +import { i18n } from '@kbn/i18n'; +import { omitBy, isUndefined } from 'lodash'; import { ILegacyScopedClusterClient, SavedObjectsClientContract, SavedObjectAttributes, SavedObject, KibanaRequest, -} from 'src/core/server'; - -import { i18n } from '@kbn/i18n'; -import { omitBy, isUndefined } from 'lodash'; + SavedObjectsUtils, +} from '../../../../src/core/server'; +import { AuditLogger, EventOutcome } from '../../security/server'; +import { ActionType } from '../common'; import { ActionTypeRegistry } from './action_type_registry'; import { validateConfig, validateSecrets, ActionExecutorContract } from './lib'; import { @@ -30,11 +33,11 @@ import { ExecuteOptions as EnqueueExecutionOptions, } from './create_execute_function'; import { ActionsAuthorization } from './authorization/actions_authorization'; -import { ActionType } from '../common'; import { getAuthorizationModeBySource, AuthorizationMode, } from './authorization/get_authorization_mode_by_source'; +import { connectorAuditEvent, ConnectorAuditAction } from './lib/audit_events'; // We are assuming there won't be many actions. This is why we will load // all the actions in advance and assume the total count to not go over 10000. @@ -65,6 +68,7 @@ interface ConstructorOptions { executionEnqueuer: ExecutionEnqueuer; request: KibanaRequest; authorization: ActionsAuthorization; + auditLogger?: AuditLogger; } interface UpdateOptions { @@ -82,6 +86,7 @@ export class ActionsClient { private readonly request: KibanaRequest; private readonly authorization: ActionsAuthorization; private readonly executionEnqueuer: ExecutionEnqueuer; + private readonly auditLogger?: AuditLogger; constructor({ actionTypeRegistry, @@ -93,6 +98,7 @@ export class ActionsClient { executionEnqueuer, request, authorization, + auditLogger, }: ConstructorOptions) { this.actionTypeRegistry = actionTypeRegistry; this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; @@ -103,6 +109,7 @@ export class ActionsClient { this.executionEnqueuer = executionEnqueuer; this.request = request; this.authorization = authorization; + this.auditLogger = auditLogger; } /** @@ -111,7 +118,20 @@ export class ActionsClient { public async create({ action: { actionTypeId, name, config, secrets }, }: CreateOptions): Promise { - await this.authorization.ensureAuthorized('create', actionTypeId); + const id = SavedObjectsUtils.generateId(); + + try { + await this.authorization.ensureAuthorized('create', actionTypeId); + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id }, + error, + }) + ); + throw error; + } const actionType = this.actionTypeRegistry.get(actionTypeId); const validatedActionTypeConfig = validateConfig(actionType, config); @@ -119,12 +139,24 @@ export class ActionsClient { this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); - const result = await this.unsecuredSavedObjectsClient.create('action', { - actionTypeId, - name, - config: validatedActionTypeConfig as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, - }); + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id }, + outcome: EventOutcome.UNKNOWN, + }) + ); + + const result = await this.unsecuredSavedObjectsClient.create( + 'action', + { + actionTypeId, + name, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }, + { id } + ); return { id: result.id, @@ -139,21 +171,32 @@ export class ActionsClient { * Update action */ public async update({ id, action }: UpdateOptions): Promise { - await this.authorization.ensureAuthorized('update'); - - if ( - this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== - undefined - ) { - throw new PreconfiguredActionDisabledModificationError( - i18n.translate('xpack.actions.serverSideErrors.predefinedActionUpdateDisabled', { - defaultMessage: 'Preconfigured action {id} is not allowed to update.', - values: { - id, - }, - }), - 'update' + try { + await this.authorization.ensureAuthorized('update'); + + if ( + this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== + undefined + ) { + throw new PreconfiguredActionDisabledModificationError( + i18n.translate('xpack.actions.serverSideErrors.predefinedActionUpdateDisabled', { + defaultMessage: 'Preconfigured action {id} is not allowed to update.', + values: { + id, + }, + }), + 'update' + ); + } + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.UPDATE, + savedObject: { type: 'action', id }, + error, + }) ); + throw error; } const { attributes, @@ -168,6 +211,14 @@ export class ActionsClient { this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.UPDATE, + savedObject: { type: 'action', id }, + outcome: EventOutcome.UNKNOWN, + }) + ); + const result = await this.unsecuredSavedObjectsClient.create( 'action', { @@ -201,12 +252,30 @@ export class ActionsClient { * Get an action */ public async get({ id }: { id: string }): Promise { - await this.authorization.ensureAuthorized('get'); + try { + await this.authorization.ensureAuthorized('get'); + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + error, + }) + ); + throw error; + } const preconfiguredActionsList = this.preconfiguredActions.find( (preconfiguredAction) => preconfiguredAction.id === id ); if (preconfiguredActionsList !== undefined) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + }) + ); + return { id, actionTypeId: preconfiguredActionsList.actionTypeId, @@ -214,8 +283,16 @@ export class ActionsClient { isPreconfigured: true, }; } + const result = await this.unsecuredSavedObjectsClient.get('action', id); + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + }) + ); + return { id, actionTypeId: result.attributes.actionTypeId, @@ -229,7 +306,17 @@ export class ActionsClient { * Get all actions with preconfigured list */ public async getAll(): Promise { - await this.authorization.ensureAuthorized('get'); + try { + await this.authorization.ensureAuthorized('get'); + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.FIND, + error, + }) + ); + throw error; + } const savedObjectsActions = ( await this.unsecuredSavedObjectsClient.find({ @@ -238,6 +325,15 @@ export class ActionsClient { }) ).saved_objects.map(actionFromSavedObject); + savedObjectsActions.forEach(({ id }) => + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.FIND, + savedObject: { type: 'action', id }, + }) + ) + ); + const mergedResult = [ ...savedObjectsActions, ...this.preconfiguredActions.map((preconfiguredAction) => ({ @@ -258,7 +354,20 @@ export class ActionsClient { * Get bulk actions with preconfigured list */ public async getBulk(ids: string[]): Promise { - await this.authorization.ensureAuthorized('get'); + try { + await this.authorization.ensureAuthorized('get'); + } catch (error) { + ids.forEach((id) => + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + error, + }) + ) + ); + throw error; + } const actionResults = new Array(); for (const actionId of ids) { @@ -283,6 +392,17 @@ export class ActionsClient { const bulkGetOpts = actionSavedObjectsIds.map((id) => ({ id, type: 'action' })); const bulkGetResult = await this.unsecuredSavedObjectsClient.bulkGet(bulkGetOpts); + bulkGetResult.saved_objects.forEach(({ id, error }) => { + if (!error && this.auditLogger) { + this.auditLogger.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + }) + ); + } + }); + for (const action of bulkGetResult.saved_objects) { if (action.error) { throw Boom.badRequest( @@ -298,22 +418,42 @@ export class ActionsClient { * Delete action */ public async delete({ id }: { id: string }) { - await this.authorization.ensureAuthorized('delete'); - - if ( - this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== - undefined - ) { - throw new PreconfiguredActionDisabledModificationError( - i18n.translate('xpack.actions.serverSideErrors.predefinedActionDeleteDisabled', { - defaultMessage: 'Preconfigured action {id} is not allowed to delete.', - values: { - id, - }, - }), - 'delete' + try { + await this.authorization.ensureAuthorized('delete'); + + if ( + this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== + undefined + ) { + throw new PreconfiguredActionDisabledModificationError( + i18n.translate('xpack.actions.serverSideErrors.predefinedActionDeleteDisabled', { + defaultMessage: 'Preconfigured action {id} is not allowed to delete.', + values: { + id, + }, + }), + 'delete' + ); + } + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.DELETE, + savedObject: { type: 'action', id }, + error, + }) ); + throw error; } + + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.DELETE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'action', id }, + }) + ); + return await this.unsecuredSavedObjectsClient.delete('action', id); } diff --git a/x-pack/plugins/actions/server/lib/audit_events.test.ts b/x-pack/plugins/actions/server/lib/audit_events.test.ts new file mode 100644 index 0000000000000..6c2fd99c2eafd --- /dev/null +++ b/x-pack/plugins/actions/server/lib/audit_events.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EventOutcome } from '../../../security/server/audit'; +import { ConnectorAuditAction, connectorAuditEvent } from './audit_events'; + +describe('#connectorAuditEvent', () => { + test('creates event with `unknown` outcome', () => { + expect( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'action', id: 'ACTION_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "connector_create", + "category": "database", + "outcome": "unknown", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ACTION_ID", + "type": "action", + }, + }, + "message": "User is creating connector [id=ACTION_ID]", + } + `); + }); + + test('creates event with `success` outcome', () => { + expect( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id: 'ACTION_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "connector_create", + "category": "database", + "outcome": "success", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ACTION_ID", + "type": "action", + }, + }, + "message": "User has created connector [id=ACTION_ID]", + } + `); + }); + + test('creates event with `failure` outcome', () => { + expect( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id: 'ACTION_ID' }, + error: new Error('ERROR_MESSAGE'), + }) + ).toMatchInlineSnapshot(` + Object { + "error": Object { + "code": "Error", + "message": "ERROR_MESSAGE", + }, + "event": Object { + "action": "connector_create", + "category": "database", + "outcome": "failure", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ACTION_ID", + "type": "action", + }, + }, + "message": "Failed attempt to create connector [id=ACTION_ID]", + } + `); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/audit_events.ts b/x-pack/plugins/actions/server/lib/audit_events.ts new file mode 100644 index 0000000000000..7d25b5c0cd479 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/audit_events.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AuditEvent, EventOutcome, EventCategory, EventType } from '../../../security/server'; + +export enum ConnectorAuditAction { + CREATE = 'connector_create', + GET = 'connector_get', + UPDATE = 'connector_update', + DELETE = 'connector_delete', + FIND = 'connector_find', + EXECUTE = 'connector_execute', +} + +type VerbsTuple = [string, string, string]; + +const eventVerbs: Record = { + connector_create: ['create', 'creating', 'created'], + connector_get: ['access', 'accessing', 'accessed'], + connector_update: ['update', 'updating', 'updated'], + connector_delete: ['delete', 'deleting', 'deleted'], + connector_find: ['access', 'accessing', 'accessed'], + connector_execute: ['execute', 'executing', 'executed'], +}; + +const eventTypes: Record = { + connector_create: EventType.CREATION, + connector_get: EventType.ACCESS, + connector_update: EventType.CHANGE, + connector_delete: EventType.DELETION, + connector_find: EventType.ACCESS, + connector_execute: undefined, +}; + +export interface ConnectorAuditEventParams { + action: ConnectorAuditAction; + outcome?: EventOutcome; + savedObject?: NonNullable['saved_object']; + error?: Error; +} + +export function connectorAuditEvent({ + action, + savedObject, + outcome, + error, +}: ConnectorAuditEventParams): AuditEvent { + const doc = savedObject ? `connector [id=${savedObject.id}]` : 'a connector'; + const [present, progressive, past] = eventVerbs[action]; + const message = error + ? `Failed attempt to ${present} ${doc}` + : outcome === EventOutcome.UNKNOWN + ? `User is ${progressive} ${doc}` + : `User has ${past} ${doc}`; + const type = eventTypes[action]; + + return { + message, + event: { + action, + category: EventCategory.DATABASE, + type, + outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS), + }, + kibana: { + saved_object: savedObject, + }, + error: error && { + code: error.name, + message: error.message, + }, + }; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index e61936321b8e0..6e37d4bd7a92a 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -314,6 +314,7 @@ export class ActionsPlugin implements Plugin, Plugi isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, preconfiguredActions, }), + auditLogger: this.security?.audit.asScoped(request), }); }; @@ -439,6 +440,7 @@ export class ActionsPlugin implements Plugin, Plugi preconfiguredActions, actionExecutor, instantiateAuthorization, + security, } = this; return async function actionsRouteHandlerContext(context, request) { @@ -468,6 +470,7 @@ export class ActionsPlugin implements Plugin, Plugi isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, preconfiguredActions, }), + auditLogger: security?.audit.asScoped(request), }); }, listTypes: actionTypeRegistry!.list.bind(actionTypeRegistry!), diff --git a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts index c83e24c5a45f4..d697817be734b 100644 --- a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts @@ -13,7 +13,8 @@ import { SavedObjectReference, SavedObject, PluginInitializerContext, -} from 'src/core/server'; + SavedObjectsUtils, +} from '../../../../../src/core/server'; import { esKuery } from '../../../../../src/plugins/data/server'; import { ActionsClient, ActionsAuthorization } from '../../../actions/server'; import { @@ -44,10 +45,12 @@ import { IEventLogClient } from '../../../../plugins/event_log/server'; import { parseIsoOrRelativeDate } from '../lib/iso_or_relative_date'; import { alertInstanceSummaryFromEventLog } from '../lib/alert_instance_summary_from_event_log'; import { IEvent } from '../../../event_log/server'; +import { AuditLogger, EventOutcome } from '../../../security/server'; import { parseDuration } from '../../common/parse_duration'; import { retryIfConflicts } from '../lib/retry_if_conflicts'; import { partiallyUpdateAlert } from '../saved_objects'; import { markApiKeyForInvalidation } from '../invalidate_pending_api_keys/mark_api_key_for_invalidation'; +import { alertAuditEvent, AlertAuditAction } from './audit_events'; export interface RegistryAlertTypeWithAuth extends RegistryAlertType { authorizedConsumers: string[]; @@ -75,6 +78,7 @@ export interface ConstructorOptions { getActionsClient: () => Promise; getEventLogClient: () => Promise; kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; + auditLogger?: AuditLogger; } export interface MuteOptions extends IndexType { @@ -176,6 +180,7 @@ export class AlertsClient { private readonly getEventLogClient: () => Promise; private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; private readonly kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; + private readonly auditLogger?: AuditLogger; constructor({ alertTypeRegistry, @@ -192,6 +197,7 @@ export class AlertsClient { actionsAuthorization, getEventLogClient, kibanaVersion, + auditLogger, }: ConstructorOptions) { this.logger = logger; this.getUserName = getUserName; @@ -207,14 +213,28 @@ export class AlertsClient { this.actionsAuthorization = actionsAuthorization; this.getEventLogClient = getEventLogClient; this.kibanaVersion = kibanaVersion; + this.auditLogger = auditLogger; } public async create({ data, options }: CreateOptions): Promise { - await this.authorization.ensureAuthorized( - data.alertTypeId, - data.consumer, - WriteOperations.Create - ); + const id = SavedObjectsUtils.generateId(); + + try { + await this.authorization.ensureAuthorized( + data.alertTypeId, + data.consumer, + WriteOperations.Create + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } // Throws an error if alert type isn't registered const alertType = this.alertTypeRegistry.get(data.alertTypeId); @@ -248,6 +268,15 @@ export class AlertsClient { error: null, }, }; + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + let createdAlert: SavedObject; try { createdAlert = await this.unsecuredSavedObjectsClient.create( @@ -256,6 +285,7 @@ export class AlertsClient { { ...options, references, + id, } ); } catch (e) { @@ -297,10 +327,27 @@ export class AlertsClient { public async get({ id }: { id: string }): Promise { const result = await this.unsecuredSavedObjectsClient.get('alert', id); - await this.authorization.ensureAuthorized( - result.attributes.alertTypeId, - result.attributes.consumer, - ReadOperations.Get + try { + await this.authorization.ensureAuthorized( + result.attributes.alertTypeId, + result.attributes.consumer, + ReadOperations.Get + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.GET, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.GET, + savedObject: { type: 'alert', id }, + }) ); return this.getAlertFromRaw(result.id, result.attributes, result.references); } @@ -370,11 +417,23 @@ export class AlertsClient { public async find({ options: { fields, ...options } = {}, }: { options?: FindOptions } = {}): Promise { + let authorizationTuple; + try { + authorizationTuple = await this.authorization.getFindAuthorizationFilter(); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.FIND, + error, + }) + ); + throw error; + } const { filter: authorizationFilter, ensureAlertTypeIsAuthorized, logSuccessfulAuthorization, - } = await this.authorization.getFindAuthorizationFilter(); + } = authorizationTuple; const { page, @@ -392,7 +451,18 @@ export class AlertsClient { }); const authorizedData = data.map(({ id, attributes, references }) => { - ensureAlertTypeIsAuthorized(attributes.alertTypeId, attributes.consumer); + try { + ensureAlertTypeIsAuthorized(attributes.alertTypeId, attributes.consumer); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.FIND, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } return this.getAlertFromRaw( id, fields ? (pick(attributes, fields) as RawAlert) : attributes, @@ -400,6 +470,15 @@ export class AlertsClient { ); }); + authorizedData.forEach(({ id }) => + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.FIND, + savedObject: { type: 'alert', id }, + }) + ) + ); + logSuccessfulAuthorization(); return { @@ -473,10 +552,29 @@ export class AlertsClient { attributes = alert.attributes; } - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.Delete + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.Delete + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.DELETE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.DELETE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) ); const removeResult = await this.unsecuredSavedObjectsClient.delete('alert', id); @@ -520,10 +618,30 @@ export class AlertsClient { // Still attempt to load the object using SOC alertSavedObject = await this.unsecuredSavedObjectsClient.get('alert', id); } - await this.authorization.ensureAuthorized( - alertSavedObject.attributes.alertTypeId, - alertSavedObject.attributes.consumer, - WriteOperations.Update + + try { + await this.authorization.ensureAuthorized( + alertSavedObject.attributes.alertTypeId, + alertSavedObject.attributes.consumer, + WriteOperations.Update + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) ); const updateResult = await this.updateAlert({ id, data }, alertSavedObject); @@ -658,14 +776,28 @@ export class AlertsClient { attributes = alert.attributes; version = alert.version; } - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.UpdateApiKey - ); - if (attributes.actions.length && !this.authorization.shouldUseLegacyAuthorization(attributes)) { - await this.actionsAuthorization.ensureAuthorized('execute'); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.UpdateApiKey + ); + if ( + attributes.actions.length && + !this.authorization.shouldUseLegacyAuthorization(attributes) + ) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE_API_KEY, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; } const username = await this.getUserName(); @@ -678,6 +810,15 @@ export class AlertsClient { updatedAt: new Date().toISOString(), updatedBy: username, }); + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE_API_KEY, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + try { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { @@ -732,16 +873,35 @@ export class AlertsClient { version = alert.version; } - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.Enable - ); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.Enable + ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.ENABLE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.ENABLE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + if (attributes.enabled === false) { const username = await this.getUserName(); const updateAttributes = this.updateMeta({ @@ -816,10 +976,29 @@ export class AlertsClient { version = alert.version; } - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.Disable + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.Disable + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.DISABLE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.DISABLE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) ); if (attributes.enabled === true) { @@ -866,16 +1045,36 @@ export class AlertsClient { 'alert', id ); - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.MuteAll - ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.MuteAll + ); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.MUTE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.MUTE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + const updateAttributes = this.updateMeta({ muteAll: true, mutedInstanceIds: [], @@ -905,16 +1104,36 @@ export class AlertsClient { 'alert', id ); - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.UnmuteAll - ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.UnmuteAll + ); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UNMUTE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UNMUTE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + const updateAttributes = this.updateMeta({ muteAll: false, mutedInstanceIds: [], @@ -945,16 +1164,35 @@ export class AlertsClient { alertId ); - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.MuteInstance - ); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.MuteInstance + ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.MUTE_INSTANCE, + savedObject: { type: 'alert', id: alertId }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.MUTE_INSTANCE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id: alertId }, + }) + ); + const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && !mutedInstanceIds.includes(alertInstanceId)) { mutedInstanceIds.push(alertInstanceId); @@ -991,15 +1229,34 @@ export class AlertsClient { alertId ); - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.UnmuteInstance - ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.UnmuteInstance + ); + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UNMUTE_INSTANCE, + savedObject: { type: 'alert', id: alertId }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UNMUTE_INSTANCE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id: alertId }, + }) + ); + const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && mutedInstanceIds.includes(alertInstanceId)) { await this.unsecuredSavedObjectsClient.update( diff --git a/x-pack/plugins/alerts/server/alerts_client/audit_events.test.ts b/x-pack/plugins/alerts/server/alerts_client/audit_events.test.ts new file mode 100644 index 0000000000000..9cd48248320c0 --- /dev/null +++ b/x-pack/plugins/alerts/server/alerts_client/audit_events.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EventOutcome } from '../../../security/server/audit'; +import { AlertAuditAction, alertAuditEvent } from './audit_events'; + +describe('#alertAuditEvent', () => { + test('creates event with `unknown` outcome', () => { + expect( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id: 'ALERT_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "alert_create", + "category": "database", + "outcome": "unknown", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ALERT_ID", + "type": "alert", + }, + }, + "message": "User is creating alert [id=ALERT_ID]", + } + `); + }); + + test('creates event with `success` outcome', () => { + expect( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + savedObject: { type: 'alert', id: 'ALERT_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "alert_create", + "category": "database", + "outcome": "success", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ALERT_ID", + "type": "alert", + }, + }, + "message": "User has created alert [id=ALERT_ID]", + } + `); + }); + + test('creates event with `failure` outcome', () => { + expect( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + savedObject: { type: 'alert', id: 'ALERT_ID' }, + error: new Error('ERROR_MESSAGE'), + }) + ).toMatchInlineSnapshot(` + Object { + "error": Object { + "code": "Error", + "message": "ERROR_MESSAGE", + }, + "event": Object { + "action": "alert_create", + "category": "database", + "outcome": "failure", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ALERT_ID", + "type": "alert", + }, + }, + "message": "Failed attempt to create alert [id=ALERT_ID]", + } + `); + }); +}); diff --git a/x-pack/plugins/alerts/server/alerts_client/audit_events.ts b/x-pack/plugins/alerts/server/alerts_client/audit_events.ts new file mode 100644 index 0000000000000..f3e3959824084 --- /dev/null +++ b/x-pack/plugins/alerts/server/alerts_client/audit_events.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AuditEvent, EventOutcome, EventCategory, EventType } from '../../../security/server'; + +export enum AlertAuditAction { + CREATE = 'alert_create', + GET = 'alert_get', + UPDATE = 'alert_update', + UPDATE_API_KEY = 'alert_update_api_key', + ENABLE = 'alert_enable', + DISABLE = 'alert_disable', + DELETE = 'alert_delete', + FIND = 'alert_find', + MUTE = 'alert_mute', + UNMUTE = 'alert_unmute', + MUTE_INSTANCE = 'alert_instance_mute', + UNMUTE_INSTANCE = 'alert_instance_unmute', +} + +type VerbsTuple = [string, string, string]; + +const eventVerbs: Record = { + alert_create: ['create', 'creating', 'created'], + alert_get: ['access', 'accessing', 'accessed'], + alert_update: ['update', 'updating', 'updated'], + alert_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'], + alert_enable: ['enable', 'enabling', 'enabled'], + alert_disable: ['disable', 'disabling', 'disabled'], + alert_delete: ['delete', 'deleting', 'deleted'], + alert_find: ['access', 'accessing', 'accessed'], + alert_mute: ['mute', 'muting', 'muted'], + alert_unmute: ['unmute', 'unmuting', 'unmuted'], + alert_instance_mute: ['mute instance of', 'muting instance of', 'muted instance of'], + alert_instance_unmute: ['unmute instance of', 'unmuting instance of', 'unmuted instance of'], +}; + +const eventTypes: Record = { + alert_create: EventType.CREATION, + alert_get: EventType.ACCESS, + alert_update: EventType.CHANGE, + alert_update_api_key: EventType.CHANGE, + alert_enable: EventType.CHANGE, + alert_disable: EventType.CHANGE, + alert_delete: EventType.DELETION, + alert_find: EventType.ACCESS, + alert_mute: EventType.CHANGE, + alert_unmute: EventType.CHANGE, + alert_instance_mute: EventType.CHANGE, + alert_instance_unmute: EventType.CHANGE, +}; + +export interface AlertAuditEventParams { + action: AlertAuditAction; + outcome?: EventOutcome; + savedObject?: NonNullable['saved_object']; + error?: Error; +} + +export function alertAuditEvent({ + action, + savedObject, + outcome, + error, +}: AlertAuditEventParams): AuditEvent { + const doc = savedObject ? `alert [id=${savedObject.id}]` : 'an alert'; + const [present, progressive, past] = eventVerbs[action]; + const message = error + ? `Failed attempt to ${present} ${doc}` + : outcome === EventOutcome.UNKNOWN + ? `User is ${progressive} ${doc}` + : `User has ${past} ${doc}`; + const type = eventTypes[action]; + + return { + message, + event: { + action, + category: EventCategory.DATABASE, + type, + outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS), + }, + kibana: { + saved_object: savedObject, + }, + error: error && { + code: error.name, + message: error.message, + }, + }; +} diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts index dcbb33d849405..b943a21ba9bb6 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts @@ -14,15 +14,24 @@ import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization, ActionsClient } from '../../../../actions/server'; import { TaskStatus } from '../../../../task_manager/server'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; +jest.mock('../../../../../../src/core/server/saved_objects/service/lib/utils', () => ({ + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, +})); + const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -40,10 +49,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -185,6 +196,62 @@ describe('create()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when creating an alert', async () => { + const data = getMockData({ + enabled: false, + actions: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: data, + references: [], + }); + await alertsClient.create({ data }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_create', + outcome: 'unknown', + }), + kibana: { saved_object: { id: 'mock-saved-object-id', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to create an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + alertsClient.create({ + data: getMockData({ + enabled: false, + actions: [], + }), + }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_create', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: 'mock-saved-object-id', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); + test('creates an alert', async () => { const data = getMockData(); const createdAttributes = { @@ -337,16 +404,17 @@ describe('create()', () => { } `); expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - } - `); + Object { + "id": "mock-saved-object-id", + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + } + `); expect(taskManager.schedule).toHaveBeenCalledTimes(1); expect(taskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -991,6 +1059,7 @@ describe('create()', () => { }, }, { + id: 'mock-saved-object-id', references: [ { id: '1', @@ -1113,6 +1182,7 @@ describe('create()', () => { }, }, { + id: 'mock-saved-object-id', references: [ { id: '1', diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts index e7b975aec8eb0..a7ef008eaa2ee 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts @@ -12,6 +12,8 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup } from './lib'; const taskManager = taskManagerMock.createStart(); @@ -20,6 +22,7 @@ const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -37,10 +40,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); describe('delete()', () => { @@ -239,4 +244,43 @@ describe('delete()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'delete'); }); }); + + describe('auditLogger', () => { + test('logs audit event when deleting an alert', async () => { + await alertsClient.delete({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_delete', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to delete an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.delete({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_delete', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts index 8c9ab9494a50a..ce0688a5ab2ff 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts @@ -12,16 +12,18 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; -import { getBeforeSetup, setGlobalDate } from './lib'; import { InvalidatePendingApiKey } from '../../types'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; +import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -39,10 +41,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -109,6 +113,45 @@ describe('disable()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when disabling an alert', async () => { + await alertsClient.disable({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_disable', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to disable an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.disable({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_disable', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); + test('disables an alert', async () => { unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts index feec1d1b9334a..daac6689a183b 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts @@ -13,16 +13,18 @@ import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { TaskStatus } from '../../../../task_manager/server'; -import { getBeforeSetup, setGlobalDate } from './lib'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { InvalidatePendingApiKey } from '../../types'; +import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -40,10 +42,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -148,6 +152,45 @@ describe('enable()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when enabling an alert', async () => { + await alertsClient.enable({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_enable', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to enable an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.enable({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_enable', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); + test('enables an alert', async () => { const createdAt = new Date().toISOString(); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts index 336cb536d702b..232d48e258256 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts @@ -14,16 +14,18 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -45,6 +47,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -251,4 +254,64 @@ describe('find()', () => { expect(logSuccessfulAuthorization).toHaveBeenCalled(); }); }); + + describe('auditLogger', () => { + test('logs audit event when searching alerts', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + await alertsClient.find(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_find', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to search alerts', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + authorization.getFindAuthorizationFilter.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.find()).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_find', + outcome: 'failure', + }), + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + + test('logs audit event when not authorised to search alert type', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + ensureAlertTypeIsAuthorized: jest.fn(() => { + throw new Error('Unauthorized'); + }), + logSuccessfulAuthorization: jest.fn(), + }); + + await expect(async () => await alertsClient.find()).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_find', + outcome: 'failure', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts index 3f0c783f424d1..32ac57459795e 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts @@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -191,4 +194,61 @@ describe('get()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'get'); }); }); + + describe('auditLogger', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + }, + references: [], + }); + }); + + test('logs audit event when getting an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + await alertsClient.get({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_get', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to get an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.get({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_get', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts index 14ebca2135587..b3c3e1bdd2ede 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts @@ -12,6 +12,8 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); @@ -20,6 +22,7 @@ const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -41,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -137,4 +141,85 @@ describe('muteAll()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'muteAll'); }); }); + + describe('auditLogger', () => { + test('logs audit event when muting an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + muteAll: false, + }, + references: [], + version: '123', + }); + await alertsClient.muteAll({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_mute', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to mute an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + muteAll: false, + }, + references: [], + version: '123', + }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.muteAll({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_mute', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts index c2188f128cb4d..ec69dbdeac55f 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts @@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -180,4 +183,75 @@ describe('muteInstance()', () => { ); }); }); + + describe('auditLogger', () => { + test('logs audit event when muting an alert instance', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + schedule: { interval: '10s' }, + alertTypeId: '2', + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: [], + }, + version: '123', + references: [], + }); + await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_instance_mute', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to mute an alert instance', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + schedule: { interval: '10s' }, + alertTypeId: '2', + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: [], + }, + version: '123', + references: [], + }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_instance_mute', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts index d92304ab873be..fd0157091e3a5 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts @@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -138,4 +141,85 @@ describe('unmuteAll()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'unmuteAll'); }); }); + + describe('auditLogger', () => { + test('logs audit event when unmuting an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + muteAll: false, + }, + references: [], + version: '123', + }); + await alertsClient.unmuteAll({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_unmute', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to unmute an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + muteAll: false, + }, + references: [], + version: '123', + }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.unmuteAll({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_unmute', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts index 3486df98f2f05..c7d084a01a2a0 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts @@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -178,4 +181,75 @@ describe('unmuteInstance()', () => { ); }); }); + + describe('auditLogger', () => { + test('logs audit event when unmuting an alert instance', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + schedule: { interval: '10s' }, + alertTypeId: '2', + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: [], + }, + version: '123', + references: [], + }); + await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_instance_unmute', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to unmute an alert instance', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + schedule: { interval: '10s' }, + alertTypeId: '2', + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: [], + }, + version: '123', + references: [], + }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_instance_unmute', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts index b42ee096777fe..15fb1e2ec0092 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts @@ -18,15 +18,17 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { resolvable } from '../../test_utils'; import { ActionsAuthorization, ActionsClient } from '../../../../actions/server'; import { TaskStatus } from '../../../../task_manager/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -44,10 +46,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -1302,4 +1306,89 @@ describe('update()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'update'); }); }); + + describe('auditLogger', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + }, + updated_at: new Date().toISOString(), + references: [], + }); + }); + + test('logs audit event when updating an alert', async () => { + await alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + actions: [], + }, + }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_update', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to update an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + actions: [], + }, + }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + outcome: 'failure', + action: 'alert_update', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts index ca5f44078f513..bf21256bb8413 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts @@ -12,8 +12,10 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; -import { getBeforeSetup, setGlobalDate } from './lib'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { InvalidatePendingApiKey } from '../../types'; +import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -21,6 +23,7 @@ const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -38,10 +41,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -269,4 +274,44 @@ describe('updateApiKey()', () => { ); }); }); + + describe('auditLogger', () => { + test('logs audit event when updating the API key of an alert', async () => { + await alertsClient.updateApiKey({ id: '1' }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_update_api_key', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to update the API key of an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.updateApiKey({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + outcome: 'failure', + action: 'alert_update_api_key', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.ts b/x-pack/plugins/alerts/server/alerts_client_factory.ts index 069703be72f8a..9d71b5f817b2c 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.ts @@ -100,6 +100,7 @@ export class AlertsClientFactory { actionsAuthorization: actions.getActionsAuthorizationWithRequest(request), namespace: this.spaceIdToNamespace(spaceId), encryptedSavedObjectsClient: this.encryptedSavedObjectsClient, + auditLogger: securityPluginSetup?.audit.asScoped(request), async getUserName() { if (!securityPluginSetup) { return null; diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index 1f34a0cef1ccf..4b332b6904282 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -94,11 +94,6 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` >
List should render with data 1`] = ` >
theme.eui.textColors.subdued}; + color: ${({ theme }) => theme.eui.euiTextSubduedColor}; } `; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx index adbcf897669ae..cc41c254ffb50 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx @@ -20,7 +20,7 @@ export const ItemRow = styled('tr')` `; export const ItemTitle = styled('td')` - color: ${({ theme }) => theme.eui.textColors.subdued}; + color: ${({ theme }) => theme.eui.euiTextSubduedColor}; padding-right: 1rem; `; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts index e2a54f6048682..f2f51496fcca8 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts @@ -129,7 +129,7 @@ const getStyle = (theme: EuiTheme): cytoscape.Stylesheet[] => { color: (el: cytoscape.NodeSingular) => el.hasClass('primary') || el.selected() ? theme.eui.euiColorPrimaryText - : theme.eui.textColors.text, + : theme.eui.euiTextColor, // theme.euiFontFamily doesn't work here for some reason, so we're just // specifying a subset of the fonts for the label text. 'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif', diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index bebd5bdabbae3..309cde4dd9f65 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -33,11 +33,9 @@ import { unit } from '../../../../style/variables'; import { ChartContainer } from '../../../shared/charts/chart_container'; import { EmptyMessage } from '../../../shared/EmptyMessage'; -type TransactionDistributionAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups/distribution'>; +type TransactionDistributionAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>; -type DistributionApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups/distribution'>; - -type DistributionBucket = DistributionApiResponse['buckets'][0]; +type DistributionBucket = TransactionDistributionAPIResponse['buckets'][0]; interface IChartPoint { x0: number; diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx index d90fe393c94a4..a633341ba2bb4 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx @@ -27,7 +27,7 @@ import { MaybeViewTraceLink } from './MaybeViewTraceLink'; import { TransactionTabs } from './TransactionTabs'; import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers'; -type DistributionApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups/distribution'>; +type DistributionApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>; type DistributionBucket = DistributionApiResponse['buckets'][0]; diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index 6b02a44dcc2f4..e4260a2533d36 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -36,7 +36,7 @@ import { ImpactBar } from '../../../shared/ImpactBar'; import { ServiceOverviewTable } from '../service_overview_table'; type ServiceTransactionGroupItem = ValuesType< - APIReturnType<'GET /api/apm/services/{serviceName}/overview_transaction_groups'>['transactionGroups'] + APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/overview'>['transactionGroups'] >; interface Props { @@ -100,7 +100,7 @@ export function ServiceOverviewTransactionsTable(props: Props) { return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/overview_transaction_groups', + 'GET /api/apm/services/{serviceName}/transactions/groups/overview', params: { path: { serviceName }, query: { diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx index c14c31afe0445..bc73a3acf4135 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx @@ -10,7 +10,7 @@ import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; import { TransactionList } from './'; -type TransactionGroup = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups'>['items'][0]; +type TransactionGroup = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups'>['items'][0]; export default { title: 'app/TransactionOverview/TransactionList', diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx index 9774538b2a7a7..ade0a0563b0dc 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx @@ -20,7 +20,7 @@ import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; import { EmptyMessage } from '../../../shared/EmptyMessage'; import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink'; -type TransactionGroup = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups'>['items'][0]; +type TransactionGroup = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups'>['items'][0]; // Truncate both the link and the child span (the tooltip anchor.) The link so // it doesn't overflow, and the anchor so we get the ellipsis. diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts b/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts index 78883ec2cf0d3..0ca2867852f26 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts @@ -9,7 +9,7 @@ import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -type TransactionsAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups'>; +type TransactionsAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups'>; const DEFAULT_RESPONSE: Partial = { items: undefined, @@ -25,7 +25,7 @@ export function useTransactionListFetcher() { (callApmApi) => { if (serviceName && start && end && transactionType) { return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups', + endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups', params: { path: { serviceName }, query: { diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx index 50f87184f8ee7..a36980d49db3a 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx @@ -22,7 +22,7 @@ const CausedByContainer = styled('h5')` `; const CausedByHeading = styled('span')` - color: ${({ theme }) => theme.eui.textColors.subdued}; + color: ${({ theme }) => theme.eui.euiTextSubduedColor}; display: block; font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; font-weight: ${({ theme }) => theme.eui.euiFontWeightBold}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts index ff744d763ecae..81840dc52c1ec 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts @@ -20,7 +20,7 @@ export function useTransactionBreakdown() { if (serviceName && start && end && transactionType) { return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/transaction_groups/breakdown', + 'GET /api/apm/services/{serviceName}/transaction/charts/breakdown', params: { path: { serviceName }, query: { diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx index 06a5e7baef79b..4a388b13d7d22 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx @@ -45,7 +45,7 @@ export function TransactionErrorRateChart({ if (serviceName && start && end) { return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/transaction_groups/error_rate', + 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate', params: { path: { serviceName, diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_charts_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_charts_fetcher.ts index f5105e38b985e..406a1a4633577 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_charts_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_charts_fetcher.ts @@ -21,8 +21,7 @@ export function useTransactionChartsFetcher() { (callApmApi) => { if (serviceName && start && end) { return callApmApi({ - endpoint: - 'GET /api/apm/services/{serviceName}/transaction_groups/charts', + endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts', params: { path: { serviceName }, query: { diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts index 74222e8ffe038..b8968031e6922 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts @@ -12,7 +12,7 @@ import { maybe } from '../../common/utils/maybe'; import { APIReturnType } from '../services/rest/createCallApmApi'; import { useUrlParams } from '../context/url_params_context/use_url_params'; -type APIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transaction_groups/distribution'>; +type APIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>; const INITIAL_DATA = { buckets: [] as APIResponse['buckets'], @@ -38,7 +38,7 @@ export function useTransactionDistributionFetcher() { if (serviceName && start && end && transactionType && transactionName) { const response = await callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/transaction_groups/distribution', + 'GET /api/apm/services/{serviceName}/transactions/charts/distribution', params: { path: { serviceName, diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_group.ts b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts similarity index 92% rename from x-pack/plugins/apm/server/lib/errors/get_error_group.ts rename to x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts index 965cc28952b7a..ff09855e63a8f 100644 --- a/x-pack/plugins/apm/server/lib/errors/get_error_group.ts +++ b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts @@ -14,8 +14,7 @@ import { rangeFilter } from '../../../common/utils/range_filter'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getTransaction } from '../transactions/get_transaction'; -// TODO: rename from "getErrorGroup" to "getErrorGroupSample" (since a single error is returned, not an errorGroup) -export async function getErrorGroup({ +export async function getErrorGroupSample({ serviceName, groupId, setup, diff --git a/x-pack/plugins/apm/server/lib/errors/queries.test.ts b/x-pack/plugins/apm/server/lib/errors/queries.test.ts index fec59393726bf..92f0abcfb77e7 100644 --- a/x-pack/plugins/apm/server/lib/errors/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/errors/queries.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getErrorGroup } from './get_error_group'; +import { getErrorGroupSample } from './get_error_group_sample'; import { getErrorGroups } from './get_error_groups'; import { SearchParamsMock, @@ -20,7 +20,7 @@ describe('error queries', () => { it('fetches a single error group', async () => { mock = await inspectSearchParams((setup) => - getErrorGroup({ + getErrorGroupSample({ groupId: 'groupId', serviceName: 'serviceName', setup, diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_sample_for_group.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_sample_for_group.ts deleted file mode 100644 index 7e1aad075fb16..0000000000000 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_sample_for_group.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { maybe } from '../../../common/utils/maybe'; -import { - SERVICE_NAME, - TRANSACTION_NAME, - TRANSACTION_SAMPLED, -} from '../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../common/processor_event'; -import { rangeFilter } from '../../../common/utils/range_filter'; -import { Setup, SetupTimeRange } from '../helpers/setup_request'; - -export async function getTransactionSampleForGroup({ - serviceName, - transactionName, - setup, -}: { - serviceName: string; - transactionName: string; - setup: Setup & SetupTimeRange; -}) { - const { apmEventClient, start, end, esFilter } = setup; - - const filter = [ - { - range: rangeFilter(start, end), - }, - { - term: { - [SERVICE_NAME]: serviceName, - }, - }, - { - term: { - [TRANSACTION_NAME]: transactionName, - }, - }, - ...esFilter, - ]; - - const getSampledTransaction = async () => { - const response = await apmEventClient.search({ - terminateAfter: 1, - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - size: 1, - query: { - bool: { - filter: [...filter, { term: { [TRANSACTION_SAMPLED]: true } }], - }, - }, - }, - }); - - return maybe(response.hits.hits[0]?._source); - }; - - const getUnsampledTransaction = async () => { - const response = await apmEventClient.search({ - terminateAfter: 1, - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - size: 1, - query: { - bool: { - filter: [...filter, { term: { [TRANSACTION_SAMPLED]: false } }], - }, - }, - }, - }); - - return maybe(response.hits.hits[0]?._source); - }; - - const [sampledTransaction, unsampledTransaction] = await Promise.all([ - getSampledTransaction(), - getUnsampledTransaction(), - ]); - - return sampledTransaction || unsampledTransaction; -} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 4f7f6320185bf..0e066a1959c49 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -23,7 +23,6 @@ import { serviceAnnotationsCreateRoute, serviceErrorGroupsRoute, serviceThroughputRoute, - serviceTransactionGroupsRoute, } from './services'; import { agentConfigurationRoute, @@ -52,13 +51,13 @@ import { correlationsForFailedTransactionsRoute, } from './correlations'; import { - transactionGroupsBreakdownRoute, - transactionGroupsChartsRoute, - transactionGroupsDistributionRoute, + transactionChartsBreakdownRoute, + transactionChartsRoute, + transactionChartsDistributionRoute, + transactionChartsErrorRateRoute, transactionGroupsRoute, - transactionSampleForGroupRoute, - transactionGroupsErrorRateRoute, -} from './transaction_groups'; + transactionGroupsOverviewRoute, +} from './transactions/transactions_routes'; import { errorGroupsLocalFiltersRoute, metricsLocalFiltersRoute, @@ -122,7 +121,6 @@ const createApmApi = () => { .add(serviceAnnotationsCreateRoute) .add(serviceErrorGroupsRoute) .add(serviceThroughputRoute) - .add(serviceTransactionGroupsRoute) // Agent configuration .add(getSingleAgentConfigurationRoute) @@ -152,13 +150,13 @@ const createApmApi = () => { .add(tracesByIdRoute) .add(rootTransactionByTraceIdRoute) - // Transaction groups - .add(transactionGroupsBreakdownRoute) - .add(transactionGroupsChartsRoute) - .add(transactionGroupsDistributionRoute) + // Transactions + .add(transactionChartsBreakdownRoute) + .add(transactionChartsRoute) + .add(transactionChartsDistributionRoute) + .add(transactionChartsErrorRateRoute) .add(transactionGroupsRoute) - .add(transactionSampleForGroupRoute) - .add(transactionGroupsErrorRateRoute) + .add(transactionGroupsOverviewRoute) // UI filters .add(errorGroupsLocalFiltersRoute) diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index 64864ec2258ba..c4bc70a92d9ee 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import { createRoute } from './create_route'; import { getErrorDistribution } from '../lib/errors/distribution/get_distribution'; -import { getErrorGroup } from '../lib/errors/get_error_group'; +import { getErrorGroupSample } from '../lib/errors/get_error_group_sample'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; import { uiFiltersRt, rangeRt } from './default_api_types'; @@ -56,7 +56,7 @@ export const errorGroupsRoute = createRoute({ handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName, groupId } = context.params.path; - return getErrorGroup({ serviceName, groupId, setup }); + return getErrorGroupSample({ serviceName, groupId, setup }); }, }); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 4c5738ecef581..a82f1b64d5537 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -19,7 +19,6 @@ import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; -import { getServiceTransactionGroups } from '../lib/services/get_service_transaction_groups'; import { getThroughput } from '../lib/services/get_throughput'; export const servicesRoute = createRoute({ @@ -276,52 +275,3 @@ export const serviceThroughputRoute = createRoute({ }); }, }); - -export const serviceTransactionGroupsRoute = createRoute({ - endpoint: 'GET /api/apm/services/{serviceName}/overview_transaction_groups', - params: t.type({ - path: t.type({ serviceName: t.string }), - query: t.intersection([ - rangeRt, - uiFiltersRt, - t.type({ - size: toNumberRt, - numBuckets: toNumberRt, - pageIndex: toNumberRt, - sortDirection: t.union([t.literal('asc'), t.literal('desc')]), - sortField: t.union([ - t.literal('latency'), - t.literal('throughput'), - t.literal('errorRate'), - t.literal('impact'), - ]), - }), - ]), - }), - options: { - tags: ['access:apm'], - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - - const { - path: { serviceName }, - query: { size, numBuckets, pageIndex, sortDirection, sortField }, - } = context.params; - - return getServiceTransactionGroups({ - setup, - serviceName, - pageIndex, - searchAggregatedTransactions, - size, - sortDirection, - sortField, - numBuckets, - }); - }, -}); diff --git a/x-pack/plugins/apm/server/routes/transaction_groups.ts b/x-pack/plugins/apm/server/routes/transactions/transactions_routes.ts similarity index 62% rename from x-pack/plugins/apm/server/routes/transaction_groups.ts rename to x-pack/plugins/apm/server/routes/transactions/transactions_routes.ts index 58c1ce3451a29..11d247ccab84f 100644 --- a/x-pack/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/plugins/apm/server/routes/transactions/transactions_routes.ts @@ -4,21 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as t from 'io-ts'; import Boom from '@hapi/boom'; -import { setupRequest } from '../lib/helpers/setup_request'; -import { getTransactionCharts } from '../lib/transactions/charts'; -import { getTransactionDistribution } from '../lib/transactions/distribution'; -import { getTransactionBreakdown } from '../lib/transactions/breakdown'; -import { getTransactionGroupList } from '../lib/transaction_groups'; -import { createRoute } from './create_route'; -import { uiFiltersRt, rangeRt } from './default_api_types'; -import { getTransactionSampleForGroup } from '../lib/transaction_groups/get_transaction_sample_for_group'; -import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; -import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; +import * as t from 'io-ts'; +import { toNumberRt } from '../../../common/runtime_types/to_number_rt'; +import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; +import { setupRequest } from '../../lib/helpers/setup_request'; +import { getServiceTransactionGroups } from '../../lib/services/get_service_transaction_groups'; +import { getTransactionBreakdown } from '../../lib/transactions/breakdown'; +import { getTransactionCharts } from '../../lib/transactions/charts'; +import { getTransactionDistribution } from '../../lib/transactions/distribution'; +import { getTransactionGroupList } from '../../lib/transaction_groups'; +import { getErrorRate } from '../../lib/transaction_groups/get_error_rate'; +import { createRoute } from '../create_route'; +import { rangeRt, uiFiltersRt } from '../default_api_types'; +/** + * Returns a list of transactions grouped by name + * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/overview/ + */ export const transactionGroupsRoute = createRoute({ - endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups', + endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups', params: t.type({ path: t.type({ serviceName: t.string, @@ -53,8 +58,64 @@ export const transactionGroupsRoute = createRoute({ }, }); -export const transactionGroupsChartsRoute = createRoute({ - endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups/charts', +export const transactionGroupsOverviewRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/overview', + params: t.type({ + path: t.type({ serviceName: t.string }), + query: t.intersection([ + rangeRt, + uiFiltersRt, + t.type({ + size: toNumberRt, + numBuckets: toNumberRt, + pageIndex: toNumberRt, + sortDirection: t.union([t.literal('asc'), t.literal('desc')]), + sortField: t.union([ + t.literal('latency'), + t.literal('throughput'), + t.literal('errorRate'), + t.literal('impact'), + ]), + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const searchAggregatedTransactions = await getSearchAggregatedTransactions( + setup + ); + + const { + path: { serviceName }, + query: { size, numBuckets, pageIndex, sortDirection, sortField }, + } = context.params; + + return getServiceTransactionGroups({ + setup, + serviceName, + pageIndex, + searchAggregatedTransactions, + size, + sortDirection, + sortField, + numBuckets, + }); + }, +}); + +/** + * Returns timeseries for latency, throughput and anomalies + * TODO: break it into 3 new APIs: + * - Latency: /transactions/charts/latency + * - Throughput: /transactions/charts/throughput + * - anomalies: /transactions/charts/anomaly + */ +export const transactionChartsRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts', params: t.type({ path: t.type({ serviceName: t.string, @@ -98,9 +159,9 @@ export const transactionGroupsChartsRoute = createRoute({ }, }); -export const transactionGroupsDistributionRoute = createRoute({ +export const transactionChartsDistributionRoute = createRoute({ endpoint: - 'GET /api/apm/services/{serviceName}/transaction_groups/distribution', + 'GET /api/apm/services/{serviceName}/transactions/charts/distribution', params: t.type({ path: t.type({ serviceName: t.string, @@ -145,8 +206,8 @@ export const transactionGroupsDistributionRoute = createRoute({ }, }); -export const transactionGroupsBreakdownRoute = createRoute({ - endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups/breakdown', +export const transactionChartsBreakdownRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/transaction/charts/breakdown', params: t.type({ path: t.type({ serviceName: t.string, @@ -177,33 +238,9 @@ export const transactionGroupsBreakdownRoute = createRoute({ }, }); -export const transactionSampleForGroupRoute = createRoute({ - endpoint: `GET /api/apm/transaction_sample`, - params: t.type({ - query: t.intersection([ - uiFiltersRt, - rangeRt, - t.type({ serviceName: t.string, transactionName: t.string }), - ]), - }), - options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - - const { transactionName, serviceName } = context.params.query; - - return { - transaction: await getTransactionSampleForGroup({ - setup, - serviceName, - transactionName, - }), - }; - }, -}); - -export const transactionGroupsErrorRateRoute = createRoute({ - endpoint: 'GET /api/apm/services/{serviceName}/transaction_groups/error_rate', +export const transactionChartsErrorRateRoute = createRoute({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate', params: t.type({ path: t.type({ serviceName: t.string, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/components/__stories__/__snapshots__/time_filter.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/components/__stories__/__snapshots__/time_filter.stories.storyshot index 3fba41069253e..59771973f2aa5 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/components/__stories__/__snapshots__/time_filter.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/components/__stories__/__snapshots__/time_filter.stories.storyshot @@ -23,11 +23,6 @@ exports[`Storyshots renderers/TimeFilter default 1`] = `
- -
-
- - Select an option: -
- , is selected - - -
- - -
-
-
-
+ />
`; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot index 05339ca558562..8194d923f34a5 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot @@ -18,7 +18,7 @@ exports[`Storyshots components/Assets/Asset airplane 1`] = ` className="canvasAsset__thumb canvasCheckered" >
Asset thumbnail
Asset thumbnail
Asset thumbnail
Asset thumbnail
- -
-
- - Select an option: - , is selected - -
-
-
+ />
`; exports[`Storyshots components/FontPicker with value 1`] = `
- -
-
- - Select an option: -
- American Typewriter -
- , is selected -
- -
- - -
-
-
-
+ />
`; diff --git a/x-pack/plugins/canvas/public/components/palette_picker/__stories__/__snapshots__/palette_picker.stories.storyshot b/x-pack/plugins/canvas/public/components/palette_picker/__stories__/__snapshots__/palette_picker.stories.storyshot index d5990d3dbfd53..680f792f4d1b9 100644 --- a/x-pack/plugins/canvas/public/components/palette_picker/__stories__/__snapshots__/palette_picker.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/palette_picker/__stories__/__snapshots__/palette_picker.stories.storyshot @@ -9,7 +9,7 @@ exports[`Storyshots components/Color/PalettePicker clearable 1`] = ` } >
- -
-
- - Select an option: None, is selected - - -
- - -
-
-
-
+ />
`; @@ -75,7 +32,7 @@ exports[`Storyshots components/Color/PalettePicker default 1`] = ` } >
- -
-
- - Select an option: -
- , is selected - - -
- - -
-
-
-
+ />
`; @@ -157,7 +55,7 @@ exports[`Storyshots components/Color/PalettePicker interactive 1`] = ` } >
- -
-
- - Select an option: -
- , is selected - - -
- - -
-
-
-
+ />
`; diff --git a/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot index 1d292c94436e3..548c441680c55 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot @@ -3,11 +3,6 @@ exports[`Storyshots components/Shapes/ShapePickerPopover default 1`] = `
- -
-
- - Select an option: - , is selected - -
-
-
+ />
- -
-
- - Select an option: - , is selected - -
-
-
+ />
- -
-
- - Select an option: -
- - - - - - Boolean - -
- , is selected -
- -
- - -
-
-
-
+ />
@@ -442,7 +363,7 @@ Array [ className="euiFormRow__fieldWrapper" >
- -
-
- - Select an option: -
- - - - - - Number - -
- , is selected -
- -
- - -
-
-
-
+ />
@@ -746,7 +588,7 @@ Array [ className="euiFormRow__fieldWrapper" >
- -
-
- - Select an option: -
- - - - - - String - -
- , is selected -
- -
- - -
-
-
-
+ />
@@ -1026,7 +789,7 @@ Array [ className="euiFormRow__fieldWrapper" >
- -
-
- - Select an option: -
- - - - - - String - -
- , is selected -
- -
- - -
-
-
-
+ />
diff --git a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__stories__/__snapshots__/edit_menu.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__stories__/__snapshots__/edit_menu.stories.storyshot index 93f4db664d1db..a242747f74362 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__stories__/__snapshots__/edit_menu.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__stories__/__snapshots__/edit_menu.stories.storyshot @@ -3,11 +3,6 @@ exports[`Storyshots components/WorkpadHeader/EditMenu 2 elements selected 1`] = `
- -
-
- - Select an option: -
- , is selected - - -
- - -
-
-
-
+ />
@@ -436,11 +375,6 @@ exports[`Storyshots arguments/ContainerStyle extended 1`] = `
- -
-
- - Select an option: -
- , is selected - - -
- - -
-
-
-
+ />
@@ -905,11 +778,6 @@ exports[`Storyshots arguments/ContainerStyle/components border form 1`] = `
- -
-
- - Select an option: -
- , is selected - - -
- - -
-
-
-
+ />
@@ -1386,11 +1193,6 @@ exports[`Storyshots arguments/ContainerStyle/components extended template 1`] =
; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; -export type Status = rt.TypeOf; export type CaseExternalServiceRequest = rt.TypeOf; export type ServiceConnectorCaseParams = rt.TypeOf; export type ServiceConnectorCaseResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/status.ts b/x-pack/plugins/case/common/api/cases/status.ts index 984181da8cdee..b812126dc1eab 100644 --- a/x-pack/plugins/case/common/api/cases/status.ts +++ b/x-pack/plugins/case/common/api/cases/status.ts @@ -8,6 +8,7 @@ import * as rt from 'io-ts'; export const CasesStatusResponseRt = rt.type({ count_open_cases: rt.number, + count_in_progress_cases: rt.number, count_closed_cases: rt.number, }); diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index d82979de2cb44..e09ce226b3125 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ConnectorTypes, CasePostRequest } from '../../../common/api'; +import { ConnectorTypes, CasePostRequest, CaseStatuses } from '../../../common/api'; import { createMockSavedObjectsRepository, @@ -60,7 +60,7 @@ describe('create', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], updated_at: null, updated_by: null, @@ -126,7 +126,7 @@ describe('create', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], updated_at: null, updated_by: null, @@ -169,7 +169,7 @@ describe('create', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], updated_at: null, updated_by: null, @@ -316,7 +316,7 @@ describe('create', () => { title: 'a title', description: 'This is a brand new case of a bad meanie defacing data', tags: ['defacement'], - status: 'closed', + status: CaseStatuses.closed, connector: { id: 'none', name: 'none', diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index 10eebd1210a9e..ae701f16b2bcb 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ConnectorTypes, CasesPatchRequest } from '../../../common/api'; +import { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common/api'; import { createMockSavedObjectsRepository, mockCaseNoConnectorId, @@ -27,7 +27,7 @@ describe('update', () => { cases: [ { id: 'mock-id-1', - status: 'closed' as const, + status: CaseStatuses.closed, version: 'WzAsMV0=', }, ], @@ -56,7 +56,7 @@ describe('update', () => { description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', external_service: null, - status: 'closed', + status: CaseStatuses.closed, tags: ['defacement'], title: 'Super Bad Security Issue', totalComment: 0, @@ -79,8 +79,8 @@ describe('update', () => { username: 'awesome', }, action_field: ['status'], - new_value: 'closed', - old_value: 'open', + new_value: CaseStatuses.closed, + old_value: CaseStatuses.open, }, references: [ { @@ -98,7 +98,7 @@ describe('update', () => { cases: [ { id: 'mock-id-1', - status: 'open' as const, + status: CaseStatuses.open, version: 'WzAsMV0=', }, ], @@ -106,7 +106,10 @@ describe('update', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: [ - { ...mockCases[0], attributes: { ...mockCases[0].attributes, status: 'closed' } }, + { + ...mockCases[0], + attributes: { ...mockCases[0].attributes, status: CaseStatuses.closed }, + }, ...mockCases.slice(1), ], }); @@ -130,7 +133,7 @@ describe('update', () => { description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', external_service: null, - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], title: 'Super Bad Security Issue', totalComment: 0, @@ -146,7 +149,7 @@ describe('update', () => { cases: [ { id: 'mock-no-connector_id', - status: 'closed' as const, + status: CaseStatuses.closed, version: 'WzAsMV0=', }, ], @@ -177,7 +180,7 @@ describe('update', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'closed', + status: CaseStatuses.closed, tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, @@ -231,7 +234,7 @@ describe('update', () => { description: 'Oh no, a bad meanie going LOLBins all over the place!', external_service: null, title: 'Another bad one', - status: 'open', + status: CaseStatuses.open, tags: ['LOLBins'], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { @@ -314,7 +317,7 @@ describe('update', () => { cases: [ { id: 'mock-id-1', - status: 'open' as const, + status: CaseStatuses.open, version: 'WzAsMV0=', }, ], diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index a754ce27c5e41..406e43a74cccf 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -19,6 +19,7 @@ import { ESCasePatchRequest, CasePatchRequest, CasesResponse, + CaseStatuses, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -98,12 +99,15 @@ export const update = ({ cases: updateFilterCases.map((thisCase) => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; let closedInfo = {}; - if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') { + if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { closedInfo = { closed_at: updatedDt, closed_by: { email, full_name, username }, }; - } else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') { + } else if ( + updateCaseAttributes.status && + updateCaseAttributes.status === CaseStatuses.open + ) { closedInfo = { closed_at: null, closed_by: null, diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 90bb1d604e733..adf94661216cb 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -9,7 +9,7 @@ import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsMock } from '../../../../actions/server/mocks'; import { validateParams } from '../../../../actions/server/lib'; -import { ConnectorTypes, CommentType } from '../../../common/api'; +import { ConnectorTypes, CommentType, CaseStatuses } from '../../../common/api'; import { createCaseServiceMock, createConfigureServiceMock, @@ -785,7 +785,7 @@ describe('case connector', () => { tags: ['case', 'connector'], description: 'Yo fields!!', external_service: null, - status: 'open' as const, + status: CaseStatuses.open, updated_at: null, updated_by: null, version: 'WzksMV0=', @@ -868,7 +868,7 @@ describe('case connector', () => { description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', external_service: null, - status: 'open' as const, + status: CaseStatuses.open, tags: ['defacement'], title: 'Update title', totalComment: 0, @@ -937,7 +937,7 @@ describe('case connector', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open' as const, + status: CaseStatuses.open, tags: ['defacement'], updated_at: null, updated_by: null, diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 4c0b5887ca998..95856dd75d0ae 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -11,6 +11,7 @@ import { ESCaseAttributes, ConnectorTypes, CommentType, + CaseStatuses, } from '../../../../common/api'; export const mockCases: Array> = [ @@ -35,7 +36,7 @@ export const mockCases: Array> = [ description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { @@ -69,7 +70,7 @@ export const mockCases: Array> = [ description: 'Oh no, a bad meanie destroying data!', external_service: null, title: 'Damaging Data Destruction Detected', - status: 'open', + status: CaseStatuses.open, tags: ['Data Destruction'], updated_at: '2019-11-25T22:32:00.900Z', updated_by: { @@ -107,7 +108,7 @@ export const mockCases: Array> = [ description: 'Oh no, a bad meanie going LOLBins all over the place!', external_service: null, title: 'Another bad one', - status: 'open', + status: CaseStatuses.open, tags: ['LOLBins'], updated_at: '2019-11-25T22:32:17.947Z', updated_by: { @@ -148,7 +149,7 @@ export const mockCases: Array> = [ }, description: 'Oh no, a bad meanie going LOLBins all over the place!', external_service: null, - status: 'closed', + status: CaseStatuses.closed, title: 'Another bad one', tags: ['LOLBins'], updated_at: '2019-11-25T22:32:17.947Z', @@ -179,7 +180,7 @@ export const mockCaseNoConnectorId: SavedObject> = { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index b2ba8b2fcb33a..dca94589bf72a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -38,6 +38,10 @@ describe('FIND all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.cases).toHaveLength(4); + // mockSavedObjectsRepository do not support filters and returns all cases every time. + expect(response.payload.count_open_cases).toEqual(4); + expect(response.payload.count_closed_cases).toEqual(4); + expect(response.payload.count_in_progress_cases).toEqual(4); }); it(`has proper connector id on cases with configured connector`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index e70225456d5a8..b034e86b4f0d4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -11,7 +11,13 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { isEmpty } from 'lodash'; -import { CasesFindResponseRt, CasesFindRequestRt, throwErrors } from '../../../../common/api'; +import { + CasesFindResponseRt, + CasesFindRequestRt, + throwErrors, + CaseStatuses, + caseStatuses, +} from '../../../../common/api'; import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; import { RouteDeps, TotalCommentByCase } from '../types'; import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; @@ -20,7 +26,7 @@ import { CASES_URL } from '../../../../common/constants'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => filters?.filter((i) => i !== '').join(` ${operator} `); -const getStatusFilter = (status: 'open' | 'closed', appendFilter?: string) => +const getStatusFilter = (status: CaseStatuses, appendFilter?: string) => `${CASE_SAVED_OBJECT}.attributes.status: ${status}${ !isEmpty(appendFilter) ? ` AND ${appendFilter}` : '' }`; @@ -75,30 +81,21 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: client, }; - const argsOpenCases = { + const statusArgs = caseStatuses.map((caseStatus) => ({ client, options: { fields: [], page: 1, perPage: 1, - filter: getStatusFilter('open', myFilters), + filter: getStatusFilter(caseStatus, myFilters), }, - }; + })); - const argsClosedCases = { - client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: getStatusFilter('closed', myFilters), - }, - }; - const [cases, openCases, closesCases] = await Promise.all([ + const [cases, openCases, inProgressCases, closedCases] = await Promise.all([ caseService.findCases(args), - caseService.findCases(argsOpenCases), - caseService.findCases(argsClosedCases), + ...statusArgs.map((arg) => caseService.findCases(arg)), ]); + const totalCommentsFindByCases = await Promise.all( cases.saved_objects.map((c) => caseService.getAllCaseComments({ @@ -133,7 +130,8 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: transformCases( cases, openCases.total ?? 0, - closesCases.total ?? 0, + inProgressCases.total ?? 0, + closedCases.total ?? 0, totalCommentsByCases ) ), diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index ea69ee77c5802..053f9ec18ab0f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -16,7 +16,7 @@ import { } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -import { ConnectorTypes } from '../../../../common/api/connectors'; +import { ConnectorTypes, CaseStatuses } from '../../../../common/api'; describe('PATCH cases', () => { let routeHandler: RequestHandler; @@ -36,7 +36,7 @@ describe('PATCH cases', () => { cases: [ { id: 'mock-id-1', - status: 'closed', + status: CaseStatuses.closed, version: 'WzAsMV0=', }, ], @@ -67,7 +67,7 @@ describe('PATCH cases', () => { description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', external_service: null, - status: 'closed', + status: CaseStatuses.closed, tags: ['defacement'], title: 'Super Bad Security Issue', totalComment: 0, @@ -86,7 +86,7 @@ describe('PATCH cases', () => { cases: [ { id: 'mock-id-4', - status: 'open', + status: CaseStatuses.open, version: 'WzUsMV0=', }, ], @@ -118,7 +118,7 @@ describe('PATCH cases', () => { description: 'Oh no, a bad meanie going LOLBins all over the place!', id: 'mock-id-4', external_service: null, - status: 'open', + status: CaseStatuses.open, tags: ['LOLBins'], title: 'Another bad one', totalComment: 0, @@ -129,6 +129,56 @@ describe('PATCH cases', () => { ]); }); + it(`Change case to in-progress`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-1', + status: CaseStatuses['in-progress'], + version: 'WzAsMV0=', + }, + ], + }, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual([ + { + closed_at: null, + closed_by: null, + comments: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, + description: 'This is a brand new case of a bad meanie defacing data', + id: 'mock-id-1', + external_service: null, + status: CaseStatuses['in-progress'], + tags: ['defacement'], + title: 'Super Bad Security Issue', + totalComment: 0, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + version: 'WzE3LDFd', + }, + ]); + }); + it(`Patches a case without a connector.id`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', @@ -137,7 +187,7 @@ describe('PATCH cases', () => { cases: [ { id: 'mock-no-connector_id', - status: 'closed', + status: CaseStatuses.closed, version: 'WzAsMV0=', }, ], @@ -163,7 +213,7 @@ describe('PATCH cases', () => { cases: [ { id: 'mock-id-3', - status: 'closed', + status: CaseStatuses.closed, version: 'WzUsMV0=', }, ], @@ -225,7 +275,7 @@ describe('PATCH cases', () => { cases: [ { id: 'mock-id-1', - case: { status: 'closed' }, + case: { status: CaseStatuses.closed }, version: 'badv=', }, ], @@ -250,7 +300,7 @@ describe('PATCH cases', () => { cases: [ { id: 'mock-id-1', - case: { status: 'open' }, + case: { status: CaseStatuses.open }, version: 'WzAsMV0=', }, ], @@ -276,7 +326,7 @@ describe('PATCH cases', () => { cases: [ { id: 'mock-id-does-not-exist', - status: 'closed', + status: CaseStatuses.closed, version: 'WzAsMV0=', }, ], diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 1e1b19baa1c47..508684b422891 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -16,7 +16,7 @@ import { import { initPostCaseApi } from './post_case'; import { CASES_URL } from '../../../../common/constants'; import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; -import { ConnectorTypes } from '../../../../common/api/connectors'; +import { ConnectorTypes, CaseStatuses } from '../../../../common/api'; describe('POST cases', () => { let routeHandler: RequestHandler; @@ -54,6 +54,7 @@ describe('POST cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.id).toEqual('mock-it'); + expect(response.payload.status).toEqual('open'); expect(response.payload.created_by.username).toEqual('awesome'); expect(response.payload.connector).toEqual({ id: 'none', @@ -104,7 +105,7 @@ describe('POST cases', () => { body: { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], connector: null, }, @@ -191,7 +192,7 @@ describe('POST cases', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, id: 'mock-it', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], title: 'Super Bad Security Issue', totalComment: 0, diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 6ba2da111090f..6a6b09dc3f87a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -18,7 +18,12 @@ import { getCommentContextFromAttributes, } from '../utils'; -import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api'; +import { + CaseExternalServiceRequestRt, + CaseResponseRt, + throwErrors, + CaseStatuses, +} from '../../../../common/api'; import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { CASE_DETAILS_URL } from '../../../../common/constants'; @@ -77,7 +82,7 @@ export function initPushCaseUserActionApi({ actionsClient.getAll(), ]); - if (myCase.attributes.status === 'closed') { + if (myCase.attributes.status === CaseStatuses.closed) { throw Boom.conflict( `This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.` ); @@ -117,7 +122,7 @@ export function initPushCaseUserActionApi({ ...(myCaseConfigure.total > 0 && myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' ? { - status: 'closed', + status: CaseStatuses.closed, closed_at: pushedDate, closed_by: { email, full_name, username }, } @@ -153,7 +158,7 @@ export function initPushCaseUserActionApi({ actionBy: { username, full_name, email }, caseId, fields: ['status'], - newValue: 'closed', + newValue: CaseStatuses.closed, oldValue: myCase.attributes.status, }), ] diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index 8f86dbc91f315..4379a6b56367c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -7,7 +7,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CasesStatusResponseRt } from '../../../../../common/api'; +import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { CASE_STATUS_URL } from '../../../../../common/constants'; @@ -20,34 +20,24 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const argsOpenCases = { + const args = caseStatuses.map((status) => ({ client, options: { fields: [], page: 1, perPage: 1, - filter: `${CASE_SAVED_OBJECT}.attributes.status: open`, + filter: `${CASE_SAVED_OBJECT}.attributes.status: ${status}`, }, - }; + })); - const argsClosedCases = { - client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: `${CASE_SAVED_OBJECT}.attributes.status: closed`, - }, - }; - - const [openCases, closesCases] = await Promise.all([ - caseService.findCases(argsOpenCases), - caseService.findCases(argsClosedCases), - ]); + const [openCases, inProgressCases, closesCases] = await Promise.all( + args.map((arg) => caseService.findCases(arg)) + ); return response.ok({ body: CasesStatusResponseRt.encode({ count_open_cases: openCases.total, + count_in_progress_cases: inProgressCases.total, count_closed_cases: closesCases.total, }), }); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index a67bae5ed74dc..7654ae5ff0d1a 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -23,7 +23,7 @@ import { mockCaseComments, mockCaseNoConnectorId, } from './__fixtures__/mock_saved_objects'; -import { ConnectorTypes, ESCaseConnector, CommentType } from '../../../common/api'; +import { ConnectorTypes, ESCaseConnector, CommentType, CaseStatuses } from '../../../common/api'; describe('Utils', () => { describe('transformNewCase', () => { @@ -57,7 +57,7 @@ describe('Utils', () => { created_at: '2020-04-09T09:43:51.778Z', created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, external_service: null, - status: 'open', + status: CaseStatuses.open, updated_at: null, updated_by: null, }); @@ -80,7 +80,7 @@ describe('Utils', () => { created_at: '2020-04-09T09:43:51.778Z', created_by: { email: undefined, full_name: undefined, username: undefined }, external_service: null, - status: 'open', + status: CaseStatuses.open, updated_at: null, updated_by: null, }); @@ -106,7 +106,7 @@ describe('Utils', () => { created_at: '2020-04-09T09:43:51.778Z', created_by: { email: null, full_name: null, username: null }, external_service: null, - status: 'open', + status: CaseStatuses.open, updated_at: null, updated_by: null, }); @@ -247,6 +247,7 @@ describe('Utils', () => { }, 2, 2, + 2, extraCaseData ); expect(res).toEqual({ @@ -259,6 +260,7 @@ describe('Utils', () => { ), count_open_cases: 2, count_closed_cases: 2, + count_in_progress_cases: 2, }); }); }); @@ -289,7 +291,7 @@ describe('Utils', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { @@ -328,7 +330,7 @@ describe('Utils', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { @@ -374,7 +376,7 @@ describe('Utils', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { @@ -484,7 +486,7 @@ describe('Utils', () => { description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', - status: 'open', + status: CaseStatuses.open, tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 589d7c02a7be6..c8753772648c2 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -33,6 +33,7 @@ import { CommentType, excess, throwErrors, + CaseStatuses, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -61,7 +62,7 @@ export const transformNewCase = ({ created_at: createdDate, created_by: { email, full_name, username }, external_service: null, - status: 'open', + status: CaseStatuses.open, updated_at: null, updated_by: null, }); @@ -103,6 +104,7 @@ export function wrapError(error: any): CustomHttpResponseOptions export const transformCases = ( cases: SavedObjectsFindResponse, countOpenCases: number, + countInProgressCases: number, countClosedCases: number, totalCommentByCase: TotalCommentByCase[] ): CasesFindResponse => ({ @@ -111,6 +113,7 @@ export const transformCases = ( total: cases.total, cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), count_open_cases: countOpenCases, + count_in_progress_cases: countInProgressCases, count_closed_cases: countClosedCases, }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts index f1e06a0cec03d..f528843cf9ea3 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts @@ -113,18 +113,3 @@ it('correctly determines attribute properties', () => { } } }); - -it('it correctly sets allowPredefinedID', () => { - const defaultTypeDefinition = new EncryptedSavedObjectAttributesDefinition({ - type: 'so-type', - attributesToEncrypt: new Set(['attr#1', 'attr#2']), - }); - expect(defaultTypeDefinition.allowPredefinedID).toBe(false); - - const typeDefinitionWithPredefinedIDAllowed = new EncryptedSavedObjectAttributesDefinition({ - type: 'so-type', - attributesToEncrypt: new Set(['attr#1', 'attr#2']), - allowPredefinedID: true, - }); - expect(typeDefinitionWithPredefinedIDAllowed.allowPredefinedID).toBe(true); -}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts index 398a64585411a..849a2888b6e1a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts @@ -15,7 +15,6 @@ export class EncryptedSavedObjectAttributesDefinition { public readonly attributesToEncrypt: ReadonlySet; private readonly attributesToExcludeFromAAD: ReadonlySet | undefined; private readonly attributesToStrip: ReadonlySet; - public readonly allowPredefinedID: boolean; constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) { const attributesToEncrypt = new Set(); @@ -35,7 +34,6 @@ export class EncryptedSavedObjectAttributesDefinition { this.attributesToEncrypt = attributesToEncrypt; this.attributesToStrip = attributesToStrip; this.attributesToExcludeFromAAD = typeRegistration.attributesToExcludeFromAAD; - this.allowPredefinedID = !!typeRegistration.allowPredefinedID; } /** diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts index 0138e929ca1ca..c692d8698771f 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts @@ -13,7 +13,6 @@ import { function createEncryptedSavedObjectsServiceMock() { return ({ isRegistered: jest.fn(), - canSpecifyID: jest.fn(), stripOrDecryptAttributes: jest.fn(), encryptAttributes: jest.fn(), decryptAttributes: jest.fn(), @@ -53,12 +52,6 @@ export const encryptedSavedObjectsServiceMock = { mock.isRegistered.mockImplementation( (type) => registrations.findIndex((r) => r.type === type) >= 0 ); - mock.canSpecifyID.mockImplementation((type, version, overwrite) => { - const registration = registrations.find((r) => r.type === type); - return ( - registration === undefined || registration.allowPredefinedID || !!(version && overwrite) - ); - }); mock.encryptAttributes.mockImplementation(async (descriptor, attrs) => processAttributes( descriptor, diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index 6bc4a392064e4..88d57072697fe 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -89,45 +89,6 @@ describe('#isRegistered', () => { }); }); -describe('#canSpecifyID', () => { - it('returns true for unknown types', () => { - expect(service.canSpecifyID('unknown-type')).toBe(true); - }); - - it('returns true for types registered setting allowPredefinedID to true', () => { - service.registerType({ - type: 'known-type-1', - attributesToEncrypt: new Set(['attr-1']), - allowPredefinedID: true, - }); - expect(service.canSpecifyID('known-type-1')).toBe(true); - }); - - it('returns true when overwriting a saved object with a version specified even when allowPredefinedID is not set', () => { - service.registerType({ - type: 'known-type-1', - attributesToEncrypt: new Set(['attr-1']), - }); - expect(service.canSpecifyID('known-type-1', '2', true)).toBe(true); - expect(service.canSpecifyID('known-type-1', '2', false)).toBe(false); - expect(service.canSpecifyID('known-type-1', undefined, true)).toBe(false); - }); - - it('returns false for types registered without setting allowPredefinedID', () => { - service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attr-1']) }); - expect(service.canSpecifyID('known-type-1')).toBe(false); - }); - - it('returns false for types registered setting allowPredefinedID to false', () => { - service.registerType({ - type: 'known-type-1', - attributesToEncrypt: new Set(['attr-1']), - allowPredefinedID: false, - }); - expect(service.canSpecifyID('known-type-1')).toBe(false); - }); -}); - describe('#stripOrDecryptAttributes', () => { it('does not strip attributes from unknown types', async () => { const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 8d2ebb575c35e..1f1093a179538 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -31,7 +31,6 @@ export interface EncryptedSavedObjectTypeRegistration { readonly type: string; readonly attributesToEncrypt: ReadonlySet; readonly attributesToExcludeFromAAD?: ReadonlySet; - readonly allowPredefinedID?: boolean; } /** @@ -145,25 +144,6 @@ export class EncryptedSavedObjectsService { return this.typeDefinitions.has(type); } - /** - * Checks whether ID can be specified for the provided saved object. - * - * If the type isn't registered as an encrypted saved object, or when overwriting an existing - * saved object with a version specified, this will return "true". - * - * @param type Saved object type. - * @param version Saved object version number which changes on each successful write operation. - * Can be used in conjunction with `overwrite` for implementing optimistic concurrency - * control. - * @param overwrite Overwrite existing documents. - */ - public canSpecifyID(type: string, version?: string, overwrite?: boolean) { - const typeDefinition = this.typeDefinitions.get(type); - return ( - typeDefinition === undefined || typeDefinition.allowPredefinedID || !!(version && overwrite) - ); - } - /** * Takes saved object attributes for the specified type and, depending on the type definition, * either decrypts or strips encrypted attributes (e.g. in case AAD or encryption key has changed diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 3c722ccfabae2..85ec08fb7388d 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -13,7 +13,18 @@ import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/s import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock'; import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock'; -jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('uuid-v4-id') })); +jest.mock('../../../../../src/core/server/saved_objects/service/lib/utils', () => { + const { SavedObjectsUtils } = jest.requireActual( + '../../../../../src/core/server/saved_objects/service/lib/utils' + ); + return { + SavedObjectsUtils: { + namespaceStringToId: SavedObjectsUtils.namespaceStringToId, + isRandomId: SavedObjectsUtils.isRandomId, + generateId: () => 'mock-saved-object-id', + }, + }; +}); let wrapper: EncryptedSavedObjectsClientWrapper; let mockBaseClient: jest.Mocked; @@ -30,11 +41,6 @@ beforeEach(() => { { key: 'attrNotSoSecret', dangerouslyExposeValue: true }, ]), }, - { - type: 'known-type-predefined-id', - attributesToEncrypt: new Set(['attrSecret']), - allowPredefinedID: true, - }, ]); wrapper = new EncryptedSavedObjectsClientWrapper({ @@ -77,36 +83,16 @@ describe('#create', () => { expect(mockBaseClient.create).toHaveBeenCalledWith('unknown-type', attributes, options); }); - it('fails if type is registered without allowPredefinedID and ID is specified', async () => { + it('fails if type is registered and ID is specified', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; await expect(wrapper.create('known-type', attributes, { id: 'some-id' })).rejects.toThrowError( - 'Predefined IDs are not allowed for encrypted saved objects of type "known-type".' + 'Predefined IDs are not allowed for saved objects with encrypted attributes, unless the ID has been generated using `SavedObjectsUtils.generateId`.' ); expect(mockBaseClient.create).not.toHaveBeenCalled(); }); - it('succeeds if type is registered with allowPredefinedID and ID is specified', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const mockedResponse = { - id: 'some-id', - type: 'known-type-predefined-id', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - references: [], - }; - - mockBaseClient.create.mockResolvedValue(mockedResponse); - await expect( - wrapper.create('known-type-predefined-id', attributes, { id: 'some-id' }) - ).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, - }); - - expect(mockBaseClient.create).toHaveBeenCalled(); - }); - it('allows a specified ID when overwriting an existing object', async () => { const attributes = { attrOne: 'one', @@ -168,7 +154,7 @@ describe('#create', () => { }; const options = { overwrite: true }; const mockedResponse = { - id: 'uuid-v4-id', + id: 'mock-saved-object-id', type: 'known-type', attributes: { attrOne: 'one', @@ -188,7 +174,7 @@ describe('#create', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'uuid-v4-id' }, + { type: 'known-type', id: 'mock-saved-object-id' }, { attrOne: 'one', attrSecret: 'secret', @@ -207,7 +193,7 @@ describe('#create', () => { attrNotSoSecret: '*not-so-secret*', attrThree: 'three', }, - { id: 'uuid-v4-id', overwrite: true } + { id: 'mock-saved-object-id', overwrite: true } ); }); @@ -216,7 +202,7 @@ describe('#create', () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; const options = { overwrite: true, namespace }; const mockedResponse = { - id: 'uuid-v4-id', + id: 'mock-saved-object-id', type: 'known-type', attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, references: [], @@ -233,7 +219,7 @@ describe('#create', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { type: 'known-type', - id: 'uuid-v4-id', + id: 'mock-saved-object-id', namespace: expectNamespaceInDescriptor ? namespace : undefined, }, { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, @@ -244,7 +230,7 @@ describe('#create', () => { expect(mockBaseClient.create).toHaveBeenCalledWith( 'known-type', { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - { id: 'uuid-v4-id', overwrite: true, namespace } + { id: 'mock-saved-object-id', overwrite: true, namespace } ); }; @@ -270,7 +256,7 @@ describe('#create', () => { expect(mockBaseClient.create).toHaveBeenCalledWith( 'known-type', { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - { id: 'uuid-v4-id' } + { id: 'mock-saved-object-id' } ); }); }); @@ -282,7 +268,7 @@ describe('#bulkCreate', () => { const mockedResponse = { saved_objects: [ { - id: 'uuid-v4-id', + id: 'mock-saved-object-id', type: 'known-type', attributes, references: [], @@ -315,7 +301,7 @@ describe('#bulkCreate', () => { [ { ...bulkCreateParams[0], - id: 'uuid-v4-id', + id: 'mock-saved-object-id', attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, }, bulkCreateParams[1], @@ -324,7 +310,7 @@ describe('#bulkCreate', () => { ); }); - it('fails if ID is specified for registered type without allowPredefinedID', async () => { + it('fails if ID is specified for registered type', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; const bulkCreateParams = [ @@ -333,48 +319,12 @@ describe('#bulkCreate', () => { ]; await expect(wrapper.bulkCreate(bulkCreateParams)).rejects.toThrowError( - 'Predefined IDs are not allowed for encrypted saved objects of type "known-type".' + 'Predefined IDs are not allowed for saved objects with encrypted attributes, unless the ID has been generated using `SavedObjectsUtils.generateId`.' ); expect(mockBaseClient.bulkCreate).not.toHaveBeenCalled(); }); - it('succeeds if ID is specified for registered type with allowPredefinedID', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { namespace: 'some-namespace' }; - const mockedResponse = { - saved_objects: [ - { - id: 'some-id', - type: 'known-type-predefined-id', - attributes, - references: [], - }, - { - id: 'some-id', - type: 'unknown-type', - attributes, - references: [], - }, - ], - }; - mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); - - const bulkCreateParams = [ - { id: 'some-id', type: 'known-type-predefined-id', attributes }, - { type: 'unknown-type', attributes }, - ]; - - await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({ - saved_objects: [ - { ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } }, - mockedResponse.saved_objects[1], - ], - }); - - expect(mockBaseClient.bulkCreate).toHaveBeenCalled(); - }); - it('allows a specified ID when overwriting an existing object', async () => { const attributes = { attrOne: 'one', @@ -456,7 +406,7 @@ describe('#bulkCreate', () => { const mockedResponse = { saved_objects: [ { - id: 'uuid-v4-id', + id: 'mock-saved-object-id', type: 'known-type', attributes: { ...attributes, attrSecret: '*secret*', attrNotSoSecret: '*not-so-secret*' }, references: [], @@ -489,7 +439,7 @@ describe('#bulkCreate', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'uuid-v4-id' }, + { type: 'known-type', id: 'mock-saved-object-id' }, { attrOne: 'one', attrSecret: 'secret', @@ -504,7 +454,7 @@ describe('#bulkCreate', () => { [ { ...bulkCreateParams[0], - id: 'uuid-v4-id', + id: 'mock-saved-object-id', attributes: { attrOne: 'one', attrSecret: '*secret*', @@ -523,7 +473,9 @@ describe('#bulkCreate', () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; const options = { namespace }; const mockedResponse = { - saved_objects: [{ id: 'uuid-v4-id', type: 'known-type', attributes, references: [] }], + saved_objects: [ + { id: 'mock-saved-object-id', type: 'known-type', attributes, references: [] }, + ], }; mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); @@ -542,7 +494,7 @@ describe('#bulkCreate', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { type: 'known-type', - id: 'uuid-v4-id', + id: 'mock-saved-object-id', namespace: expectNamespaceInDescriptor ? namespace : undefined, }, { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, @@ -554,7 +506,7 @@ describe('#bulkCreate', () => { [ { ...bulkCreateParams[0], - id: 'uuid-v4-id', + id: 'mock-saved-object-id', attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, }, ], @@ -590,7 +542,7 @@ describe('#bulkCreate', () => { [ { type: 'known-type', - id: 'uuid-v4-id', + id: 'mock-saved-object-id', attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, }, ], diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index ddef9f477433c..313e7c7da9eba 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import uuid from 'uuid'; import { SavedObject, SavedObjectsBaseOptions, @@ -25,7 +24,8 @@ import { SavedObjectsRemoveReferencesToOptions, ISavedObjectTypeRegistry, SavedObjectsRemoveReferencesToResponse, -} from 'src/core/server'; + SavedObjectsUtils, +} from '../../../../../src/core/server'; import { AuthenticatedUser } from '../../../security/common/model'; import { EncryptedSavedObjectsService } from '../crypto'; import { getDescriptorNamespace } from './get_descriptor_namespace'; @@ -37,14 +37,6 @@ interface EncryptedSavedObjectsClientOptions { getCurrentUser: () => AuthenticatedUser | undefined; } -/** - * Generates UUIDv4 ID for the any newly created saved object that is supposed to contain - * encrypted attributes. - */ -function generateID() { - return uuid.v4(); -} - export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientContract { constructor( private readonly options: EncryptedSavedObjectsClientOptions, @@ -67,19 +59,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.create(type, attributes, options); } - // Saved objects with encrypted attributes should have IDs that are hard to guess especially - // since IDs are part of the AAD used during encryption. Types can opt-out of this restriction, - // when necessary, but it's much safer for this wrapper to generate them. - if ( - options.id && - !this.options.service.canSpecifyID(type, options.version, options.overwrite) - ) { - throw new Error( - `Predefined IDs are not allowed for encrypted saved objects of type "${type}".` - ); - } - - const id = options.id ?? generateID(); + const id = getValidId(options.id, options.version, options.overwrite); const namespace = getDescriptorNamespace( this.options.baseTypeRegistry, type, @@ -113,19 +93,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return object; } - // Saved objects with encrypted attributes should have IDs that are hard to guess especially - // since IDs are part of the AAD used during encryption, that's why we control them within this - // wrapper and don't allow consumers to specify their own IDs directly unless overwriting the original document. - if ( - object.id && - !this.options.service.canSpecifyID(object.type, object.version, options?.overwrite) - ) { - throw new Error( - `Predefined IDs are not allowed for encrypted saved objects of type "${object.type}".` - ); - } - - const id = object.id ?? generateID(); + const id = getValidId(object.id, object.version, options?.overwrite); const namespace = getDescriptorNamespace( this.options.baseTypeRegistry, object.type, @@ -327,3 +295,26 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return response; } } + +// Saved objects with encrypted attributes should have IDs that are hard to guess especially +// since IDs are part of the AAD used during encryption, that's why we control them within this +// wrapper and don't allow consumers to specify their own IDs directly unless overwriting the original document. +function getValidId( + id: string | undefined, + version: string | undefined, + overwrite: boolean | undefined +) { + if (id) { + // only allow a specified ID if we're overwriting an existing ESO with a Version + // this helps us ensure that the document really was previously created using ESO + // and not being used to get around the specified ID limitation + const canSpecifyID = (overwrite && version) || SavedObjectsUtils.isRandomId(id); + if (!canSpecifyID) { + throw new Error( + 'Predefined IDs are not allowed for saved objects with encrypted attributes, unless the ID has been generated using `SavedObjectsUtils.generateId`.' + ); + } + return id; + } + return SavedObjectsUtils.generateId(); +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts index 51b5735f01045..a0a9cb5a61367 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { keys, pickBy } from 'lodash'; +import { keys, pickBy, isEmpty } from 'lodash'; import { kea, MakeLogicType } from 'kea'; @@ -486,8 +486,10 @@ export const SourceLogic = kea>({ if (subdomain) params.append('subdomain', subdomain); if (indexPermissions) params.append('index_permissions', indexPermissions.toString()); + const paramsString = !isEmpty(params) ? `?${params}` : ''; + try { - const response = await HttpLogic.values.http.get(`${route}?${params}`); + const response = await HttpLogic.values.http.get(`${route}${paramsString}`); actions.setSourceConnectData(response); successCallback(response.oauthUrl); } catch (e) { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 1757f2a6414f7..4a7d44a936d9a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -14,7 +14,7 @@ import { HttpLogic } from '../../../shared/http'; import { flashAPIErrors, - setSuccessMessage, + setQueuedSuccessMessage, FlashMessagesLogic, } from '../../../shared/flash_messages'; @@ -225,7 +225,7 @@ export const SourcesLogic = kea>( } ); - setSuccessMessage( + setQueuedSuccessMessage( [ successfullyConnectedMessage, additionalConfiguration ? additionalConfigurationMessage : '', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/danger_eui_context_menu_item.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/danger_eui_context_menu_item.tsx index 54dc8ab6188b7..b90b2fd5441b5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/danger_eui_context_menu_item.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/danger_eui_context_menu_item.tsx @@ -8,5 +8,5 @@ import styled from 'styled-components'; import { EuiContextMenuItem } from '@elastic/eui'; export const DangerEuiContextMenuItem = styled(EuiContextMenuItem)` - color: ${(props) => props.theme.eui.textColors.danger}; + color: ${(props) => props.theme.eui.euiTextColors.danger}; `; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx index 0cad0b4d487d0..f89b8b53a1878 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_integrations.tsx @@ -10,9 +10,11 @@ import { EuiLink, EuiAccordion, EuiTitle, + EuiToolTip, EuiPanel, EuiButtonIcon, EuiBasicTable, + EuiBasicTableProps, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; @@ -25,8 +27,15 @@ const StyledEuiAccordion = styled(EuiAccordion)` .ingest-integration-title-button { padding: ${(props) => props.theme.eui.paddingSizes.m} ${(props) => props.theme.eui.paddingSizes.m}; + } + + &.euiAccordion-isOpen .ingest-integration-title-button { border-bottom: 1px solid ${(props) => props.theme.eui.euiColorLightShade}; } + + .euiTableRow:last-child .euiTableRowCell { + border-bottom: none; + } `; const CollapsablePanel: React.FC<{ id: string; title: React.ReactNode }> = ({ @@ -35,7 +44,7 @@ const CollapsablePanel: React.FC<{ id: string; title: React.ReactNode }> = ({ children, }) => { return ( - + input.enabled); }, [packagePolicy.inputs]); - const columns = [ + const columns: EuiBasicTableProps['columns'] = [ { field: 'type', width: '100%', @@ -71,6 +80,7 @@ export const AgentDetailsIntegration: React.FunctionComponent<{ }, }, { + align: 'right', name: i18n.translate('xpack.fleet.agentDetailsIntegrations.actionsLabel', { defaultMessage: 'Actions', }), @@ -78,17 +88,20 @@ export const AgentDetailsIntegration: React.FunctionComponent<{ width: 'auto', render: (inputType: string) => { return ( - + > + + ); }, }, @@ -142,7 +155,7 @@ export const AgentDetailsIntegrationsSection: React.FunctionComponent<{ } return ( - + {(agentPolicy.package_policies as PackagePolicy[]).map((packagePolicy) => { return ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx index a19f6658ef93f..81195bdeaa9e2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx @@ -11,10 +11,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, + EuiIcon, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiText } from '@elastic/eui'; -import { EuiIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent, AgentPolicy } from '../../../../../types'; import { useKibanaVersion, useLink } from '../../../../../hooks'; @@ -66,14 +66,14 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{ {isAgentUpgradeable(agent, kibanaVersion) ? ( - - -   - - + + + ) : null} @@ -81,12 +81,6 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{ '-' ), }, - { - title: i18n.translate('xpack.fleet.agentDetails.enrollmentTokenLabel', { - defaultMessage: 'Enrollment token', - }), - description: '-', // Fixme when we have the enrollment tokenhttps://github.com/elastic/kibana/issues/61269 - }, { title: i18n.translate('xpack.fleet.agentDetails.integrationsLabel', { defaultMessage: 'Integrations', diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index 4cf759f4882ef..db180d027b228 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -252,6 +252,7 @@ export function SearchBar({ return ( } searchProps={{ - onSearch: () => undefined, onKeyUpCapture: (e: React.KeyboardEvent) => setSearchValue(e.currentTarget.value), 'data-test-subj': 'nav-search-input', diff --git a/x-pack/plugins/graph/public/components/settings/use_list_keys.test.tsx b/x-pack/plugins/graph/public/components/settings/use_list_keys.test.tsx index c80296feccdd4..aa17ef6153f92 100644 --- a/x-pack/plugins/graph/public/components/settings/use_list_keys.test.tsx +++ b/x-pack/plugins/graph/public/components/settings/use_list_keys.test.tsx @@ -8,6 +8,10 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { useListKeys } from './use_list_keys'; +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + describe('use_list_keys', () => { function ListingComponent({ items }: { items: object[] }) { const getListKey = useListKeys(items); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap index 6106503566c2f..b7c1044754b31 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap @@ -349,7 +349,6 @@ exports[`extend index management ilm summary extension should return extension w panelPaddingSize="m" >
{ return ( - + ); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index f9f2233ff02ee..6bb51602df21f 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -9,12 +9,15 @@ import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBedConfig } from '@kbn/test/jest'; +import { licensingMock } from '../../../../licensing/public/mocks'; + import { EditPolicy } from '../../../public/application/sections/edit_policy'; import { DataTierAllocationType } from '../../../public/application/sections/edit_policy/types'; import { Phases as PolicyPhases } from '../../../common/types'; import { KibanaContextProvider } from '../../../public/shared_imports'; +import { AppServicesContext } from '../../../public/types'; import { createBreadcrumbsMock } from '../../../public/application/services/breadcrumbs.mock'; type Phases = keyof PolicyPhases; @@ -53,10 +56,16 @@ const testBedConfig: TestBedConfig = { const breadcrumbService = createBreadcrumbsMock(); -const MyComponent = (props: any) => { +const MyComponent = ({ appServicesContext, ...rest }: any) => { return ( - - + + ); }; @@ -67,10 +76,10 @@ type SetupReturn = ReturnType; export type EditPolicyTestBed = SetupReturn extends Promise ? U : SetupReturn; -export const setup = async () => { - const testBed = await initTestBed(); +export const setup = async (arg?: { appServicesContext: Partial }) => { + const testBed = await initTestBed(arg); - const { find, component, form } = testBed; + const { find, component, form, exists } = testBed; const createFormToggleAction = (dataTestSubject: string) => async (checked: boolean) => { await act(async () => { @@ -128,12 +137,15 @@ export const setup = async () => { component.update(); }; - const toggleForceMerge = (phase: Phases) => createFormToggleAction(`${phase}-forceMergeSwitch`); - - const setForcemergeSegmentsCount = (phase: Phases) => - createFormSetValueAction(`${phase}-selectedForceMergeSegments`); - - const setBestCompression = (phase: Phases) => createFormToggleAction(`${phase}-bestCompression`); + const createForceMergeActions = (phase: Phases) => { + const toggleSelector = `${phase}-forceMergeSwitch`; + return { + forceMergeFieldExists: () => exists(toggleSelector), + toggleForceMerge: createFormToggleAction(toggleSelector), + setForcemergeSegmentsCount: createFormSetValueAction(`${phase}-selectedForceMergeSegments`), + setBestCompression: createFormToggleAction(`${phase}-bestCompression`), + }; + }; const setIndexPriority = (phase: Phases) => createFormSetValueAction(`${phase}-phaseIndexPriority`); @@ -180,7 +192,35 @@ export const setup = async () => { await createFormSetValueAction('warm-selectedPrimaryShardCount')(value); }; + const shrinkExists = () => exists('shrinkSwitch'); + const setFreeze = createFormToggleAction('freezeSwitch'); + const freezeExists = () => exists('freezeSwitch'); + + const createSearchableSnapshotActions = (phase: Phases) => { + const fieldSelector = `searchableSnapshotField-${phase}`; + const licenseCalloutSelector = `${fieldSelector}.searchableSnapshotDisabledDueToLicense`; + const toggleSelector = `${fieldSelector}.searchableSnapshotToggle`; + + const toggleSearchableSnapshot = createFormToggleAction(toggleSelector); + return { + searchableSnapshotDisabled: () => exists(licenseCalloutSelector), + searchableSnapshotsExists: () => exists(fieldSelector), + findSearchableSnapshotToggle: () => find(toggleSelector), + searchableSnapshotDisabledDueToLicense: () => + exists(`${fieldSelector}.searchableSnapshotDisabledDueToLicense`), + toggleSearchableSnapshot, + setSearchableSnapshot: async (value: string) => { + await toggleSearchableSnapshot(true); + act(() => { + find(`searchableSnapshotField-${phase}.searchableSnapshotCombobox`).simulate('change', [ + { label: value }, + ]); + }); + component.update(); + }, + }; + }; return { ...testBed, @@ -192,10 +232,9 @@ export const setup = async () => { setMaxDocs, setMaxAge, toggleRollover, - toggleForceMerge: toggleForceMerge('hot'), - setForcemergeSegments: setForcemergeSegmentsCount('hot'), - setBestCompression: setBestCompression('hot'), + ...createForceMergeActions('hot'), setIndexPriority: setIndexPriority('hot'), + ...createSearchableSnapshotActions('hot'), }, warm: { enable: enable('warm'), @@ -206,9 +245,8 @@ export const setup = async () => { setSelectedNodeAttribute: setSelectedNodeAttribute('warm'), setReplicas: setReplicas('warm'), setShrink, - toggleForceMerge: toggleForceMerge('warm'), - setForcemergeSegments: setForcemergeSegmentsCount('warm'), - setBestCompression: setBestCompression('warm'), + shrinkExists, + ...createForceMergeActions('warm'), setIndexPriority: setIndexPriority('warm'), }, cold: { @@ -219,7 +257,9 @@ export const setup = async () => { setSelectedNodeAttribute: setSelectedNodeAttribute('cold'), setReplicas: setReplicas('cold'), setFreeze, + freezeExists, setIndexPriority: setIndexPriority('cold'), + ...createSearchableSnapshotActions('cold'), }, delete: { enable: enable('delete'), diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index a203a434bb21a..12a061f0980dd 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -6,10 +6,11 @@ import { act } from 'react-dom/test-utils'; +import { licensingMock } from '../../../../licensing/public/mocks'; +import { API_BASE_PATH } from '../../../common/constants'; import { setupEnvironment } from '../helpers/setup_environment'; import { EditPolicyTestBed, setup } from './edit_policy.helpers'; -import { API_BASE_PATH } from '../../../common/constants'; import { DELETE_PHASE_POLICY, NEW_SNAPSHOT_POLICY_NAME, @@ -100,6 +101,11 @@ describe('', () => { describe('serialization', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + nodesByRoles: {}, + nodesByAttributes: { test: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, + }); httpRequestsMockHelpers.setLoadSnapshotPolicies([]); await act(async () => { @@ -117,7 +123,7 @@ describe('', () => { await actions.hot.setMaxDocs('123'); await actions.hot.setMaxAge('123', 'h'); await actions.hot.toggleForceMerge(true); - await actions.hot.setForcemergeSegments('123'); + await actions.hot.setForcemergeSegmentsCount('123'); await actions.hot.setBestCompression(true); await actions.hot.setIndexPriority('123'); @@ -150,6 +156,19 @@ describe('', () => { `); }); + test('setting searchable snapshot', async () => { + const { actions } = testBed; + + await actions.hot.setSearchableSnapshot('my-repo'); + + await actions.savePolicy(); + const latestRequest = server.requests[server.requests.length - 1]; + const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(entirePolicy.phases.hot.actions.searchable_snapshot.snapshot_repository).toBe( + 'my-repo' + ); + }); + test('disabling rollover', async () => { const { actions } = testBed; await actions.hot.toggleRollover(true); @@ -167,6 +186,26 @@ describe('', () => { } `); }); + + test('enabling searchable snapshot should hide force merge, freeze and shrink in subsequent phases', async () => { + const { actions } = testBed; + + await actions.warm.enable(true); + await actions.cold.enable(true); + + expect(actions.warm.forceMergeFieldExists()).toBeTruthy(); + expect(actions.warm.shrinkExists()).toBeTruthy(); + expect(actions.cold.searchableSnapshotsExists()).toBeTruthy(); + expect(actions.cold.freezeExists()).toBeTruthy(); + + await actions.hot.setSearchableSnapshot('my-repo'); + + expect(actions.warm.forceMergeFieldExists()).toBeFalsy(); + expect(actions.warm.shrinkExists()).toBeFalsy(); + // searchable snapshot in cold is still visible + expect(actions.cold.searchableSnapshotsExists()).toBeTruthy(); + expect(actions.cold.freezeExists()).toBeFalsy(); + }); }); }); @@ -202,7 +241,6 @@ describe('', () => { "priority": 50, }, }, - "min_age": "0ms", } `); }); @@ -210,14 +248,12 @@ describe('', () => { test('setting all values', async () => { const { actions } = testBed; await actions.warm.enable(true); - await actions.warm.setMinAgeValue('123'); - await actions.warm.setMinAgeUnits('d'); await actions.warm.setDataAllocation('node_attrs'); await actions.warm.setSelectedNodeAttribute('test:123'); await actions.warm.setReplicas('123'); await actions.warm.setShrink('123'); await actions.warm.toggleForceMerge(true); - await actions.warm.setForcemergeSegments('123'); + await actions.warm.setForcemergeSegmentsCount('123'); await actions.warm.setBestCompression(true); await actions.warm.setIndexPriority('123'); await actions.savePolicy(); @@ -259,22 +295,23 @@ describe('', () => { "number_of_shards": 123, }, }, - "min_age": "123d", }, }, } `); }); - test('setting warm phase on rollover to "true"', async () => { + test('setting warm phase on rollover to "false"', async () => { const { actions } = testBed; await actions.warm.enable(true); - await actions.warm.warmPhaseOnRollover(true); + await actions.warm.warmPhaseOnRollover(false); + await actions.warm.setMinAgeValue('123'); + await actions.warm.setMinAgeUnits('d'); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; const warmPhaseMinAge = JSON.parse(JSON.parse(latestRequest.requestBody).body).phases.warm .min_age; - expect(warmPhaseMinAge).toBe(undefined); + expect(warmPhaseMinAge).toBe('123d'); }); }); @@ -359,7 +396,7 @@ describe('', () => { `); }); - test('setting all values', async () => { + test('setting all values, excluding searchable snapshot', async () => { const { actions } = testBed; await actions.cold.enable(true); @@ -410,6 +447,19 @@ describe('', () => { } `); }); + + // Setting searchable snapshot field disables setting replicas so we test this separately + test('setting searchable snapshot', async () => { + const { actions } = testBed; + await actions.cold.enable(true); + await actions.cold.setSearchableSnapshot('my-repo'); + await actions.savePolicy(); + const latestRequest2 = server.requests[server.requests.length - 1]; + const entirePolicy2 = JSON.parse(JSON.parse(latestRequest2.requestBody).body); + expect(entirePolicy2.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( + 'my-repo' + ); + }); }); }); @@ -598,6 +648,7 @@ describe('', () => { `); }); }); + describe('node attr and none', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([POLICY_WITH_NODE_ATTR_AND_OFF_ALLOCATION]); @@ -625,4 +676,73 @@ describe('', () => { }); }); }); + + describe('searchable snapshot', () => { + describe('on cloud', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + isUsingDeprecatedDataRoleConfig: false, + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data: ['123'] }, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + + const { component } = testBed; + component.update(); + }); + + test('correctly sets snapshot repository default to "found-snapshots"', async () => { + const { actions } = testBed; + await actions.cold.enable(true); + await actions.cold.toggleSearchableSnapshot(true); + await actions.savePolicy(); + const latestRequest = server.requests[server.requests.length - 1]; + const request = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(request.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( + 'found-snapshots' + ); + }); + }); + describe('on non-enterprise license', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + isUsingDeprecatedDataRoleConfig: false, + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data: ['123'] }, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + + await act(async () => { + testBed = await setup({ + appServicesContext: { + license: licensingMock.createLicense({ license: { type: 'basic' } }), + }, + }); + }); + + const { component } = testBed; + component.update(); + }); + test('disable setting searchable snapshots', async () => { + const { actions } = testBed; + + expect(actions.cold.searchableSnapshotsExists()).toBeFalsy(); + expect(actions.hot.searchableSnapshotsExists()).toBeFalsy(); + + await actions.cold.enable(true); + + // Still hidden in hot + expect(actions.hot.searchableSnapshotsExists()).toBeFalsy(); + + expect(actions.cold.searchableSnapshotsExists()).toBeTruthy(); + expect(actions.cold.searchableSnapshotDisabledDueToLicense()).toBeTruthy(); + }); + }); + }); }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts index c7a493ce80d96..d9bb6702cb166 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts @@ -6,7 +6,7 @@ import { fakeServer, SinonFakeServer } from 'sinon'; import { API_BASE_PATH } from '../../../common/constants'; -import { ListNodesRouteResponse } from '../../../common/types'; +import { ListNodesRouteResponse, ListSnapshotReposResponse } from '../../../common/types'; export const init = () => { const server = fakeServer.create(); @@ -47,9 +47,18 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setListSnapshotRepos = (body: ListSnapshotReposResponse) => { + server.respondWith('GET', `${API_BASE_PATH}/snapshot_repositories`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + return { setLoadPolicies, setLoadSnapshotPolicies, setListNodes, + setListSnapshotRepos, }; }; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/README.md b/x-pack/plugins/index_lifecycle_management/__jest__/components/README.md new file mode 100644 index 0000000000000..ce1ea7aa396a6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/README.md @@ -0,0 +1,8 @@ +# Deprecated + +This test folder contains useful test coverage, mostly error states for form validation. However, it is +not in keeping with other ES UI maintained plugins. See ../client_integration for the established pattern +of tests. + +The tests here should be migrated to the above pattern and should not be added to. Any new test coverage must +be added to ../client_integration. diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index eb17402a46950..65952e81ae0ff 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -172,6 +172,9 @@ const MyComponent = ({ existingPolicies, policyName, getUrlForApp, + license: { + canUseSearchableSnapshot: () => true, + }, }} > @@ -209,6 +212,7 @@ describe('edit policy', () => { getUrlForApp={jest.fn()} policyName="test" isCloudEnabled={false} + license={{ canUseSearchableSnapshot: () => true }} /> ); @@ -247,6 +251,7 @@ describe('edit policy', () => { existingPolicies={policies} getUrlForApp={jest.fn()} isCloudEnabled={false} + license={{ canUseSearchableSnapshot: () => true }} /> ); const rendered = mountWithIntl(component); @@ -283,6 +288,7 @@ describe('edit policy', () => { existingPolicies={policies} getUrlForApp={jest.fn()} isCloudEnabled={false} + license={{ canUseSearchableSnapshot: () => true }} /> ); @@ -827,6 +833,7 @@ describe('edit policy', () => { existingPolicies={policies} getUrlForApp={jest.fn()} isCloudEnabled={true} + license={{ canUseSearchableSnapshot: () => true }} /> ); ({ http } = editPolicyHelpers.setup()); diff --git a/x-pack/plugins/index_lifecycle_management/common/constants/index.ts b/x-pack/plugins/index_lifecycle_management/common/constants/index.ts index 522dc6d82a4e9..7982bdb211ae7 100644 --- a/x-pack/plugins/index_lifecycle_management/common/constants/index.ts +++ b/x-pack/plugins/index_lifecycle_management/common/constants/index.ts @@ -5,18 +5,19 @@ */ import { i18n } from '@kbn/i18n'; -import { LicenseType } from '../../../licensing/common/types'; export { phaseToNodePreferenceMap } from './data_tiers'; -const basicLicense: LicenseType = 'basic'; +import { MIN_PLUGIN_LICENSE, MIN_SEARCHABLE_SNAPSHOT_LICENSE } from './license'; export const PLUGIN = { ID: 'index_lifecycle_management', - minimumLicenseType: basicLicense, + minimumLicenseType: MIN_PLUGIN_LICENSE, TITLE: i18n.translate('xpack.indexLifecycleMgmt.appTitle', { defaultMessage: 'Index Lifecycle Policies', }), }; export const API_BASE_PATH = '/api/index_lifecycle_management'; + +export { MIN_SEARCHABLE_SNAPSHOT_LICENSE, MIN_PLUGIN_LICENSE }; diff --git a/x-pack/plugins/index_lifecycle_management/common/constants/license.ts b/x-pack/plugins/index_lifecycle_management/common/constants/license.ts new file mode 100644 index 0000000000000..ccb0a2a59a315 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/common/constants/license.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LicenseType } from '../../../licensing/common/types'; + +export const MIN_PLUGIN_LICENSE: LicenseType = 'basic'; + +export const MIN_SEARCHABLE_SNAPSHOT_LICENSE: LicenseType = 'enterprise'; diff --git a/x-pack/plugins/index_lifecycle_management/common/types/api.ts b/x-pack/plugins/index_lifecycle_management/common/types/api.ts index b7ca16ac46dde..c0355daf3c62a 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/api.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/api.ts @@ -18,3 +18,10 @@ export interface ListNodesRouteResponse { */ isUsingDeprecatedDataRoleConfig: boolean; } + +export interface ListSnapshotReposResponse { + /** + * An array of repository names + */ + repositories: string[]; +} diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index dd5fb9e014446..94cc11d0b61a6 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -48,6 +48,15 @@ export interface SerializedActionWithAllocation { migrate?: MigrateAction; } +export interface SearchableSnapshotAction { + snapshot_repository: string; + /** + * We do not configure this value in the UI as it is an advanced setting that will + * not suit the vast majority of cases. + */ + force_merge_index?: boolean; +} + export interface SerializedHotPhase extends SerializedPhase { actions: { rollover?: { @@ -59,6 +68,10 @@ export interface SerializedHotPhase extends SerializedPhase { set_priority?: { priority: number | null; }; + /** + * Only available on enterprise license + */ + searchable_snapshot?: SearchableSnapshotAction; }; } @@ -84,6 +97,10 @@ export interface SerializedColdPhase extends SerializedPhase { priority: number | null; }; migrate?: MigrateAction; + /** + * Only available on enterprise license + */ + searchable_snapshot?: SearchableSnapshotAction; }; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/index.tsx b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx index bb1a4810ba2d2..e44854985c056 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/index.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/index.tsx @@ -9,6 +9,7 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { I18nStart, ScopedHistory, ApplicationStart } from 'kibana/public'; import { UnmountCallback } from 'src/core/public'; import { CloudSetup } from '../../../cloud/public'; +import { ILicense } from '../../../licensing/public'; import { KibanaContextProvider } from '../shared_imports'; @@ -23,11 +24,12 @@ export const renderApp = ( navigateToApp: ApplicationStart['navigateToApp'], getUrlForApp: ApplicationStart['getUrlForApp'], breadcrumbService: BreadcrumbService, + license: ILicense, cloud?: CloudSetup ): UnmountCallback => { render( - + JSX.Element) | JSX.Element | JSX.Element[] | undefined; + switchProps?: Omit; }; export const DescribedFormField: FunctionComponent = ({ @@ -20,7 +21,13 @@ export const DescribedFormField: FunctionComponent = ({ }) => { return ( - {children} + {switchProps ? ( + {children} + ) : typeof children === 'function' ? ( + children() + ) : ( + children + )} ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/field_loading_error.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/field_loading_error.tsx new file mode 100644 index 0000000000000..de1a6875c29f4 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/field_loading_error.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiSpacer, EuiButtonIcon } from '@elastic/eui'; + +interface Props { + title: React.ReactNode; + body: React.ReactNode; + resendRequest: () => void; + 'data-test-subj'?: string; + 'aria-label'?: string; +} + +export const FieldLoadingError: FunctionComponent = (props) => { + const { title, body, resendRequest } = props; + return ( + <> + + + {title} + + + + } + > + {body} + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index 326f6ff87dc3b..265996c650024 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -10,5 +10,6 @@ export { LearnMoreLink } from './learn_more_link'; export { OptionalLabel } from './optional_label'; export { PolicyJsonFlyout } from './policy_json_flyout'; export { DescribedFormField } from './described_form_field'; +export { FieldLoadingError } from './field_loading_error'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index b87243bd1a9a1..2f5be3e45cbe7 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -9,17 +9,23 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; -import { EuiDescribedFormGroup, EuiTextColor } from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiTextColor, EuiAccordion } from '@elastic/eui'; import { Phases } from '../../../../../../../common/types'; import { useFormData, UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; import { useEditPolicyContext } from '../../../edit_policy_context'; +import { useConfigurationIssues } from '../../../form'; import { LearnMoreLink, ActiveBadge, DescribedFormField } from '../../'; -import { MinAgeInputField, DataTierAllocationField, SetPriorityInput } from '../shared_fields'; +import { + MinAgeInputField, + DataTierAllocationField, + SetPriorityInputField, + SearchableSnapshotField, +} from '../shared_fields'; const i18nTexts = { dataTierAllocation: { @@ -34,16 +40,19 @@ const coldProperty: keyof Phases = 'cold'; const formFieldPaths = { enabled: '_meta.cold.enabled', + searchableSnapshot: 'phases.cold.actions.searchable_snapshot.snapshot_repository', }; export const ColdPhase: FunctionComponent = () => { const { policy } = useEditPolicyContext(); + const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); const [formData] = useFormData({ - watch: [formFieldPaths.enabled], + watch: [formFieldPaths.enabled, formFieldPaths.searchableSnapshot], }); const enabled = get(formData, formFieldPaths.enabled); + const showReplicasField = get(formData, formFieldPaths.searchableSnapshot) == null; return (
@@ -91,83 +100,104 @@ export const ColdPhase: FunctionComponent = () => { {enabled && ( <> - {/* Data tier allocation section */} - - - {/* Replicas section */} - - {i18n.translate('xpack.indexLifecycleMgmt.coldPhase.replicasTitle', { - defaultMessage: 'Replicas', - })} - - } - description={i18n.translate( - 'xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasDescription', + + + - - - {/* Freeze section */} - - - - } - description={ - - {' '} - - + { + /* Replicas section */ + showReplicasField && ( + + {i18n.translate('xpack.indexLifecycleMgmt.coldPhase.replicasTitle', { + defaultMessage: 'Replicas', + })} + + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + 'data-test-subj': 'cold-setReplicasSwitch', + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel', + { defaultMessage: 'Set replicas' } + ), + initialValue: Boolean( + policy.phases.cold?.actions?.allocate?.number_of_replicas + ), + }} + fullWidth + > + + + ) } - fullWidth - titleSize="xs" - > - + + + } + description={ + + {' '} + + + } + fullWidth + titleSize="xs" + > + + + )} + {/* Data tier allocation section */} + - - + + )} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index 629c1388f61fb..d358fdeb25194 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FunctionComponent, useState } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -14,6 +14,8 @@ import { EuiSpacer, EuiDescribedFormGroup, EuiCallOut, + EuiAccordion, + EuiTextColor, } from '@elastic/eui'; import { Phases } from '../../../../../../../common/types'; @@ -28,29 +30,40 @@ import { import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION } from '../../../form'; +import { ROLLOVER_EMPTY_VALIDATION, useConfigurationIssues } from '../../../form'; + +import { useEditPolicyContext } from '../../../edit_policy_context'; import { ROLLOVER_FORM_PATHS } from '../../../constants'; -import { LearnMoreLink, ActiveBadge } from '../../'; +import { LearnMoreLink, ActiveBadge, DescribedFormField } from '../../'; -import { Forcemerge, SetPriorityInput, useRolloverPath } from '../shared_fields'; +import { + ForcemergeField, + SetPriorityInputField, + SearchableSnapshotField, + useRolloverPath, +} from '../shared_fields'; import { maxSizeStoredUnits, maxAgeUnits } from './constants'; const hotProperty: keyof Phases = 'hot'; export const HotPhase: FunctionComponent = () => { + const { license } = useEditPolicyContext(); const [formData] = useFormData({ watch: useRolloverPath, }); const isRolloverEnabled = get(formData, useRolloverPath); - const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); + const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); + return ( <>

@@ -62,166 +75,184 @@ export const HotPhase: FunctionComponent = () => {

} - titleSize="s" description={ - -

- -

-
+

+ +

} - fullWidth > - - key="_meta.hot.useRollover" - path="_meta.hot.useRollover" - component={ToggleField} - componentProps={{ - hasEmptyLabelSpace: true, - fullWidth: false, - helpText: ( - <> -

- -

+
+ + + + + {i18n.translate('xpack.indexLifecycleMgmt.hotPhase.rolloverFieldTitle', { + defaultMessage: 'Rollover', + })} + + } + description={ + +

+ {' '} } docPath="indices-rollover-index.html" /> - - - ), - euiFieldProps: { - 'data-test-subj': 'rolloverSwitch', - }, - }} - /> - {isRolloverEnabled && ( - <> - - {showEmptyRolloverFieldsError && ( - <> - -

{i18nTexts.editPolicy.errors.rollOverConfigurationCallout.body}
- - - - )} - - - - {(field) => { - const showErrorCallout = field.errors.some( - (e) => e.validationType === ROLLOVER_EMPTY_VALIDATION - ); - if (showErrorCallout !== showEmptyRolloverFieldsError) { - setShowEmptyRolloverFieldsError(showErrorCallout); - } - return ( - - ); - }} - - - - - - - - - - - - - - - - - - - - - - +

+
+ } + fullWidth + > + + key="_meta.hot.useRollover" + path="_meta.hot.useRollover" + component={ToggleField} + componentProps={{ + fullWidth: false, + euiFieldProps: { + 'data-test-subj': 'rolloverSwitch', + }, + }} + /> + {isRolloverEnabled && ( + <> + + {showEmptyRolloverFieldsError && ( + <> + +
{i18nTexts.editPolicy.errors.rollOverConfigurationCallout.body}
+
+ + + )} + + + + {(field) => { + const showErrorCallout = field.errors.some( + (e) => e.validationType === ROLLOVER_EMPTY_VALIDATION + ); + if (showErrorCallout !== showEmptyRolloverFieldsError) { + setShowEmptyRolloverFieldsError(showErrorCallout); + } + return ( + + ); + }} + + + + + + + + + + + + + + + + + + + + + + + )} +
+ {license.canUseSearchableSnapshot() && } + {isRolloverEnabled && !isUsingSearchableSnapshotInHotPhase && ( + )} - - {isRolloverEnabled && } - + +
); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/_data_tier_allocation.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/_data_tier_allocation.scss new file mode 100644 index 0000000000000..8449d5ea53bdf --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/_data_tier_allocation.scss @@ -0,0 +1,3 @@ +.ilmDataTierAllocationField { + max-width: $euiFormMaxWidth; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx index 73814537ff276..0879b12ed0b28 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx @@ -8,7 +8,7 @@ import { get } from 'lodash'; import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiDescribedFormGroup, EuiFormRow, EuiSpacer } from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiSpacer } from '@elastic/eui'; import { useKibana, useFormData } from '../../../../../../../shared_imports'; @@ -28,6 +28,8 @@ import { CloudDataTierCallout, } from './components'; +import './_data_tier_allocation.scss'; + const i18nTexts = { title: i18n.translate('xpack.indexLifecycleMgmt.common.dataTier.title', { defaultMessage: 'Data allocation', @@ -114,21 +116,19 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr description={description} fullWidth > - - <> - - - {/* Data tier related warnings and call-to-action notices */} - {renderNotice()} - - +
+ + + {/* Data tier related warnings and call-to-action notices */} + {renderNotice()} +
); }} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx index b05d49be497cd..fb7f93a42e491 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx @@ -20,7 +20,7 @@ interface Props { phase: 'hot' | 'warm'; } -export const Forcemerge: React.FunctionComponent = ({ phase }) => { +export const ForcemergeField: React.FunctionComponent = ({ phase }) => { const { policy } = useEditPolicyContext(); const initialToggleValue = useMemo(() => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts index 9cf6034a15e35..452abd4c2aeac 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts @@ -8,10 +8,12 @@ export { useRolloverPath } from '../../../constants'; export { DataTierAllocationField } from './data_tier_allocation_field'; -export { Forcemerge } from './forcemerge_field'; +export { ForcemergeField } from './forcemerge_field'; -export { SetPriorityInput } from './set_priority_input'; +export { SetPriorityInputField } from './set_priority_input_field'; export { MinAgeInputField } from './min_age_input_field'; export { SnapshotPoliciesField } from './snapshot_policies_field'; + +export { SearchableSnapshotField } from './searchable_snapshot_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/_searchable_snapshot_field.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/_searchable_snapshot_field.scss new file mode 100644 index 0000000000000..04fec443a5290 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/_searchable_snapshot_field.scss @@ -0,0 +1,3 @@ +.ilmSearchableSnapshotField { + max-width: $euiFormMaxWidth; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/index.ts new file mode 100644 index 0000000000000..2e8878004f544 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SearchableSnapshotField } from './searchable_snapshot_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_data_provider.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_data_provider.tsx new file mode 100644 index 0000000000000..c940dc88b16c0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_data_provider.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useLoadSnapshotRepositories } from '../../../../../../services/api'; + +interface Props { + children: (arg: ReturnType) => JSX.Element; +} + +export const SearchableSnapshotDataProvider = ({ children }: Props) => { + return children(useLoadSnapshotRepositories()); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx new file mode 100644 index 0000000000000..e5ab5fb6a2c71 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx @@ -0,0 +1,338 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent, useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiComboBoxOptionOption, + EuiTextColor, + EuiSpacer, + EuiCallOut, + EuiLink, +} from '@elastic/eui'; + +import { + UseField, + ComboBoxField, + useKibana, + fieldValidators, + useFormData, +} from '../../../../../../../shared_imports'; + +import { useEditPolicyContext } from '../../../../edit_policy_context'; +import { useConfigurationIssues } from '../../../../form'; + +import { i18nTexts } from '../../../../i18n_texts'; + +import { FieldLoadingError, DescribedFormField, LearnMoreLink } from '../../../index'; + +import { SearchableSnapshotDataProvider } from './searchable_snapshot_data_provider'; + +import './_searchable_snapshot_field.scss'; + +const { emptyField } = fieldValidators; + +export interface Props { + phase: 'hot' | 'cold'; +} + +/** + * This repository is provisioned by Elastic Cloud and will always + * exist as a "managed" repository. + */ +const CLOUD_DEFAULT_REPO = 'found-snapshots'; + +export const SearchableSnapshotField: FunctionComponent = ({ phase }) => { + const { + services: { cloud }, + } = useKibana(); + const { getUrlForApp, policy, license } = useEditPolicyContext(); + const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); + const searchableSnapshotPath = `phases.${phase}.actions.searchable_snapshot.snapshot_repository`; + + const isDisabledDueToLicense = !license.canUseSearchableSnapshot(); + const isDisabledInColdDueToHotPhase = phase === 'cold' && isUsingSearchableSnapshotInHotPhase; + + const isDisabled = isDisabledDueToLicense || isDisabledInColdDueToHotPhase; + + const [isFieldToggleChecked, setIsFieldToggleChecked] = useState(() => + Boolean(policy.phases[phase]?.actions?.searchable_snapshot?.snapshot_repository) + ); + + useEffect(() => { + if (isDisabled) { + setIsFieldToggleChecked(false); + } + }, [isDisabled]); + + const [formData] = useFormData({ watch: searchableSnapshotPath }); + const searchableSnapshotRepo = get(formData, searchableSnapshotPath); + + const renderField = () => ( + + {({ error, isLoading, resendRequest, data }) => { + const repos = data?.repositories ?? []; + + let calloutContent: React.ReactNode | undefined; + + if (!isLoading) { + if (error) { + calloutContent = ( + + } + body={ + + } + /> + ); + } else if (repos.length === 0) { + calloutContent = ( + + + {i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.createSearchableSnapshotLink', + { + defaultMessage: 'Create a snapshot repository', + } + )} + + ), + }} + /> + + ); + } else if (searchableSnapshotRepo && !repos.includes(searchableSnapshotRepo)) { + calloutContent = ( + + + {i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.createSnapshotRepositoryLink', + { + defaultMessage: 'create a new snapshot repository', + } + )} + + ), + }} + /> + + ); + } + } + + return ( +
+ + config={{ + defaultValue: cloud?.isCloudEnabled ? CLOUD_DEFAULT_REPO : undefined, + label: i18nTexts.editPolicy.searchableSnapshotsFieldLabel, + validations: [ + { + validator: emptyField( + i18nTexts.editPolicy.errors.searchableSnapshotRepoRequired + ), + }, + ], + }} + path={searchableSnapshotPath} + > + {(field) => { + const singleSelectionArray: [selectedSnapshot?: string] = field.value + ? [field.value] + : []; + + return ( + ({ label: repo, value: repo })), + singleSelection: { asPlainText: true }, + isLoading, + noSuggestions: !!(error || repos.length === 0), + onCreateOption: (newOption: string) => { + field.setValue(newOption); + }, + onChange: (options: EuiComboBoxOptionOption[]) => { + if (options.length > 0) { + field.setValue(options[0].label); + } else { + field.setValue(''); + } + }, + }} + /> + ); + }} + + {calloutContent && ( + <> + + {calloutContent} + + )} +
+ ); + }} +
+ ); + + const renderInfoCallout = (): JSX.Element | undefined => { + let infoCallout: JSX.Element | undefined; + + if (phase === 'hot' && isUsingSearchableSnapshotInHotPhase) { + infoCallout = ( + + ); + } else if (isDisabledDueToLicense) { + infoCallout = ( + + {i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotLicenseCalloutBody', + { + defaultMessage: 'To create a searchable snapshot an enterprise license is required.', + } + )} + + ); + } else if (isDisabledInColdDueToHotPhase) { + infoCallout = ( + + ); + } + + return infoCallout ? ( + <> + + {infoCallout} + + + ) : undefined; + }; + + return ( + + {i18n.translate('xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotFieldTitle', { + defaultMessage: 'Searchable snapshot', + })} + + } + description={ + <> + + , + }} + /> + + {renderInfoCallout()} + + } + fullWidth + > + {isDisabled ?
: renderField} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/set_priority_input.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/set_priority_input_field.tsx similarity index 93% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/set_priority_input.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/set_priority_input_field.tsx index 700a020577a43..e5ec1d116ec6f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/set_priority_input.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/set_priority_input_field.tsx @@ -12,13 +12,13 @@ import { Phases } from '../../../../../../../common/types'; import { UseField, NumericField } from '../../../../../../shared_imports'; -import { LearnMoreLink } from '../../'; +import { LearnMoreLink } from '../..'; interface Props { phase: keyof Phases & string; } -export const SetPriorityInput: FunctionComponent = ({ phase }) => { +export const SetPriorityInputField: FunctionComponent = ({ phase }) => { return ( { @@ -46,40 +42,28 @@ export const SnapshotPoliciesField: React.FunctionComponent = () => { let calloutContent; if (error) { calloutContent = ( - <> - - - - - - + + )} + title={ + + } + body={ - - + } + /> ); } else if (data.length === 0) { calloutContent = ( @@ -87,7 +71,6 @@ export const SnapshotPoliciesField: React.FunctionComponent = () => { { { const { policy } = useEditPolicyContext(); + const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); const [formData] = useFormData({ watch: [useRolloverPath, formFieldPaths.enabled, formFieldPaths.warmPhaseOnRollover], }); @@ -74,7 +81,7 @@ export const WarmPhase: FunctionComponent = () => { } titleSize="s" description={ - + <>

{ }, }} /> - + } fullWidth > - + <> {enabled && ( - + <> {hotPhaseRolloverEnabled && ( { )} - + )} - + {enabled && ( - - {/* Data tier allocation section */} - - + @@ -168,58 +178,64 @@ export const WarmPhase: FunctionComponent = () => { }} /> - - - - } - description={ - - {' '} - - - } - titleSize="xs" - switchProps={{ - 'aria-controls': 'shrinkContent', - 'data-test-subj': 'shrinkSwitch', - label: i18nTexts.shrinkLabel, - 'aria-label': i18nTexts.shrinkLabel, - initialValue: Boolean(policy.phases.warm?.actions?.shrink), - }} - fullWidth - > -

- - - - + - - - -
- - - + + } + description={ + + {' '} + + + } + titleSize="xs" + switchProps={{ + 'aria-controls': 'shrinkContent', + 'data-test-subj': 'shrinkSwitch', + label: i18nTexts.shrinkLabel, + 'aria-label': i18nTexts.shrinkLabel, + initialValue: Boolean(policy.phases.warm?.actions?.shrink), + }} + fullWidth + > +
+ + + + + + + +
+ + )} - -
+ {!isUsingSearchableSnapshotInHotPhase && } + {/* Data tier allocation section */} + + + )}
diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx index d188a172d746b..fb5e636902780 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx @@ -8,17 +8,24 @@ import React, { FunctionComponent, useState } from 'react'; import { EuiSpacer, EuiSwitch, EuiSwitchProps } from '@elastic/eui'; export interface Props extends Omit { - initialValue: boolean; + children: (() => JSX.Element) | JSX.Element | JSX.Element[] | undefined; + checked?: boolean; + initialValue?: boolean; onChange?: (nextValue: boolean) => void; } export const ToggleableField: FunctionComponent = ({ initialValue, + checked, onChange, children, ...restProps }) => { - const [isContentVisible, setIsContentVisible] = useState(initialValue); + const [uncontrolledIsContentVisible, setUncontrolledIsContentVisible] = useState( + initialValue ?? false + ); + + const isContentVisible = Boolean(checked ?? uncontrolledIsContentVisible); return ( <> @@ -27,14 +34,14 @@ export const ToggleableField: FunctionComponent = ({ checked={isContentVisible} onChange={(e) => { const nextValue = e.target.checked; - setIsContentVisible(nextValue); + setUncontrolledIsContentVisible(nextValue); if (onChange) { onChange(nextValue); } }} /> - {isContentVisible ? children : null} + {isContentVisible ? (typeof children === 'function' ? children() : children) : null} ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx index 4c0cc2c8957e1..b65e161685985 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx @@ -9,6 +9,7 @@ import { RouteComponentProps } from 'react-router-dom'; import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { MIN_SEARCHABLE_SNAPSHOT_LICENSE } from '../../../../common/constants'; import { useKibana, attemptToURIDecode } from '../../../shared_imports'; import { useLoadPoliciesList } from '../../services/api'; @@ -40,7 +41,7 @@ export const EditPolicy: React.FunctionComponent { const { - services: { breadcrumbService }, + services: { breadcrumbService, license }, } = useKibana(); const { error, isLoading, data: policies, resendRequest } = useLoadPoliciesList(false); @@ -100,6 +101,9 @@ export const EditPolicy: React.FunctionComponent license.hasAtLeast(MIN_SEARCHABLE_SNAPSHOT_LICENSE), + }, }} > diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 1e462dcb680f2..97e4c3ddf4a87 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -30,7 +30,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { useForm, Form, UseField, TextField, useFormData } from '../../../shared_imports'; +import { useForm, UseField, TextField, useFormData } from '../../../shared_imports'; import { toasts } from '../../services/notification'; @@ -45,7 +45,7 @@ import { WarmPhase, } from './components'; -import { schema, deserializer, createSerializer, createPolicyNameValidations } from './form'; +import { schema, deserializer, createSerializer, createPolicyNameValidations, Form } from './form'; import { useEditPolicyContext } from './edit_policy_context'; import { FormInternal } from './types'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx index da5f940b1b6c8..f7b9b1af1ee3a 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx @@ -14,6 +14,9 @@ export interface EditPolicyContextValue { policy: SerializedPolicy; existingPolicies: PolicyFromES[]; getUrlForApp: ApplicationStart['getUrlForApp']; + license: { + canUseSearchableSnapshot: () => boolean; + }; policyName?: string; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx new file mode 100644 index 0000000000000..2b3411e394a90 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; + +import { Form as LibForm, FormHook } from '../../../../../shared_imports'; + +import { ConfigurationIssuesProvider } from '../configuration_issues_context'; + +interface Props { + form: FormHook; +} + +export const Form: FunctionComponent = ({ form, children }) => ( + + {children} + +); diff --git a/x-pack/test/functional/services/data/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/index.ts similarity index 78% rename from x-pack/test/functional/services/data/index.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/index.ts index c2e3fcb41a7c9..15d8d4ed272e5 100644 --- a/x-pack/test/functional/services/data/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SendToBackgroundProvider } from './send_to_background'; +export { Form } from './form'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/configuration_issues_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/configuration_issues_context.tsx new file mode 100644 index 0000000000000..c31eb5bdaa329 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/configuration_issues_context.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import React, { FunctionComponent, createContext, useContext } from 'react'; +import { useFormData } from '../../../../shared_imports'; + +export interface ConfigurationIssues { + isUsingForceMergeInHotPhase: boolean; + /** + * If this value is true, phases after hot cannot set shrink, forcemerge, freeze, or + * searchable_snapshot actions. + * + * See https://github.com/elastic/elasticsearch/blob/master/docs/reference/ilm/actions/ilm-searchable-snapshot.asciidoc. + */ + isUsingSearchableSnapshotInHotPhase: boolean; +} + +const ConfigurationIssuesContext = createContext(null as any); + +const pathToHotPhaseSearchableSnapshot = + 'phases.hot.actions.searchable_snapshot.snapshot_repository'; + +const pathToHotForceMerge = 'phases.hot.actions.forcemerge.max_num_segments'; + +export const ConfigurationIssuesProvider: FunctionComponent = ({ children }) => { + const [formData] = useFormData({ + watch: [pathToHotPhaseSearchableSnapshot, pathToHotForceMerge], + }); + return ( + + {children} + + ); +}; + +export const useConfigurationIssues = () => { + const ctx = useContext(ConfigurationIssuesContext); + if (!ctx) + throw new Error('Cannot use configuration issues outside of configuration issues context'); + + return ctx; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index df5d6e2f80c15..04d4fbef9939e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -26,7 +26,7 @@ export const deserializer = (policy: SerializedPolicy): FormInternal => { }, warm: { enabled: Boolean(warm), - warmPhaseOnRollover: Boolean(warm?.min_age === '0ms'), + warmPhaseOnRollover: warm === undefined ? true : Boolean(warm.min_age === '0ms'), bestCompression: warm?.actions?.forcemerge?.index_codec === 'best_compression', dataTierAllocationType: determineDataTierAllocationType(warm?.actions), }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts index edff72dccc6dd..bafe6c15d9dca 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts @@ -76,6 +76,10 @@ const originalPolicy: SerializedPolicy = { set_priority: { priority: 12, }, + searchable_snapshot: { + snapshot_repository: 'my repo!', + force_merge_index: false, + }, }, }, delete: { @@ -209,6 +213,16 @@ describe('deserializer and serializer', () => { expect(result.phases.warm!.min_age).toBeUndefined(); }); + it('removes snapshot_repository when it is unset', () => { + delete formInternal.phases.hot!.actions.searchable_snapshot; + delete formInternal.phases.cold!.actions.searchable_snapshot; + + const result = serializer(formInternal); + + expect(result.phases.hot!.actions.searchable_snapshot).toBeUndefined(); + expect(result.phases.cold!.actions.searchable_snapshot).toBeUndefined(); + }); + it('correctly serializes a minimal policy', () => { policy = cloneDeep(originalMinimalPolicy); const formInternalPolicy = cloneDeep(originalMinimalPolicy); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts index 82fa478832582..66fe498cbac87 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts @@ -11,3 +11,10 @@ export { createSerializer } from './serializer'; export { schema } from './schema'; export * from './validations'; + +export { Form } from './components'; + +export { + ConfigurationIssuesProvider, + useConfigurationIssues, +} from './configuration_issues_context'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index 0ad2d923117f4..cedf1cdb4d9fe 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -287,6 +287,14 @@ export const schema: FormSchema = { serializer: serializers.stringToNumber, }, }, + searchable_snapshot: { + snapshot_repository: { + label: i18nTexts.editPolicy.searchableSnapshotsFieldLabel, + validations: [ + { validator: emptyField(i18nTexts.editPolicy.errors.searchableSnapshotRepoRequired) }, + ], + }, + }, }, }, delete: { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts index c543fef05733a..2071d1be523b6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts @@ -68,6 +68,10 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( if (!updatedPolicy.phases.hot!.actions?.set_priority) { delete hotPhaseActions.set_priority; } + + if (!updatedPolicy.phases.hot!.actions?.searchable_snapshot) { + delete hotPhaseActions.searchable_snapshot; + } } /** @@ -137,6 +141,10 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( if (!updatedPolicy.phases.cold?.actions?.set_priority) { delete coldPhase.actions.set_priority; } + + if (!updatedPolicy.phases.cold?.actions?.searchable_snapshot) { + delete coldPhase.actions.searchable_snapshot; + } } else { delete draft.phases.cold; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index ccd5d3a568fe3..f787f2661aa5c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -8,6 +8,23 @@ import { i18n } from '@kbn/i18n'; export const i18nTexts = { editPolicy: { + searchableSnapshotInHotPhase: { + searchableSnapshotDisallowed: { + calloutTitle: i18n.translate( + 'xpack.indexLifecycleMgmt.searchableSnapshot.disallowedCalloutTitle', + { + defaultMessage: 'Searchable snapshot disabled', + } + ), + calloutBody: i18n.translate( + 'xpack.indexLifecycleMgmt.searchableSnapshot.disallowedCalloutBody', + { + defaultMessage: + 'To use searchable snapshot in this phase you must disable searchable snapshot in the hot phase.', + } + ), + }, + }, forceMergeEnabledFieldLabel: i18n.translate('xpack.indexLifecycleMgmt.forcemerge.enableLabel', { defaultMessage: 'Force merge data', }), @@ -46,6 +63,12 @@ export const i18nTexts = { defaultMessage: 'Select a node attribute', } ), + searchableSnapshotsFieldLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotFieldLabel', + { + defaultMessage: 'Searchable snapshot repository', + } + ), errors: { numberRequired: i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.errors.numberRequiredErrorMessage', @@ -134,6 +157,12 @@ export const i18nTexts = { defaultMessage: 'A policy name cannot be longer than 255 bytes.', } ), + searchableSnapshotRepoRequired: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotRepoRequiredError', + { + defaultMessage: 'A snapshot repository name is required.', + } + ), }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts index f63c62e1fc529..8f1a4d733887f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts @@ -6,7 +6,12 @@ import { METRIC_TYPE } from '@kbn/analytics'; -import { PolicyFromES, SerializedPolicy, ListNodesRouteResponse } from '../../../common/types'; +import { + PolicyFromES, + SerializedPolicy, + ListNodesRouteResponse, + ListSnapshotReposResponse, +} from '../../../common/types'; import { UIM_POLICY_DELETE, @@ -112,3 +117,10 @@ export const useLoadSnapshotPolicies = () => { initialData: [], }); }; + +export const useLoadSnapshotRepositories = () => { + return useRequest({ + path: `snapshot_repositories`, + method: 'get', + }); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx index deef5cfe6ef2c..e0b4ac6d848b6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx @@ -3,9 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { first } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; -import { CoreSetup, PluginInitializerContext } from 'src/core/public'; +import { CoreSetup, PluginInitializerContext, Plugin } from 'src/core/public'; import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { PLUGIN } from '../common/constants'; import { init as initHttp } from './application/services/http'; @@ -14,15 +14,16 @@ import { init as initUiMetric } from './application/services/ui_metric'; import { init as initNotification } from './application/services/notification'; import { BreadcrumbService } from './application/services/breadcrumbs'; import { addAllExtensions } from './extend_index_management'; -import { ClientConfigType, SetupDependencies } from './types'; +import { ClientConfigType, SetupDependencies, StartDependencies } from './types'; import { registerUrlGenerator } from './url_generator'; -export class IndexLifecycleManagementPlugin { +export class IndexLifecycleManagementPlugin + implements Plugin { constructor(private readonly initializerContext: PluginInitializerContext) {} private breadcrumbService = new BreadcrumbService(); - public setup(coreSetup: CoreSetup, plugins: SetupDependencies) { + public setup(coreSetup: CoreSetup, plugins: SetupDependencies) { const { ui: { enabled: isIndexLifecycleManagementUiEnabled }, } = this.initializerContext.config.get(); @@ -47,7 +48,7 @@ export class IndexLifecycleManagementPlugin { title: PLUGIN.TITLE, order: 2, mount: async ({ element, history, setBreadcrumbs }) => { - const [coreStart] = await getStartServices(); + const [coreStart, { licensing }] = await getStartServices(); const { chrome: { docTitle }, i18n: { Context: I18nContext }, @@ -55,6 +56,8 @@ export class IndexLifecycleManagementPlugin { application: { navigateToApp, getUrlForApp }, } = coreStart; + const license = await licensing.license$.pipe(first()).toPromise(); + docTitle.change(PLUGIN.TITLE); this.breadcrumbService.setup(setBreadcrumbs); @@ -72,6 +75,7 @@ export class IndexLifecycleManagementPlugin { navigateToApp, getUrlForApp, this.breadcrumbService, + license, cloud ); diff --git a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts index a5844af0bf6dd..4cb5d95239408 100644 --- a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts @@ -11,6 +11,7 @@ export { useForm, useFormData, Form, + FormHook, UseField, FieldConfig, OnFormUpdateArg, diff --git a/x-pack/plugins/index_lifecycle_management/public/types.ts b/x-pack/plugins/index_lifecycle_management/public/types.ts index 1ce43957b1444..9107dcc9f2e9a 100644 --- a/x-pack/plugins/index_lifecycle_management/public/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/types.ts @@ -8,18 +8,23 @@ import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { IndexManagementPluginSetup } from '../../index_management/public'; -import { CloudSetup } from '../../cloud/public'; import { SharePluginSetup } from '../../../../src/plugins/share/public'; +import { CloudSetup } from '../../cloud/public'; +import { LicensingPluginStart, ILicense } from '../../licensing/public'; + import { BreadcrumbService } from './application/services/breadcrumbs'; export interface SetupDependencies { usageCollection?: UsageCollectionSetup; management: ManagementSetup; - cloud?: CloudSetup; indexManagement?: IndexManagementPluginSetup; - home?: HomePublicPluginSetup; share: SharePluginSetup; + cloud?: CloudSetup; + home?: HomePublicPluginSetup; +} +export interface StartDependencies { + licensing: LicensingPluginStart; } export interface ClientConfigType { @@ -30,5 +35,6 @@ export interface ClientConfigType { export interface AppServicesContext { breadcrumbService: BreadcrumbService; + license: ILicense; cloud?: CloudSetup; } diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_repositories/index.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_repositories/index.ts new file mode 100644 index 0000000000000..d61b30a4e0ebe --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_repositories/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerFetchRoute } from './register_fetch_route'; +import { RouteDependencies } from '../../../types'; + +export const registerSnapshotRepositoriesRoutes = (deps: RouteDependencies) => { + registerFetchRoute(deps); +}; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_repositories/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_repositories/register_fetch_route.ts new file mode 100644 index 0000000000000..f3097f1f39ec9 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_repositories/register_fetch_route.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +import { MIN_SEARCHABLE_SNAPSHOT_LICENSE } from '../../../../common/constants'; +import { ListSnapshotReposResponse } from '../../../../common/types'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; +import { handleEsError } from '../../../shared_imports'; + +export const registerFetchRoute = ({ router, license }: RouteDependencies) => { + router.get( + { path: addBasePath('/snapshot_repositories'), validate: false }, + async (ctx, request, response) => { + if (!license.isCurrentLicenseAtLeast(MIN_SEARCHABLE_SNAPSHOT_LICENSE)) { + return response.forbidden({ + body: i18n.translate('xpack.indexLifecycleMgmt.searchSnapshotlicenseCheckErrorMessage', { + defaultMessage: + 'Use of searchable snapshots requires at least an enterprise level license.', + }), + }); + } + + try { + const esResult = await ctx.core.elasticsearch.client.asCurrentUser.snapshot.getRepository({ + repository: '*', + }); + const repos: ListSnapshotReposResponse = { + repositories: Object.keys(esResult.body), + }; + return response.ok({ body: repos }); + } catch (e) { + // If ES responds with 404 when looking up all snapshots we return an empty array + if (e?.statusCode === 404) { + const repos: ListSnapshotReposResponse = { + repositories: [], + }; + return response.ok({ body: repos }); + } + return handleEsError({ error: e, response }); + } + } + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/index.ts b/x-pack/plugins/index_lifecycle_management/server/routes/index.ts index f7390debbe177..6c450ea0d3c71 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/index.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/index.ts @@ -11,6 +11,7 @@ import { registerNodesRoutes } from './api/nodes'; import { registerPoliciesRoutes } from './api/policies'; import { registerTemplatesRoutes } from './api/templates'; import { registerSnapshotPoliciesRoutes } from './api/snapshot_policies'; +import { registerSnapshotRepositoriesRoutes } from './api/snapshot_repositories'; export function registerApiRoutes(dependencies: RouteDependencies) { registerIndexRoutes(dependencies); @@ -18,4 +19,5 @@ export function registerApiRoutes(dependencies: RouteDependencies) { registerPoliciesRoutes(dependencies); registerTemplatesRoutes(dependencies); registerSnapshotPoliciesRoutes(dependencies); + registerSnapshotRepositoriesRoutes(dependencies); } diff --git a/x-pack/plugins/index_lifecycle_management/server/services/license.ts b/x-pack/plugins/index_lifecycle_management/server/services/license.ts index 2d863e283d440..e7e05f480a21f 100644 --- a/x-pack/plugins/index_lifecycle_management/server/services/license.ts +++ b/x-pack/plugins/index_lifecycle_management/server/services/license.ts @@ -12,7 +12,7 @@ import { } from 'kibana/server'; import { LicensingPluginSetup } from '../../../licensing/server'; -import { LicenseType } from '../../../licensing/common/types'; +import { LicenseType, ILicense } from '../shared_imports'; export interface LicenseStatus { isValid: boolean; @@ -26,6 +26,7 @@ interface SetupSettings { } export class License { + private currentLicense: ILicense | undefined; private licenseStatus: LicenseStatus = { isValid: false, message: 'Invalid License', @@ -36,6 +37,7 @@ export class License { { licensing, logger }: { licensing: LicensingPluginSetup; logger: Logger } ) { licensing.license$.subscribe((license) => { + this.currentLicense = license; const { state, message } = license.check(pluginId, minimumLicenseType); const hasRequiredLicense = state === 'valid'; @@ -76,6 +78,13 @@ export class License { }; } + isCurrentLicenseAtLeast(type: LicenseType): boolean { + if (!this.currentLicense) { + return false; + } + return this.currentLicense.hasAtLeast(type); + } + getStatus() { return this.licenseStatus; } diff --git a/x-pack/plugins/index_lifecycle_management/server/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/server/shared_imports.ts index 068cddcee4c86..18740d91a179c 100644 --- a/x-pack/plugins/index_lifecycle_management/server/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/server/shared_imports.ts @@ -5,3 +5,4 @@ */ export { handleEsError } from '../../../../src/plugins/es_ui_shared/server'; +export { ILicense, LicenseType } from '../../licensing/common/types'; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx index 4bee9d9a5209b..458fdbfcd4916 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx @@ -47,7 +47,7 @@ TimeRuler.displayName = 'TimeRuler'; const TimeRulerTickLabel = euiStyled.text` font-size: 9px; line-height: ${(props) => props.theme.eui.euiLineHeight}; - fill: ${(props) => props.theme.eui.textColors.subdued}; + fill: ${(props) => props.theme.eui.euiTextSubduedColor}; user-select: none; pointer-events: none; `; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx index be953ded70d79..b0e6ab751f02e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPortal, EuiTabs, EuiTab, EuiPanel, EuiTitle } from '@elastic/eui'; +import { EuiPortal, EuiTabs, EuiTab, EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { CSSProperties, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import { euiStyled } from '../../../../../../../observability/public'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib'; @@ -15,7 +15,7 @@ import { MetricsTab } from './tabs/metrics/metrics'; import { LogsTab } from './tabs/logs'; import { ProcessesTab } from './tabs/processes'; import { PropertiesTab } from './tabs/properties/index'; -import { OVERLAY_Y_START, OVERLAY_BOTTOM_MARGIN, OVERLAY_HEADER_SIZE } from './tabs/shared'; +import { OVERLAY_Y_START, OVERLAY_BOTTOM_MARGIN } from './tabs/shared'; import { useLinkProps } from '../../../../../hooks/use_link_props'; import { getNodeDetailUrl } from '../../../../link_to'; import { findInventoryModel } from '../../../../../../common/inventory_models'; @@ -70,21 +70,23 @@ export const NodeContextPopover = ({ return ( - + - + - +

{node.name}

- + - + -
- + + + {tabs.map((tab, i) => ( setSelectedTab(i)}> {tab.name} @@ -112,32 +115,38 @@ export const NodeContextPopover = ({
{tabs[selectedTab].content} -
+
); }; const OverlayHeader = euiStyled.div` - border-color: ${(props) => props.theme.eui.euiBorderColor}; - border-bottom-width: ${(props) => props.theme.eui.euiBorderWidthThick}; - padding-bottom: 0; - overflow: hidden; - background-color: ${(props) => props.theme.eui.euiColorLightestShade}; - height: ${OVERLAY_HEADER_SIZE}px; + padding-top: ${(props) => props.theme.eui.paddingSizes.m}; + padding-right: ${(props) => props.theme.eui.paddingSizes.m}; + padding-left: ${(props) => props.theme.eui.paddingSizes.m}; + background-color: ${(props) => props.theme.eui.euiPageBackgroundColor}; + box-shadow: inset 0 -1px ${(props) => props.theme.eui.euiBorderColor}; `; -const OverlayHeaderTitleWrapper = euiStyled(EuiFlexGroup).attrs({ alignItems: 'center' })` - padding: ${(props) => props.theme.eui.paddingSizes.s} ${(props) => - props.theme.eui.paddingSizes.m} 0; -`; +const OverlayPanel = euiStyled(EuiPanel).attrs({ paddingSize: 'none' })` + display: flex; + flex-direction: column; + position: absolute; + right: 16px; + top: ${OVERLAY_Y_START}px; + width: 100%; + max-width: 720px; + z-index: 2; + max-height: calc(100vh - ${OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN}px); + overflow: hidden; -const panelStyle: CSSProperties = { - position: 'absolute', - right: 10, - top: OVERLAY_Y_START, - width: '50%', - maxWidth: 730, - zIndex: 2, - height: `calc(100vh - ${OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN}px)`, - overflow: 'hidden', -}; + @media (max-width: 752px) { + border-radius: 0px !important; + left: 0px; + right: 0px; + top: 97px; + bottom: 0; + max-height: calc(100vh - 97px); + max-width: 100%; + } +`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx index ce800a7d73700..81ca7d1dcd27f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/logs.tsx @@ -15,7 +15,6 @@ import { TabContent, TabProps } from './shared'; import { LogStream } from '../../../../../../components/log_stream'; import { useWaffleOptionsContext } from '../../../hooks/use_waffle_options'; import { findInventoryFields } from '../../../../../../../common/inventory_models'; -import { euiStyled } from '../../../../../../../../observability/public'; import { useLinkProps } from '../../../../../../hooks/use_link_props'; import { getNodeLogsUrl } from '../../../../../link_to'; @@ -51,22 +50,25 @@ const TabComponent = (props: TabProps) => { return ( - + - - - + - + { ); }; -const QueryWrapper = euiStyled.div` - padding: ${(props) => props.theme.eui.paddingSizes.m}; - padding-right: 0; -`; - export const LogsTab = { id: 'logs', name: i18n.translate('xpack.infra.nodeDetails.tabs.logs', { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx index 63004072c08d0..ad4a48635d376 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx @@ -11,7 +11,6 @@ import { EuiFlexGroup } from '@elastic/eui'; import { EuiIcon } from '@elastic/eui'; import { colorTransformer } from '../../../../../../../../common/color_palette'; import { MetricsExplorerOptionsMetric } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; -import { euiStyled } from '../../../../../../../../../observability/public'; interface Props { title: string; @@ -20,33 +19,33 @@ interface Props { export const ChartHeader = ({ title, metrics }: Props) => { return ( - + - - {title} + +

{title}

- + {metrics.map((chartMetric) => ( - - - - - - {chartMetric.label} - - + + + + + + + {chartMetric.label} + + + ))} -
+
); }; - -const ChartHeaderWrapper = euiStyled.div` - display: flex; - width: 100%; - padding: ${(props) => props.theme.eui.paddingSizes.s} ${(props) => - props.theme.eui.paddingSizes.m}; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx index 789658c060403..a295d8293632f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { Axis, Chart, + ChartSizeArray, niceTimeFormatter, Position, Settings, @@ -17,7 +18,7 @@ import { PointerEvent, } from '@elastic/charts'; import moment from 'moment'; -import { EuiLoadingChart } from '@elastic/eui'; +import { EuiLoadingChart, EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; import { TabContent, TabProps } from '../shared'; import { useSnapshot } from '../../../../hooks/use_snaphot'; import { useWaffleOptionsContext } from '../../../../hooks/use_waffle_options'; @@ -39,7 +40,6 @@ import { createInventoryMetricFormatter } from '../../../../lib/create_inventory import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain'; import { getTimelineChartTheme } from '../../../../../metrics_explorer/components/helpers/get_chart_theme'; import { useUiSetting } from '../../../../../../../../../../../src/plugins/kibana_react/public'; -import { euiStyled } from '../../../../../../../../../observability/public'; import { ChartHeader } from './chart_header'; import { SYSTEM_METRIC_NAME, @@ -56,6 +56,8 @@ import { import { TimeDropdown } from './time_dropdown'; const ONE_HOUR = 60 * 60 * 1000; +const CHART_SIZE: ChartSizeArray = ['100%', 160]; + const TabComponent = (props: TabProps) => { const cpuChartRef = useRef(null); const networkChartRef = useRef(null); @@ -282,217 +284,184 @@ const TabComponent = (props: TabProps) => { return ( - - - - - + + + + + + - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + ); }; -const ChartsContainer = euiStyled.div` - display: flex; - flex-direction: row; - flex-wrap: wrap; -`; - -const ChartContainerWrapper = euiStyled.div` - width: 50% -`; - -const TimepickerWrapper = euiStyled.div` - padding: ${(props) => props.theme.eui.paddingSizes.m}; - width: 50%; -`; - -const ChartContainer: React.FC = ({ children }) => ( -
- {children} -
-); - const LoadingPlaceholder = () => { return (
( { }; const TableWrapper = euiStyled.div` - margin-bottom: 20px + &:not(:last-child) { + margin-bottom: 16px + } `; const LoadingPlaceholder = () => { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx index c3e47b6084eb2..7f0ca2b6e262a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText } from '@elastic/eui'; -import { EuiToolTip } from '@elastic/eui'; -import { EuiButtonIcon } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiLink } from '@elastic/eui'; -import { EuiBasicTable } from '@elastic/eui'; +import { + EuiText, + EuiToolTip, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiBasicTable, + EuiSpacer, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { first } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { euiStyled } from '../../../../../../../../../observability/public'; interface Row { name: string; @@ -51,15 +53,19 @@ export const Table = (props: Props) => { render: (_name: string, item: Row) => { return ( - - - + + + { )} onClick={() => onClick(item)} /> - - - {!Array.isArray(item.value) && item.value} - {Array.isArray(item.value) && } - - - + + + + {!Array.isArray(item.value) && item.value} + {Array.isArray(item.value) && } + + ); }, @@ -86,20 +92,21 @@ export const Table = (props: Props) => { return ( <> - - -

{title}

-
-
- + +

{title}

+
+ + ); }; -const TitleWrapper = euiStyled.div` - margin-bottom: 10px -`; - class TableWithoutHeader extends EuiBasicTable { renderTableHead() { return <>; @@ -123,7 +130,7 @@ const ArrayValue = (props: MoreProps) => { return ( <> {!isExpanded && ( - + {first(values)} {' ... '} @@ -148,7 +155,7 @@ const ArrayValue = (props: MoreProps) => { ))} {i18n.translate('xpack.infra.nodeDetails.tabs.metadata.seeLess', { - defaultMessage: 'See less', + defaultMessage: 'Show less', })}
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx index 7386fa64aca9c..6ff31e86c9d5e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx @@ -17,11 +17,9 @@ export interface TabProps { export const OVERLAY_Y_START = 266; export const OVERLAY_BOTTOM_MARGIN = 16; -export const OVERLAY_HEADER_SIZE = 96; -const contentHeightOffset = OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN + OVERLAY_HEADER_SIZE; export const TabContent = euiStyled.div` - padding: ${(props) => props.theme.eui.paddingSizes.s}; - height: calc(100vh - ${contentHeightOffset}px); + padding: ${(props) => props.theme.eui.paddingSizes.m}; + flex: 1; overflow-y: auto; overflow-x: hidden; `; 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 53d94f24d616c..7402a712793fa 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 @@ -1143,7 +1143,7 @@ describe('editor_frame', () => { .find(EuiPanel) .map((el) => el.parents(EuiToolTip).prop('content')) ).toEqual([ - 'Current', + 'Current visualization', 'Suggestion1', 'Suggestion2', 'Suggestion3', diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss index 007d833e97e9d..b3e6f68b0a68c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss @@ -16,6 +16,7 @@ // Padding / negative margins to make room for overflow shadow padding-left: $euiSizeXS; margin-left: -$euiSizeXS; + padding-bottom: $euiSizeXS; } .lnsSuggestionPanel__button { @@ -27,13 +28,31 @@ margin-left: $euiSizeXS / 2; margin-bottom: $euiSizeXS / 2; + &:focus { + @include euiFocusRing; + transform: none !important; // sass-lint:disable-line no-important + } + .lnsSuggestionPanel__expressionRenderer { position: static; // Let the progress indicator position itself against the button } } .lnsSuggestionPanel__button-isSelected { - @include euiFocusRing; + background-color: $euiColorLightestShade !important; // sass-lint:disable-line no-important + border-color: $euiColorMediumShade; + + &:not(:focus) { + box-shadow: none !important; // sass-lint:disable-line no-important + } + + &:focus { + @include euiFocusRing; + } + + &:hover { + transform: none !important; // sass-lint:disable-line no-important + } } .lnsSuggestionPanel__suggestionIcon { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index 382178a14793b..9a1d7b23fa3dd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -98,7 +98,7 @@ describe('suggestion_panel', () => { .find('[data-test-subj="lnsSuggestion"]') .find(EuiPanel) .map((el) => el.parents(EuiToolTip).prop('content')) - ).toEqual(['Current', 'Suggestion1', 'Suggestion2']); + ).toEqual(['Current visualization', 'Suggestion1', 'Suggestion2']); }); describe('uncommitted suggestions', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 913b396622518..e42d4daffbb66 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -136,6 +136,8 @@ const SuggestionPreview = ({ paddingSize="none" data-test-subj="lnsSuggestion" onClick={onSelect} + aria-current={!!selected} + aria-label={preview.title} > {preview.expression || preview.error ? ( { const example = { type: 'lens', + id: 'mock-saved-object-id', attributes: { expression: 'kibana\n| kibana_context query="{\\"language\\":\\"kuery\\",\\"query\\":\\"\\"}" \n| lens_merge_tables layerIds="c61a8afb-a185-4fae-a064-fb3846f6c451" \n tables={esaggs index="logstash-*" metricsAtAllLevels=false partialRows=false includeFormatHints=true aggConfigs="[{\\"id\\":\\"2cd09808-3915-49f4-b3b0-82767eba23f7\\",\\"enabled\\":true,\\"type\\":\\"max\\",\\"schema\\":\\"metric\\",\\"params\\":{\\"field\\":\\"bytes\\"}}]" | lens_rename_columns idMap="{\\"col-0-2cd09808-3915-49f4-b3b0-82767eba23f7\\":\\"2cd09808-3915-49f4-b3b0-82767eba23f7\\"}"}\n| lens_metric_chart title="Maximum of bytes" accessor="2cd09808-3915-49f4-b3b0-82767eba23f7"', @@ -164,6 +165,7 @@ describe('Lens migrations', () => { const example = { type: 'lens', + id: 'mock-saved-object-id', attributes: { expression: `kibana | kibana_context query="{\\"query\\":\\"\\",\\"language\\":\\"kuery\\"}" filters="[]" @@ -265,6 +267,7 @@ describe('Lens migrations', () => { it('should handle pre-migrated expression', () => { const input = { type: 'lens', + id: 'mock-saved-object-id', attributes: { ...example.attributes, expression: `kibana @@ -283,6 +286,7 @@ describe('Lens migrations', () => { const context = {} as SavedObjectMigrationContext; const example = { + id: 'mock-saved-object-id', attributes: { description: '', expression: @@ -513,6 +517,7 @@ describe('Lens migrations', () => { const example = { type: 'lens', + id: 'mock-saved-object-id', attributes: { state: { datasourceStates: { diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index cc41a3a4b4e22..32b0d2eaee632 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -1373,12 +1373,16 @@ exports[`UploadLicense should display an error when ES says license is expired 1 token="euiForm.addressFormErrors" >
, + getState: () => MapStoreState + ) => { + const layer = getLayerById(layerId, getState()); + const previousFields = await (layer as IVectorLayer).getFields(); + await dispatch({ + type: UPDATE_SOURCE_PROP, + layerId, + propName: 'metrics', + value, + }); + await dispatch(updateStyleProperties(layerId, previousFields as IESAggField[])); + dispatch(syncDataForLayerId(layerId)); + }; +} + export function updateSourceProp( layerId: string, propName: string, @@ -281,6 +301,12 @@ export function updateSourceProp( newLayerType?: LAYER_TYPE ) { return async (dispatch: ThunkDispatch) => { + if (propName === 'metrics') { + if (newLayerType) { + throw new Error('May not change layer-type when modifying metrics source-property'); + } + return await dispatch(updateMetricsProp(layerId, value)); + } dispatch({ type: UPDATE_SOURCE_PROP, layerId, @@ -290,7 +316,6 @@ export function updateSourceProp( if (newLayerType) { dispatch(updateLayerType(layerId, newLayerType)); } - await dispatch(clearMissingStyleProperties(layerId)); dispatch(syncDataForLayerId(layerId)); }; } @@ -422,7 +447,7 @@ function removeLayerFromLayerList(layerId: string) { }; } -export function clearMissingStyleProperties(layerId: string) { +function updateStyleProperties(layerId: string, previousFields: IField[]) { return async ( dispatch: ThunkDispatch, getState: () => MapStoreState @@ -441,8 +466,9 @@ export function clearMissingStyleProperties(layerId: string) { const { hasChanges, nextStyleDescriptor, - } = await (style as IVectorStyle).getDescriptorWithMissingStylePropsRemoved( + } = await (style as IVectorStyle).getDescriptorWithUpdatedStyleProps( nextFields, + previousFields, getMapColors(getState()) ); if (hasChanges && nextStyleDescriptor) { @@ -485,13 +511,13 @@ export function updateLayerStyleForSelectedLayer(styleDescriptor: StyleDescripto export function setJoinsForLayer(layer: ILayer, joins: JoinDescriptor[]) { return async (dispatch: ThunkDispatch) => { + const previousFields = await (layer as IVectorLayer).getFields(); await dispatch({ type: SET_JOINS, layer, joins, }); - - await dispatch(clearMissingStyleProperties(layer.getId())); + await dispatch(updateStyleProperties(layer.getId(), previousFields)); dispatch(syncDataForLayerId(layer.getId())); }; } diff --git a/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts b/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts index a4562c91e92a6..ff6dbbce6f095 100644 --- a/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts +++ b/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts @@ -97,4 +97,8 @@ export class CountAggField implements IESAggField { canReadFromGeoJson(): boolean { return this._canReadFromGeoJson; } + + isEqual(field: IESAggField) { + return field.getName() === this.getName(); + } } diff --git a/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts b/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts index e3d62afaca921..cc8e3b4675308 100644 --- a/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts +++ b/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts @@ -83,4 +83,8 @@ export class TopTermPercentageField implements IESAggField { canReadFromGeoJson(): boolean { return this._canReadFromGeoJson; } + + isEqual(field: IESAggField) { + return field.getName() === this.getName(); + } } diff --git a/x-pack/plugins/maps/public/classes/fields/field.ts b/x-pack/plugins/maps/public/classes/fields/field.ts index 658c2bba87847..9cb7debd320a1 100644 --- a/x-pack/plugins/maps/public/classes/fields/field.ts +++ b/x-pack/plugins/maps/public/classes/fields/field.ts @@ -32,6 +32,7 @@ export interface IField { supportsFieldMeta(): boolean; canReadFromGeoJson(): boolean; + isEqual(field: IField): boolean; } export class AbstractField implements IField { @@ -99,4 +100,8 @@ export class AbstractField implements IField { canReadFromGeoJson(): boolean { return true; } + + isEqual(field: IField) { + return this._origin === field.getOrigin() && this._fieldName === field.getName(); + } } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap index 8fa69e1a5b467..c0505426d1f63 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap @@ -44,6 +44,7 @@ exports[`Should render icon select 1`] = ` clickOutsideDisables={true} > { + describe('isFieldDataTypeCompatibleWithStyleType', () => { + async function createHelper( + supportsAutoDomain: boolean + ): Promise<{ + styleFieldHelper: StyleFieldsHelper; + stringField: IField; + numberField: IField; + dateField: IField; + }> { + const stringField = new MockField({ + dataType: 'string', + supportsAutoDomain, + }); + const numberField = new MockField({ + dataType: 'number', + supportsAutoDomain, + }); + const dateField = new MockField({ + dataType: 'date', + supportsAutoDomain, + }); + return { + styleFieldHelper: await createStyleFieldsHelper([stringField, numberField, dateField]), + stringField, + numberField, + dateField, + }; + } + + test('Should validate colors for all data types', async () => { + const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(true); + + [ + VECTOR_STYLES.FILL_COLOR, + VECTOR_STYLES.LINE_COLOR, + VECTOR_STYLES.LABEL_COLOR, + VECTOR_STYLES.LABEL_BORDER_COLOR, + ].forEach((styleType) => { + expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(true); + expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(true); + expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(true); + }); + }); + + test('Should validate sizes for all number types', async () => { + const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(true); + + [VECTOR_STYLES.LINE_WIDTH, VECTOR_STYLES.LABEL_SIZE, VECTOR_STYLES.ICON_SIZE].forEach( + (styleType) => { + expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(false); + expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(true); + expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(true); + } + ); + }); + + test('Should not validate sizes if autodomain is not enabled', async () => { + const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(false); + + [VECTOR_STYLES.LINE_WIDTH, VECTOR_STYLES.LABEL_SIZE, VECTOR_STYLES.ICON_SIZE].forEach( + (styleType) => { + expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(false); + expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(false); + expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(false); + } + ); + }); + + test('Should validate orientation only number types', async () => { + const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(true); + + [VECTOR_STYLES.ICON_ORIENTATION].forEach((styleType) => { + expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(false); + expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(true); + expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(false); + }); + }); + + test('Should not validate label_border_size', async () => { + const { styleFieldHelper, stringField, numberField, dateField } = await createHelper(true); + + [VECTOR_STYLES.LABEL_BORDER_SIZE].forEach((styleType) => { + expect(styleFieldHelper.hasFieldForStyle(stringField, styleType)).toEqual(false); + expect(styleFieldHelper.hasFieldForStyle(numberField, styleType)).toEqual(false); + expect(styleFieldHelper.hasFieldForStyle(dateField, styleType)).toEqual(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.ts index fbe643a401484..d36cf575a9bd8 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.ts @@ -69,6 +69,11 @@ export class StyleFieldsHelper { this._ordinalFields = ordinalFields; } + hasFieldForStyle(field: IField, styleName: VECTOR_STYLES): boolean { + const fieldList = this.getFieldsForStyle(styleName); + return fieldList.some((styleField) => field.getName() === styleField.name); + } + getFieldsForStyle(styleName: VECTOR_STYLES): StyleField[] { switch (styleName) { case VECTOR_STYLES.ICON_ORIENTATION: diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js index 1dbadc054c8a0..94090c8abfe4f 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js @@ -31,8 +31,8 @@ class MockSource { } } -describe('getDescriptorWithMissingStylePropsRemoved', () => { - const fieldName = 'doIStillExist'; +describe('getDescriptorWithUpdatedStyleProps', () => { + const previousFieldName = 'doIStillExist'; const mapColors = []; const properties = { fillColor: { @@ -43,7 +43,7 @@ describe('getDescriptorWithMissingStylePropsRemoved', () => { type: STYLE_TYPE.DYNAMIC, options: { field: { - name: fieldName, + name: previousFieldName, origin: FIELD_ORIGIN.SOURCE, }, }, @@ -53,89 +53,123 @@ describe('getDescriptorWithMissingStylePropsRemoved', () => { options: { minSize: 1, maxSize: 10, - field: { name: fieldName, origin: FIELD_ORIGIN.SOURCE }, + field: { name: previousFieldName, origin: FIELD_ORIGIN.SOURCE }, }, }, }; + const previousFields = [new MockField({ fieldName: previousFieldName })]; + beforeEach(() => { require('../../../kibana_services').getUiSettings = () => ({ get: jest.fn(), }); }); - it('Should return no changes when next ordinal fields contain existing style property fields', async () => { - const vectorStyle = new VectorStyle({ properties }, new MockSource()); + describe('When there is no mismatch in configuration', () => { + it('Should return no changes when next ordinal fields contain existing style property fields', async () => { + const vectorStyle = new VectorStyle({ properties }, new MockSource()); - const nextFields = [new MockField({ fieldName, dataType: 'number' })]; - const { hasChanges } = await vectorStyle.getDescriptorWithMissingStylePropsRemoved( - nextFields, - mapColors - ); - expect(hasChanges).toBe(false); + const nextFields = [new MockField({ fieldName: previousFieldName, dataType: 'number' })]; + const { hasChanges } = await vectorStyle.getDescriptorWithUpdatedStyleProps( + nextFields, + previousFields, + mapColors + ); + expect(hasChanges).toBe(false); + }); }); - it('Should clear missing fields when next ordinal fields do not contain existing style property fields', async () => { - const vectorStyle = new VectorStyle({ properties }, new MockSource()); + describe('When styles should revert to static styling', () => { + it('Should convert dynamic styles to static styles when there are no next fields', async () => { + const vectorStyle = new VectorStyle({ properties }, new MockSource()); - const nextFields = [new MockField({ fieldName: 'someOtherField', dataType: 'number' })]; - const { - hasChanges, - nextStyleDescriptor, - } = await vectorStyle.getDescriptorWithMissingStylePropsRemoved(nextFields, mapColors); - expect(hasChanges).toBe(true); - expect(nextStyleDescriptor.properties[VECTOR_STYLES.LINE_COLOR]).toEqual({ - options: {}, - type: 'DYNAMIC', - }); - expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({ - options: { - minSize: 1, - maxSize: 10, - }, - type: 'DYNAMIC', + const nextFields = []; + const { + hasChanges, + nextStyleDescriptor, + } = await vectorStyle.getDescriptorWithUpdatedStyleProps( + nextFields, + previousFields, + mapColors + ); + expect(hasChanges).toBe(true); + expect(nextStyleDescriptor.properties[VECTOR_STYLES.LINE_COLOR]).toEqual({ + options: { + color: '#41937c', + }, + type: 'STATIC', + }); + expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({ + options: { + size: 6, + }, + type: 'STATIC', + }); }); - }); - it('Should convert dynamic styles to static styles when there are no next fields', async () => { - const vectorStyle = new VectorStyle({ properties }, new MockSource()); + it('Should convert dynamic ICON_SIZE static style when there are no next ordinal fields', async () => { + const vectorStyle = new VectorStyle({ properties }, new MockSource()); - const nextFields = []; - const { - hasChanges, - nextStyleDescriptor, - } = await vectorStyle.getDescriptorWithMissingStylePropsRemoved(nextFields, mapColors); - expect(hasChanges).toBe(true); - expect(nextStyleDescriptor.properties[VECTOR_STYLES.LINE_COLOR]).toEqual({ - options: { - color: '#41937c', - }, - type: 'STATIC', - }); - expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({ - options: { - size: 6, - }, - type: 'STATIC', + const nextFields = [ + new MockField({ + fieldName: previousFieldName, + dataType: 'number', + supportsAutoDomain: false, + }), + ]; + const { + hasChanges, + nextStyleDescriptor, + } = await vectorStyle.getDescriptorWithUpdatedStyleProps( + nextFields, + previousFields, + mapColors + ); + expect(hasChanges).toBe(true); + expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({ + options: { + size: 6, + }, + type: 'STATIC', + }); }); }); - it('Should convert dynamic ICON_SIZE static style when there are no next ordinal fields', async () => { - const vectorStyle = new VectorStyle({ properties }, new MockSource()); + describe('When styles should not be cleared', () => { + it('Should update field in styles when the fields and style combination remains compatible', async () => { + const vectorStyle = new VectorStyle({ properties }, new MockSource()); - const nextFields = [ - new MockField({ fieldName, dataType: 'number', supportsAutoDomain: false }), - ]; - const { - hasChanges, - nextStyleDescriptor, - } = await vectorStyle.getDescriptorWithMissingStylePropsRemoved(nextFields, mapColors); - expect(hasChanges).toBe(true); - expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({ - options: { - size: 6, - }, - type: 'STATIC', + const nextFields = [new MockField({ fieldName: 'someOtherField', dataType: 'number' })]; + const { + hasChanges, + nextStyleDescriptor, + } = await vectorStyle.getDescriptorWithUpdatedStyleProps( + nextFields, + previousFields, + mapColors + ); + expect(hasChanges).toBe(true); + expect(nextStyleDescriptor.properties[VECTOR_STYLES.LINE_COLOR]).toEqual({ + options: { + field: { + name: 'someOtherField', + origin: FIELD_ORIGIN.SOURCE, + }, + }, + type: 'DYNAMIC', + }); + expect(nextStyleDescriptor.properties[VECTOR_STYLES.ICON_SIZE]).toEqual({ + options: { + minSize: 1, + maxSize: 10, + field: { + name: 'someOtherField', + origin: FIELD_ORIGIN.SOURCE, + }, + }, + type: 'DYNAMIC', + }); }); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index 2dc9ef612d8b2..1c36961aae1b1 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -6,17 +6,17 @@ import _ from 'lodash'; import React, { ReactElement } from 'react'; -import { Map as MbMap, FeatureIdentifier } from 'mapbox-gl'; +import { FeatureIdentifier, Map as MbMap } from 'mapbox-gl'; import { FeatureCollection } from 'geojson'; import { StyleProperties, VectorStyleEditor } from './components/vector_style_editor'; import { getDefaultStaticProperties, LINE_STYLES, POLYGON_STYLES } from './vector_style_defaults'; import { - GEO_JSON_TYPE, + DEFAULT_ICON, FIELD_ORIGIN, - STYLE_TYPE, - SOURCE_FORMATTERS_DATA_REQUEST_ID, + GEO_JSON_TYPE, LAYER_STYLE_TYPE, - DEFAULT_ICON, + SOURCE_FORMATTERS_DATA_REQUEST_ID, + STYLE_TYPE, VECTOR_SHAPE_TYPE, VECTOR_STYLES, } from '../../../../common/constants'; @@ -25,7 +25,7 @@ import { VectorIcon } from './components/legend/vector_icon'; import { VectorStyleLegend } from './components/legend/vector_style_legend'; import { isOnlySingleFeatureType } from './style_util'; import { StaticStyleProperty } from './properties/static_style_property'; -import { DynamicStyleProperty } from './properties/dynamic_style_property'; +import { DynamicStyleProperty, IDynamicStyleProperty } from './properties/dynamic_style_property'; import { DynamicSizeProperty } from './properties/dynamic_size_property'; import { StaticSizeProperty } from './properties/static_size_property'; import { StaticColorProperty } from './properties/static_color_property'; @@ -43,6 +43,7 @@ import { ColorDynamicOptions, ColorStaticOptions, ColorStylePropertyDescriptor, + DynamicStyleProperties, DynamicStylePropertyOptions, IconDynamicOptions, IconStaticOptions, @@ -66,11 +67,11 @@ import { import { DataRequest } from '../../util/data_request'; import { IStyle } from '../style'; import { IStyleProperty } from './properties/style_property'; -import { IDynamicStyleProperty } from './properties/dynamic_style_property'; import { IField } from '../../fields/field'; import { IVectorLayer } from '../../layers/vector_layer/vector_layer'; import { IVectorSource } from '../../sources/vector_source'; -import { createStyleFieldsHelper } from './style_fields_helper'; +import { createStyleFieldsHelper, StyleFieldsHelper } from './style_fields_helper'; +import { IESAggField } from '../../fields/agg'; const POINTS = [GEO_JSON_TYPE.POINT, GEO_JSON_TYPE.MULTI_POINT]; const LINES = [GEO_JSON_TYPE.LINE_STRING, GEO_JSON_TYPE.MULTI_LINE_STRING]; @@ -81,8 +82,9 @@ export interface IVectorStyle extends IStyle { getDynamicPropertiesArray(): Array>; getSourceFieldNames(): string[]; getStyleMeta(): StyleMeta; - getDescriptorWithMissingStylePropsRemoved( + getDescriptorWithUpdatedStyleProps( nextFields: IField[], + previousFields: IField[], mapColors: string[] ): Promise<{ hasChanges: boolean; nextStyleDescriptor?: VectorStyleDescriptor }>; pluckStyleMetaFromSourceDataRequest(sourceDataRequest: DataRequest): Promise; @@ -239,11 +241,187 @@ export class VectorStyle implements IVectorStyle { ); } + async _updateFieldsInDescriptor( + nextFields: IField[], + styleFieldsHelper: StyleFieldsHelper, + previousFields: IField[], + mapColors: string[] + ) { + const originalProperties = this.getRawProperties(); + const invalidStyleNames: VECTOR_STYLES[] = (Object.keys( + originalProperties + ) as VECTOR_STYLES[]).filter((key) => { + const dynamicOptions = getDynamicOptions(originalProperties, key); + if (!dynamicOptions || !dynamicOptions.field || !dynamicOptions.field.name) { + return false; + } + + const hasMatchingField = nextFields.some((field) => { + return ( + dynamicOptions && dynamicOptions.field && dynamicOptions.field.name === field.getName() + ); + }); + return !hasMatchingField; + }); + + let hasChanges = false; + + const updatedProperties: VectorStylePropertiesDescriptor = { ...originalProperties }; + invalidStyleNames.forEach((invalidStyleName) => { + for (let i = 0; i < previousFields.length; i++) { + const previousField = previousFields[i]; + const nextField = nextFields[i]; + if (previousField.isEqual(nextField)) { + continue; + } + const isFieldDataTypeCompatible = styleFieldsHelper.hasFieldForStyle( + nextField, + invalidStyleName + ); + if (!isFieldDataTypeCompatible) { + return; + } + hasChanges = true; + (updatedProperties[invalidStyleName] as DynamicStyleProperties) = { + type: STYLE_TYPE.DYNAMIC, + options: { + ...originalProperties[invalidStyleName].options, + field: rectifyFieldDescriptor(nextField as IESAggField, { + origin: previousField.getOrigin(), + name: previousField.getName(), + }), + } as DynamicStylePropertyOptions, + }; + } + }); + + return this._deleteFieldsFromDescriptorAndUpdateStyling( + nextFields, + updatedProperties, + hasChanges, + styleFieldsHelper, + mapColors + ); + } + + async _deleteFieldsFromDescriptorAndUpdateStyling( + nextFields: IField[], + originalProperties: VectorStylePropertiesDescriptor, + hasChanges: boolean, + styleFieldsHelper: StyleFieldsHelper, + mapColors: string[] + ) { + // const originalProperties = this.getRawProperties(); + const updatedProperties = {} as VectorStylePropertiesDescriptor; + + const dynamicProperties = (Object.keys(originalProperties) as VECTOR_STYLES[]).filter((key) => { + const dynamicOptions = getDynamicOptions(originalProperties, key); + return dynamicOptions && dynamicOptions.field && dynamicOptions.field.name; + }); + + dynamicProperties.forEach((key: VECTOR_STYLES) => { + // Convert dynamic styling to static stying when there are no style fields + const styleFields = styleFieldsHelper.getFieldsForStyle(key); + if (styleFields.length === 0) { + const staticProperties = getDefaultStaticProperties(mapColors); + updatedProperties[key] = staticProperties[key] as any; + return; + } + + const dynamicProperty = originalProperties[key]; + if (!dynamicProperty || !dynamicProperty.options) { + return; + } + const fieldName = (dynamicProperty.options as DynamicStylePropertyOptions).field!.name; + if (!fieldName) { + return; + } + + const matchingOrdinalField = nextFields.find((ordinalField) => { + return fieldName === ordinalField.getName(); + }); + + if (matchingOrdinalField) { + return; + } + + updatedProperties[key] = { + type: DynamicStyleProperty.type, + options: { + ...originalProperties[key]!.options, + }, + } as any; + + if ('field' in updatedProperties[key].options) { + delete (updatedProperties[key].options as DynamicStylePropertyOptions).field; + } + }); + + if (Object.keys(updatedProperties).length !== 0) { + return { + hasChanges: true, + nextStyleDescriptor: VectorStyle.createDescriptor( + { + ...originalProperties, + ...updatedProperties, + }, + this.isTimeAware() + ), + }; + } else { + return { + hasChanges, + nextStyleDescriptor: VectorStyle.createDescriptor( + { + ...originalProperties, + }, + this.isTimeAware() + ), + }; + } + } + + /* + * Changes to source descriptor and join descriptor will impact style properties. + * For instance, a style property may be dynamically tied to the value of an ordinal field defined + * by a join or a metric aggregation. The metric aggregation or join may be edited or removed. + * When this happens, the style will be linked to a no-longer-existing ordinal field. + * This method provides a way for a style to clean itself and return a descriptor that unsets any dynamic + * properties that are tied to missing oridinal fields + * + * This method does not update its descriptor. It just returns a new descriptor that the caller + * can then use to update store state via dispatch. + */ + async getDescriptorWithUpdatedStyleProps( + nextFields: IField[], + previousFields: IField[], + mapColors: string[] + ) { + const styleFieldsHelper = await createStyleFieldsHelper(nextFields); + + return previousFields.length === nextFields.length + ? // Field-config changed + await this._updateFieldsInDescriptor( + nextFields, + styleFieldsHelper, + previousFields, + mapColors + ) + : // Deletions or additions + await this._deleteFieldsFromDescriptorAndUpdateStyling( + nextFields, + this.getRawProperties(), + false, + styleFieldsHelper, + mapColors + ); + } + getType() { return LAYER_STYLE_TYPE.VECTOR; } - getAllStyleProperties() { + getAllStyleProperties(): Array> { return [ this._symbolizeAsStyleProperty, this._iconStyleProperty, @@ -303,94 +481,6 @@ export class VectorStyle implements IVectorStyle { ); } - /* - * Changes to source descriptor and join descriptor will impact style properties. - * For instance, a style property may be dynamically tied to the value of an ordinal field defined - * by a join or a metric aggregation. The metric aggregation or join may be edited or removed. - * When this happens, the style will be linked to a no-longer-existing ordinal field. - * This method provides a way for a style to clean itself and return a descriptor that unsets any dynamic - * properties that are tied to missing oridinal fields - * - * This method does not update its descriptor. It just returns a new descriptor that the caller - * can then use to update store state via dispatch. - */ - async getDescriptorWithMissingStylePropsRemoved(nextFields: IField[], mapColors: string[]) { - const styleFieldsHelper = await createStyleFieldsHelper(nextFields); - const originalProperties = this.getRawProperties(); - const updatedProperties = {} as VectorStylePropertiesDescriptor; - - const dynamicProperties = (Object.keys(originalProperties) as VECTOR_STYLES[]).filter((key) => { - if (!originalProperties[key]) { - return false; - } - const propertyDescriptor = originalProperties[key]; - if ( - !propertyDescriptor || - !('type' in propertyDescriptor) || - propertyDescriptor.type !== STYLE_TYPE.DYNAMIC || - !propertyDescriptor.options - ) { - return false; - } - const dynamicOptions = propertyDescriptor.options as DynamicStylePropertyOptions; - return dynamicOptions.field && dynamicOptions.field.name; - }); - - dynamicProperties.forEach((key: VECTOR_STYLES) => { - // Convert dynamic styling to static stying when there are no style fields - const styleFields = styleFieldsHelper.getFieldsForStyle(key); - if (styleFields.length === 0) { - const staticProperties = getDefaultStaticProperties(mapColors); - updatedProperties[key] = staticProperties[key] as any; - return; - } - - const dynamicProperty = originalProperties[key]; - if (!dynamicProperty || !dynamicProperty.options) { - return; - } - const fieldName = (dynamicProperty.options as DynamicStylePropertyOptions).field!.name; - if (!fieldName) { - return; - } - - const matchingOrdinalField = nextFields.find((ordinalField) => { - return fieldName === ordinalField.getName(); - }); - - if (matchingOrdinalField) { - return; - } - - updatedProperties[key] = { - type: DynamicStyleProperty.type, - options: { - ...originalProperties[key].options, - }, - } as any; - // @ts-expect-error - delete updatedProperties[key].options.field; - }); - - if (Object.keys(updatedProperties).length === 0) { - return { - hasChanges: false, - nextStyleDescriptor: { ...this._descriptor }, - }; - } - - return { - hasChanges: true, - nextStyleDescriptor: VectorStyle.createDescriptor( - { - ...originalProperties, - ...updatedProperties, - }, - this.isTimeAware() - ), - }; - } - async pluckStyleMetaFromSourceDataRequest(sourceDataRequest: DataRequest) { const features = _.get(sourceDataRequest.getData(), 'features', []); @@ -478,11 +568,11 @@ export class VectorStyle implements IVectorStyle { return this._descriptor.isTimeAware; } - getRawProperties() { + getRawProperties(): VectorStylePropertiesDescriptor { return this._descriptor.properties || {}; } - getDynamicPropertiesArray() { + getDynamicPropertiesArray(): Array> { const styleProperties = this.getAllStyleProperties(); return styleProperties.filter( (styleProperty) => styleProperty.isDynamic() && styleProperty.isComplete() @@ -882,3 +972,32 @@ export class VectorStyle implements IVectorStyle { } } } + +function getDynamicOptions( + originalProperties: VectorStylePropertiesDescriptor, + key: VECTOR_STYLES +): DynamicStylePropertyOptions | null { + if (!originalProperties[key]) { + return null; + } + const propertyDescriptor = originalProperties[key]; + if ( + !propertyDescriptor || + !('type' in propertyDescriptor) || + propertyDescriptor.type !== STYLE_TYPE.DYNAMIC || + !propertyDescriptor.options + ) { + return null; + } + return propertyDescriptor.options as DynamicStylePropertyOptions; +} + +function rectifyFieldDescriptor( + currentField: IESAggField, + previousFieldDescriptor: StylePropertyField +): StylePropertyField { + return { + origin: previousFieldDescriptor.origin, + name: currentField.getName(), + }; +} diff --git a/x-pack/plugins/maps/public/components/tooltip_selector/__snapshots__/add_tooltip_field_popover.test.tsx.snap b/x-pack/plugins/maps/public/components/tooltip_selector/__snapshots__/add_tooltip_field_popover.test.tsx.snap index be362c2ae0422..84534515dfa57 100644 --- a/x-pack/plugins/maps/public/components/tooltip_selector/__snapshots__/add_tooltip_field_popover.test.tsx.snap +++ b/x-pack/plugins/maps/public/components/tooltip_selector/__snapshots__/add_tooltip_field_popover.test.tsx.snap @@ -26,6 +26,7 @@ exports[`Should remove selected fields from selectable 1`] = ` panelPaddingSize="none" > iconForNode(el), 'border-width': (el: cytoscape.NodeSingular) => (el.selected() ? 2 : 1), - // @ts-ignore - color: theme.textColors.default, + color: theme.euiTextColors.default, 'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif', 'font-size': theme.euiFontSizeXS, 'min-zoomed-font-size': parseInt(theme.euiSizeL, 10), diff --git a/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js b/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts similarity index 74% rename from x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js rename to x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts index 37e739d0066a0..fc103959381bc 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.js +++ b/x-pack/plugins/monitoring/server/lib/apm/_get_time_of_last_event.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; +// @ts-ignore import { createApmQuery } from './create_apm_query'; +// @ts-ignore import { ApmClusterMetric } from '../metrics'; +import { LegacyRequest, ElasticsearchResponse } from '../../types'; export async function getTimeOfLastEvent({ req, @@ -15,6 +17,13 @@ export async function getTimeOfLastEvent({ start, end, clusterUuid, +}: { + req: LegacyRequest; + callWithRequest: (_req: any, endpoint: string, params: any) => Promise; + apmIndexPattern: string; + start: number; + end: number; + clusterUuid: string; }) { const params = { index: apmIndexPattern, @@ -49,5 +58,5 @@ export async function getTimeOfLastEvent({ }; const response = await callWithRequest(req, 'search', params); - return get(response, 'hits.hits[0]._source.timestamp'); + return response.hits?.hits.length ? response.hits?.hits[0]._source.timestamp : undefined; } diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts similarity index 65% rename from x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js rename to x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts index ea37ff7783ad7..4ca708e9d2832 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.js +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts @@ -4,39 +4,49 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get, upperFirst } from 'lodash'; +import { upperFirst } from 'lodash'; +// @ts-ignore import { checkParam } from '../error_missing_required'; +// @ts-ignore import { createQuery } from '../create_query'; +// @ts-ignore import { getDiffCalculation } from '../beats/_beats_stats'; +// @ts-ignore import { ApmMetric } from '../metrics'; import { getTimeOfLastEvent } from './_get_time_of_last_event'; +import { LegacyRequest, ElasticsearchResponse } from '../../types'; -export function handleResponse(response, apmUuid) { - const firstStats = get( - response, - 'hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats' - ); - const stats = get(response, 'hits.hits[0]._source.beats_stats'); +export function handleResponse(response: ElasticsearchResponse, apmUuid: string) { + if (!response.hits || response.hits.hits.length === 0) { + return {}; + } - const eventsTotalFirst = get(firstStats, 'metrics.libbeat.pipeline.events.total', null); - const eventsEmittedFirst = get(firstStats, 'metrics.libbeat.pipeline.events.published', null); - const eventsDroppedFirst = get(firstStats, 'metrics.libbeat.pipeline.events.dropped', null); - const bytesWrittenFirst = get(firstStats, 'metrics.libbeat.output.write.bytes', null); + const firstStats = response.hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats; + const stats = response.hits.hits[0]._source.beats_stats; - const eventsTotalLast = get(stats, 'metrics.libbeat.pipeline.events.total', null); - const eventsEmittedLast = get(stats, 'metrics.libbeat.pipeline.events.published', null); - const eventsDroppedLast = get(stats, 'metrics.libbeat.pipeline.events.dropped', null); - const bytesWrittenLast = get(stats, 'metrics.libbeat.output.write.bytes', null); + if (!firstStats || !stats) { + return {}; + } + + const eventsTotalFirst = firstStats.metrics?.libbeat?.pipeline?.events?.total; + const eventsEmittedFirst = firstStats.metrics?.libbeat?.pipeline?.events?.published; + const eventsDroppedFirst = firstStats.metrics?.libbeat?.pipeline?.events?.dropped; + const bytesWrittenFirst = firstStats.metrics?.libbeat?.output?.write?.bytes; + + const eventsTotalLast = stats.metrics?.libbeat?.pipeline?.events?.total; + const eventsEmittedLast = stats.metrics?.libbeat?.pipeline?.events?.published; + const eventsDroppedLast = stats.metrics?.libbeat?.pipeline?.events?.dropped; + const bytesWrittenLast = stats.metrics?.libbeat?.output?.write?.bytes; return { uuid: apmUuid, - transportAddress: get(stats, 'beat.host', null), - version: get(stats, 'beat.version', null), - name: get(stats, 'beat.name', null), - type: upperFirst(get(stats, 'beat.type')) || null, - output: upperFirst(get(stats, 'metrics.libbeat.output.type')) || null, - configReloads: get(stats, 'metrics.libbeat.config.reloads', null), - uptime: get(stats, 'metrics.beat.info.uptime.ms', null), + transportAddress: stats.beat?.host, + version: stats.beat?.version, + name: stats.beat?.name, + type: upperFirst(stats.beat?.type) || null, + output: upperFirst(stats.metrics?.libbeat?.output?.type) || null, + configReloads: stats.metrics?.libbeat?.config?.reloads, + uptime: stats.metrics?.beat?.info?.uptime?.ms, eventsTotal: getDiffCalculation(eventsTotalLast, eventsTotalFirst), eventsEmitted: getDiffCalculation(eventsEmittedLast, eventsEmittedFirst), eventsDropped: getDiffCalculation(eventsDroppedLast, eventsDroppedFirst), @@ -44,7 +54,21 @@ export function handleResponse(response, apmUuid) { }; } -export async function getApmInfo(req, apmIndexPattern, { clusterUuid, apmUuid, start, end }) { +export async function getApmInfo( + req: LegacyRequest, + apmIndexPattern: string, + { + clusterUuid, + apmUuid, + start, + end, + }: { + clusterUuid: string; + apmUuid: string; + start: number; + end: number; + } +) { checkParam(apmIndexPattern, 'apmIndexPattern in beats/getBeatSummary'); const filters = [ diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apms.js b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts similarity index 69% rename from x-pack/plugins/monitoring/server/lib/apm/get_apms.js rename to x-pack/plugins/monitoring/server/lib/apm/get_apms.ts index 2d59bfea72eb2..f6df94f8de138 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apms.js +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts @@ -5,68 +5,79 @@ */ import moment from 'moment'; -import { upperFirst, get } from 'lodash'; +import { upperFirst } from 'lodash'; +// @ts-ignore import { checkParam } from '../error_missing_required'; +// @ts-ignore import { createApmQuery } from './create_apm_query'; +// @ts-ignore import { calculateRate } from '../calculate_rate'; +// @ts-ignore import { getDiffCalculation } from './_apm_stats'; +import { LegacyRequest, ElasticsearchResponse, ElasticsearchResponseHit } from '../../types'; -export function handleResponse(response, start, end) { - const hits = get(response, 'hits.hits', []); +export function handleResponse(response: ElasticsearchResponse, start: number, end: number) { const initial = { ids: new Set(), beats: [] }; - const { beats } = hits.reduce((accum, hit) => { - const stats = get(hit, '_source.beats_stats'); - const uuid = get(stats, 'beat.uuid'); + const { beats } = response.hits?.hits.reduce((accum: any, hit: ElasticsearchResponseHit) => { + const stats = hit._source.beats_stats; + if (!stats) { + return accum; + } + + const earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats; + if (!earliestStats) { + return accum; + } + + const uuid = stats?.beat?.uuid; // skip this duplicated beat, newer one was already added if (accum.ids.has(uuid)) { return accum; } - // add another beat summary accum.ids.add(uuid); - const earliestStats = get(hit, 'inner_hits.earliest.hits.hits[0]._source.beats_stats'); // add the beat const rateOptions = { - hitTimestamp: get(stats, 'timestamp'), - earliestHitTimestamp: get(earliestStats, 'timestamp'), + hitTimestamp: stats.timestamp, + earliestHitTimestamp: earliestStats.timestamp, timeWindowMin: start, timeWindowMax: end, }; const { rate: bytesSentRate } = calculateRate({ - latestTotal: get(stats, 'metrics.libbeat.output.write.bytes'), - earliestTotal: get(earliestStats, 'metrics.libbeat.output.write.bytes'), + latestTotal: stats.metrics?.libbeat?.output?.write?.bytes, + earliestTotal: earliestStats?.metrics?.libbeat?.output?.write?.bytes, ...rateOptions, }); const { rate: totalEventsRate } = calculateRate({ - latestTotal: get(stats, 'metrics.libbeat.pipeline.events.total'), - earliestTotal: get(earliestStats, 'metrics.libbeat.pipeline.events.total'), + latestTotal: stats.metrics?.libbeat?.pipeline?.events?.total, + earliestTotal: earliestStats.metrics?.libbeat?.pipeline?.events?.total, ...rateOptions, }); - const errorsWrittenLatest = get(stats, 'metrics.libbeat.output.write.errors'); - const errorsWrittenEarliest = get(earliestStats, 'metrics.libbeat.output.write.errors'); - const errorsReadLatest = get(stats, 'metrics.libbeat.output.read.errors'); - const errorsReadEarliest = get(earliestStats, 'metrics.libbeat.output.read.errors'); + const errorsWrittenLatest = stats.metrics?.libbeat?.output?.write?.errors ?? 0; + const errorsWrittenEarliest = earliestStats.metrics?.libbeat?.output?.write?.errors ?? 0; + const errorsReadLatest = stats.metrics?.libbeat?.output?.read?.errors ?? 0; + const errorsReadEarliest = earliestStats.metrics?.libbeat?.output?.read?.errors ?? 0; const errors = getDiffCalculation( errorsWrittenLatest + errorsReadLatest, errorsWrittenEarliest + errorsReadEarliest ); accum.beats.push({ - uuid: get(stats, 'beat.uuid'), - name: get(stats, 'beat.name'), - type: upperFirst(get(stats, 'beat.type')), - output: upperFirst(get(stats, 'metrics.libbeat.output.type')), + uuid: stats.beat?.uuid, + name: stats.beat?.name, + type: upperFirst(stats.beat?.type), + output: upperFirst(stats.metrics?.libbeat?.output?.type), total_events_rate: totalEventsRate, bytes_sent_rate: bytesSentRate, errors, - memory: get(stats, 'metrics.beat.memstats.memory_alloc'), - version: get(stats, 'beat.version'), - time_of_last_event: get(hit, '_source.timestamp'), + memory: stats.metrics?.beat?.memstats?.memory_alloc, + version: stats.beat?.version, + time_of_last_event: hit._source.timestamp, }); return accum; @@ -75,7 +86,7 @@ export function handleResponse(response, start, end) { return beats; } -export async function getApms(req, apmIndexPattern, clusterUuid) { +export async function getApms(req: LegacyRequest, apmIndexPattern: string, clusterUuid: string) { checkParam(apmIndexPattern, 'apmIndexPattern in getBeats'); const config = req.server.config(); diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts similarity index 60% rename from x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js rename to x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts index 5d6c38e19bef2..57325673a131a 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.js +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts @@ -4,52 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ -import { upperFirst, get } from 'lodash'; +import { upperFirst } from 'lodash'; +import { LegacyRequest, ElasticsearchResponse } from '../../types'; +// @ts-ignore import { checkParam } from '../error_missing_required'; +// @ts-ignore import { createBeatsQuery } from './create_beats_query.js'; +// @ts-ignore import { getDiffCalculation } from './_beats_stats'; -export function handleResponse(response, beatUuid) { - const firstStats = get( - response, - 'hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats' - ); - const stats = get(response, 'hits.hits[0]._source.beats_stats'); +export function handleResponse(response: ElasticsearchResponse, beatUuid: string) { + if (!response.hits || response.hits.hits.length === 0) { + return {}; + } - const eventsTotalFirst = get(firstStats, 'metrics.libbeat.pipeline.events.total', null); - const eventsEmittedFirst = get(firstStats, 'metrics.libbeat.pipeline.events.published', null); - const eventsDroppedFirst = get(firstStats, 'metrics.libbeat.pipeline.events.dropped', null); - const bytesWrittenFirst = get(firstStats, 'metrics.libbeat.output.write.bytes', null); + const firstStats = response.hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats; + const stats = response.hits.hits[0]._source.beats_stats; - const eventsTotalLast = get(stats, 'metrics.libbeat.pipeline.events.total', null); - const eventsEmittedLast = get(stats, 'metrics.libbeat.pipeline.events.published', null); - const eventsDroppedLast = get(stats, 'metrics.libbeat.pipeline.events.dropped', null); - const bytesWrittenLast = get(stats, 'metrics.libbeat.output.write.bytes', null); - const handlesHardLimit = get(stats, 'metrics.beat.handles.limit.hard', null); - const handlesSoftLimit = get(stats, 'metrics.beat.handles.limit.soft', null); + const eventsTotalFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.total ?? null; + const eventsEmittedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.published ?? null; + const eventsDroppedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.dropped ?? null; + const bytesWrittenFirst = firstStats?.metrics?.libbeat?.output?.write?.bytes ?? null; + + const eventsTotalLast = stats?.metrics?.libbeat?.pipeline?.events?.total ?? null; + const eventsEmittedLast = stats?.metrics?.libbeat?.pipeline?.events?.published ?? null; + const eventsDroppedLast = stats?.metrics?.libbeat?.pipeline?.events?.dropped ?? null; + const bytesWrittenLast = stats?.metrics?.libbeat?.output?.write?.bytes ?? null; + const handlesHardLimit = stats?.metrics?.beat?.handles?.limit?.hard ?? null; + const handlesSoftLimit = stats?.metrics?.beat?.handles?.limit?.soft ?? null; return { uuid: beatUuid, - transportAddress: get(stats, 'beat.host', null), - version: get(stats, 'beat.version', null), - name: get(stats, 'beat.name', null), - type: upperFirst(get(stats, 'beat.type')) || null, - output: upperFirst(get(stats, 'metrics.libbeat.output.type')) || null, - configReloads: get(stats, 'metrics.libbeat.config.reloads', null), - uptime: get(stats, 'metrics.beat.info.uptime.ms', null), - eventsTotal: getDiffCalculation(eventsTotalLast, eventsTotalFirst), - eventsEmitted: getDiffCalculation(eventsEmittedLast, eventsEmittedFirst), - eventsDropped: getDiffCalculation(eventsDroppedLast, eventsDroppedFirst), - bytesWritten: getDiffCalculation(bytesWrittenLast, bytesWrittenFirst), + transportAddress: stats?.beat?.host ?? null, + version: stats?.beat?.version ?? null, + name: stats?.beat?.name ?? null, + type: upperFirst(stats?.beat?.type) ?? null, + output: upperFirst(stats?.metrics?.libbeat?.output?.type) ?? null, + configReloads: stats?.metrics?.libbeat?.config?.reloads ?? null, + uptime: stats?.metrics?.beat?.info?.uptime?.ms ?? null, + eventsTotal: getDiffCalculation(eventsTotalLast, eventsTotalFirst) ?? null, + eventsEmitted: getDiffCalculation(eventsEmittedLast, eventsEmittedFirst) ?? null, + eventsDropped: getDiffCalculation(eventsDroppedLast, eventsDroppedFirst) ?? null, + bytesWritten: getDiffCalculation(bytesWrittenLast, bytesWrittenFirst) ?? null, handlesHardLimit, handlesSoftLimit, }; } export async function getBeatSummary( - req, - beatsIndexPattern, - { clusterUuid, beatUuid, start, end } + req: LegacyRequest, + beatsIndexPattern: string, + { + clusterUuid, + beatUuid, + start, + end, + }: { clusterUuid: string; beatUuid: string; start: number; end: number } ) { checkParam(beatsIndexPattern, 'beatsIndexPattern in beats/getBeatSummary'); diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index a5d7051105797..73eea99467c59 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -78,7 +78,9 @@ export interface IBulkUploader { export interface LegacyRequest { logger: Logger; getLogger: (...scopes: string[]) => Logger; - payload: unknown; + payload: { + [key: string]: any; + }; getKibanaStatsCollector: () => any; getUiSettingsService: () => any; getActionTypeRegistry: () => any; @@ -107,3 +109,80 @@ export interface LegacyRequest { }; }; } + +export interface ElasticsearchResponse { + hits?: { + hits: ElasticsearchResponseHit[]; + total: { + value: number; + }; + }; +} + +export interface ElasticsearchResponseHit { + _source: ElasticsearchSource; + inner_hits: { + [field: string]: { + hits: { + hits: ElasticsearchResponseHit[]; + total: { + value: number; + }; + }; + }; + }; +} + +export interface ElasticsearchSource { + timestamp: string; + beats_stats?: { + timestamp?: string; + beat?: { + uuid?: string; + name?: string; + type?: string; + version?: string; + host?: string; + }; + metrics?: { + beat?: { + memstats?: { + memory_alloc?: number; + }; + info?: { + uptime?: { + ms?: number; + }; + }; + handles?: { + limit?: { + hard?: number; + soft?: number; + }; + }; + }; + libbeat?: { + config?: { + reloads?: number; + }; + output?: { + type?: string; + write?: { + bytes?: number; + errors?: number; + }; + read?: { + errors?: number; + }; + }; + pipeline?: { + events?: { + total?: number; + published?: number; + dropped?: number; + }; + }; + }; + }; + }; +} diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 303ce5f0c172d..c3765fdca4346 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -27,6 +27,8 @@ export { METRIC_TYPE, } from './hooks/use_track_metric'; +export { useFetcher } from './hooks/use_fetcher'; + export * from './typings'; export { useChartTheme } from './hooks/use_chart_theme'; diff --git a/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap b/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap index ab554e05ace2d..a79b6080ed12e 100644 --- a/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap +++ b/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap @@ -50,7 +50,7 @@ Array [ />
> { const { timeout } = opts; logger.debug(`waitForSelector ${selector}`); - const resp = await this.page.waitFor(selector, { timeout }); // override default 30000ms + const resp = await this.page.waitForSelector(selector, { timeout }); // override default 30000ms logger.debug(`waitForSelector ${selector} resolved`); return resp; } diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index efef323612322..4b42e2cc59425 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -9,13 +9,7 @@ import del from 'del'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { - Browser, - ConsoleMessage, - LaunchOptions, - Page, - Request as PuppeteerRequest, -} from 'puppeteer'; +import puppeteer from 'puppeteer'; import * as Rx from 'rxjs'; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; @@ -26,7 +20,6 @@ import { CaptureConfig } from '../../../../server/types'; import { LevelLogger } from '../../../lib'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; -import { puppeteerLaunch } from '../puppeteer'; import { args } from './args'; type BrowserConfig = CaptureConfig['browser']['chromium']; @@ -73,10 +66,10 @@ export class HeadlessChromiumDriverFactory { const chromiumArgs = this.getChromiumArgs(viewport); - let browser: Browser; - let page: Page; + let browser: puppeteer.Browser; + let page: puppeteer.Page; try { - browser = await puppeteerLaunch({ + browser = await puppeteer.launch({ pipe: !this.browserConfig.inspect, userDataDir: this.userDataDir, executablePath: this.binaryPath, @@ -85,7 +78,7 @@ export class HeadlessChromiumDriverFactory { env: { TZ: browserTimezone, }, - } as LaunchOptions); + } as puppeteer.LaunchOptions); page = await browser.newPage(); @@ -160,8 +153,8 @@ export class HeadlessChromiumDriverFactory { }); } - getBrowserLogger(page: Page, logger: LevelLogger): Rx.Observable { - const consoleMessages$ = Rx.fromEvent(page, 'console').pipe( + getBrowserLogger(page: puppeteer.Page, logger: LevelLogger): Rx.Observable { + const consoleMessages$ = Rx.fromEvent(page, 'console').pipe( map((line) => { if (line.type() === 'error') { logger.error(line.text(), ['headless-browser-console']); @@ -171,7 +164,7 @@ export class HeadlessChromiumDriverFactory { }) ); - const pageRequestFailed$ = Rx.fromEvent(page, 'requestfailed').pipe( + const pageRequestFailed$ = Rx.fromEvent(page, 'requestfailed').pipe( map((req) => { const failure = req.failure && req.failure(); if (failure) { @@ -185,7 +178,7 @@ export class HeadlessChromiumDriverFactory { return Rx.merge(consoleMessages$, pageRequestFailed$); } - getProcessLogger(browser: Browser, logger: LevelLogger): Rx.Observable { + getProcessLogger(browser: puppeteer.Browser, logger: LevelLogger): Rx.Observable { const childProcess = browser.process(); // NOTE: The browser driver can not observe stdout and stderr of the child process // Puppeteer doesn't give a handle to the original ChildProcess object @@ -201,7 +194,7 @@ export class HeadlessChromiumDriverFactory { return processClose$; // ideally, this would also merge with observers for stdout and stderr } - getPageExit(browser: Browser, page: Page) { + getPageExit(browser: puppeteer.Browser, page: puppeteer.Page) { const pageError$ = Rx.fromEvent(page, 'error').pipe( mergeMap((err) => { return Rx.throwError( diff --git a/x-pack/plugins/reporting/server/browsers/chromium/paths.ts b/x-pack/plugins/reporting/server/browsers/chromium/paths.ts index c22db895b451e..61a268460bd1b 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/paths.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/paths.ts @@ -13,34 +13,34 @@ export const paths = { { platforms: ['darwin', 'freebsd', 'openbsd'], architecture: 'x64', - archiveFilename: 'chromium-312d84c-darwin.zip', - archiveChecksum: '020303e829745fd332ae9b39442ce570', - binaryChecksum: '5cdec11d45a0eddf782bed9b9f10319f', - binaryRelativePath: 'headless_shell-darwin/headless_shell', + archiveFilename: 'chromium-ef768c9-darwin_x64.zip', + archiveChecksum: 'd87287f6b2159cff7c64babac873cc73', + binaryChecksum: '8d777b3380a654e2730fc36afbfb11e1', + binaryRelativePath: 'headless_shell-darwin_x64/headless_shell', }, { platforms: ['linux'], architecture: 'x64', - archiveFilename: 'chromium-312d84c-linux.zip', - archiveChecksum: '15ba9166a42f93ee92e42217b737018d', - binaryChecksum: 'c7fe36ed3e86a6dd23323be0a4e8c0fd', - binaryRelativePath: 'headless_shell-linux/headless_shell', + archiveFilename: 'chromium-ef768c9-linux_x64.zip', + archiveChecksum: '85575e8fd56849f4de5e3584e05712c0', + binaryChecksum: '38c4d849c17683def1283d7e5aa56fe9', + binaryRelativePath: 'headless_shell-linux_x64/headless_shell', }, { platforms: ['linux'], architecture: 'arm64', - archiveFilename: 'chromium-312d84c-linux_arm64.zip', - archiveChecksum: 'aa4d5b99dd2c1bd8e614e67f63a48652', - binaryChecksum: '7fdccff319396f0aee7f269dd85fe6fc', + archiveFilename: 'chromium-ef768c9-linux_arm64.zip', + archiveChecksum: '20b09b70476bea76a276c583bf72eac7', + binaryChecksum: 'dcfd277800c1a5c7d566c445cbdc225c', binaryRelativePath: 'headless_shell-linux_arm64/headless_shell', }, { platforms: ['win32'], architecture: 'x64', - archiveFilename: 'chromium-312d84c-windows.zip', - archiveChecksum: '3e36adfb755dacacc226ed5fd6b43105', - binaryChecksum: '9913e431fbfc7dfcd958db74ace4d58b', - binaryRelativePath: 'headless_shell-windows\\headless_shell.exe', + archiveFilename: 'chromium-ef768c9-windows_x64.zip', + archiveChecksum: '33301c749b5305b65311742578c52f15', + binaryChecksum: '9f28dd56c7a304a22bf66f0097fa4de9', + binaryRelativePath: 'headless_shell-windows_x64\\headless_shell.exe', }, ], }; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/puppeteer.ts b/x-pack/plugins/reporting/server/browsers/chromium/puppeteer.ts deleted file mode 100644 index caa25aab06287..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/chromium/puppeteer.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import puppeteer from 'puppeteer'; -// @ts-ignore lacking typedefs which this module fixes -import puppeteerCore from 'puppeteer-core'; - -export const puppeteerLaunch: ( - opts?: puppeteer.LaunchOptions -) => Promise = puppeteerCore.launch.bind(puppeteerCore); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index 798f926cd0a31..c945801dd49c2 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -jest.mock('../../browsers/chromium/puppeteer', () => ({ - puppeteerLaunch: () => ({ +jest.mock('puppeteer', () => ({ + launch: () => ({ // Fixme needs event emitters newPage: () => ({ setDefaultTimeout: jest.fn(), diff --git a/x-pack/plugins/saved_objects_tagging/README.md b/x-pack/plugins/saved_objects_tagging/README.md index 0da16746f6494..93747639ef3bb 100644 --- a/x-pack/plugins/saved_objects_tagging/README.md +++ b/x-pack/plugins/saved_objects_tagging/README.md @@ -51,3 +51,8 @@ export const tagUsageCollectorSchema: MakeSchemaFrom = { }, }; ``` + +### Update the `taggableTypes` constant to add your type + +Edit the `taggableTypes` list in `x-pack/plugins/saved_objects_tagging/common/constants.ts` to add +the name of the type you are adding. diff --git a/x-pack/plugins/saved_objects_tagging/common/assignments.ts b/x-pack/plugins/saved_objects_tagging/common/assignments.ts new file mode 100644 index 0000000000000..dac62bf6a240b --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/assignments.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * `type`+`id` tuple of a saved object + */ +export interface ObjectReference { + type: string; + id: string; +} + +/** + * Represent an assignable saved object, as returned by the `_find_assignable_objects` API + */ +export interface AssignableObject extends ObjectReference { + icon?: string; + title: string; + tags: string[]; +} + +export interface UpdateTagAssignmentsOptions { + tags: string[]; + assign: ObjectReference[]; + unassign: ObjectReference[]; +} + +export interface FindAssignableObjectsOptions { + search?: string; + maxResults?: number; + types?: string[]; +} + +/** + * Return a string that can be used as an unique identifier for given saved object + */ +export const getKey = ({ id, type }: ObjectReference) => `${type}|${id}`; diff --git a/x-pack/plugins/saved_objects_tagging/common/constants.ts b/x-pack/plugins/saved_objects_tagging/common/constants.ts index 8f7ba86973f3c..d4181889c3243 100644 --- a/x-pack/plugins/saved_objects_tagging/common/constants.ts +++ b/x-pack/plugins/saved_objects_tagging/common/constants.ts @@ -4,6 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +/** + * The id of the tagging feature as registered to to `features` plugin + */ export const tagFeatureId = 'savedObjectsTagging'; +/** + * The saved object type for `tag` objects + */ export const tagSavedObjectTypeName = 'tag'; +/** + * The management section id as registered to the `management` plugin + */ export const tagManagementSectionId = 'tags'; +/** + * The list of saved object types that are currently supporting tagging. + */ +export const taggableTypes = ['dashboard', 'visualization', 'map', 'lens']; diff --git a/x-pack/plugins/saved_objects_tagging/common/http_api_types.ts b/x-pack/plugins/saved_objects_tagging/common/http_api_types.ts new file mode 100644 index 0000000000000..bed32fa3fcb35 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/http_api_types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AssignableObject } from './assignments'; + +export interface FindAssignableObjectResponse { + objects: AssignableObject[]; +} + +export interface GetAssignableTypesResponse { + types: string[]; +} diff --git a/x-pack/plugins/saved_objects_tagging/common/references.test.ts b/x-pack/plugins/saved_objects_tagging/common/references.test.ts new file mode 100644 index 0000000000000..ab30122cdad8c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/references.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectReference } from 'src/core/types'; +import { tagIdToReference, replaceTagReferences, updateTagReferences } from './references'; + +const ref = (type: string, id: string): SavedObjectReference => ({ + id, + type, + name: `${type}-ref-${id}`, +}); + +const tagRef = (id: string) => ref('tag', id); + +describe('tagIdToReference', () => { + it('returns a reference for given tag id', () => { + expect(tagIdToReference('some-tag-id')).toEqual({ + id: 'some-tag-id', + type: 'tag', + name: 'tag-ref-some-tag-id', + }); + }); +}); + +describe('replaceTagReferences', () => { + it('updates the tag references', () => { + expect( + replaceTagReferences([tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')], ['tag-2', 'tag-4']) + ).toEqual([tagRef('tag-2'), tagRef('tag-4')]); + }); + it('leaves the non-tag references unchanged', () => { + expect( + replaceTagReferences( + [ref('dashboard', 'dash-1'), tagRef('tag-1'), ref('lens', 'lens-1'), tagRef('tag-2')], + ['tag-2', 'tag-4'] + ) + ).toEqual([ + ref('dashboard', 'dash-1'), + ref('lens', 'lens-1'), + tagRef('tag-2'), + tagRef('tag-4'), + ]); + }); +}); + +describe('updateTagReferences', () => { + it('adds the `toAdd` tag references', () => { + expect( + updateTagReferences({ + references: [tagRef('tag-1'), tagRef('tag-2')], + toAdd: ['tag-3', 'tag-4'], + }) + ).toEqual([tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3'), tagRef('tag-4')]); + }); + + it('removes the `toRemove` tag references', () => { + expect( + updateTagReferences({ + references: [tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3'), tagRef('tag-4')], + toRemove: ['tag-1', 'tag-3'], + }) + ).toEqual([tagRef('tag-2'), tagRef('tag-4')]); + }); + + it('accepts both parameters at the same time', () => { + expect( + updateTagReferences({ + references: [tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3'), tagRef('tag-4')], + toRemove: ['tag-1', 'tag-3'], + toAdd: ['tag-5', 'tag-6'], + }) + ).toEqual([tagRef('tag-2'), tagRef('tag-4'), tagRef('tag-5'), tagRef('tag-6')]); + }); + + it('does not create a duplicate reference when adding an already assigned tag', () => { + expect( + updateTagReferences({ + references: [tagRef('tag-1'), tagRef('tag-2')], + toAdd: ['tag-1', 'tag-3'], + }) + ).toEqual([tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')]); + }); + + it('ignores non-existing `toRemove` ids', () => { + expect( + updateTagReferences({ + references: [tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')], + toRemove: ['tag-2', 'unknown'], + }) + ).toEqual([tagRef('tag-1'), tagRef('tag-3')]); + }); + + it('throws if the same id is present in both `toAdd` and `toRemove`', () => { + expect(() => + updateTagReferences({ + references: [tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')], + toAdd: ['tag-1', 'tag-2'], + toRemove: ['tag-2', 'tag-3'], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Some ids from 'toAdd' also present in 'toRemove': [tag-2]"` + ); + }); + + it('preserves the non-tag references', () => { + expect( + updateTagReferences({ + references: [ + ref('dashboard', 'dash-1'), + tagRef('tag-1'), + ref('lens', 'lens-1'), + tagRef('tag-2'), + ], + toAdd: ['tag-3'], + toRemove: ['tag-1'], + }) + ).toEqual([ + ref('dashboard', 'dash-1'), + ref('lens', 'lens-1'), + tagRef('tag-2'), + tagRef('tag-3'), + ]); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/common/references.ts b/x-pack/plugins/saved_objects_tagging/common/references.ts new file mode 100644 index 0000000000000..4dd001d39e5c1 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/common/references.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uniq, intersection } from 'lodash'; +import { SavedObjectReference } from '../../../../src/core/types'; +import { tagSavedObjectTypeName } from './constants'; + +/** + * Create a {@link SavedObjectReference | reference} for given tag id. + */ +export const tagIdToReference = (tagId: string): SavedObjectReference => ({ + type: tagSavedObjectTypeName, + id: tagId, + name: `tag-ref-${tagId}`, +}); + +/** + * Update the given `references` array, replacing all the `tag` references with + * references for the given `newTagIds`, while preserving all references to non-tag objects. + */ +export const replaceTagReferences = ( + references: SavedObjectReference[], + newTagIds: string[] +): SavedObjectReference[] => { + return [ + ...references.filter(({ type }) => type !== tagSavedObjectTypeName), + ...newTagIds.map(tagIdToReference), + ]; +}; + +/** + * Update the given `references` array, adding references to `toAdd` tag ids and removing references + * to `toRemove` tag ids. + * All references to non-tag objects will be preserved. + * + * @remarks: Having the same id(s) in `toAdd` and `toRemove` will result in an error. + */ +export const updateTagReferences = ({ + references, + toAdd = [], + toRemove = [], +}: { + references: SavedObjectReference[]; + toAdd?: string[]; + toRemove?: string[]; +}): SavedObjectReference[] => { + const duplicates = intersection(toAdd, toRemove); + if (duplicates.length > 0) { + throw new Error(`Some ids from 'toAdd' also present in 'toRemove': [${duplicates.join(', ')}]`); + } + + const nonTagReferences = references.filter(({ type }) => type !== tagSavedObjectTypeName); + const newTagIds = uniq([ + ...references + .filter(({ type }) => type === tagSavedObjectTypeName) + .map(({ id }) => id) + .filter((id) => !toRemove.includes(id)), + ...toAdd, + ]); + + return [...nonTagReferences, ...newTagIds.map(tagIdToReference)]; +}; diff --git a/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts b/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts index 7f6e2a12d9e53..1ff07a1819463 100644 --- a/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts +++ b/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts @@ -7,13 +7,16 @@ import { SavedObject, SavedObjectReference } from 'src/core/types'; import { Tag, TagAttributes } from '../types'; import { TagsCapabilities } from '../capabilities'; +import { AssignableObject } from '../assignments'; -export const createTagReference = (id: string): SavedObjectReference => ({ - type: 'tag', +export const createReference = (type: string, id: string): SavedObjectReference => ({ + type, id, - name: `tag-ref-${id}`, + name: `${type}-ref-${id}`, }); +export const createTagReference = (id: string) => createReference('tag', id); + export const createSavedObject = (parts: Partial): SavedObject => ({ type: 'tag', id: 'id', @@ -46,3 +49,13 @@ export const createTagCapabilities = (parts: Partial = {}): Ta viewConnections: true, ...parts, }); + +export const createAssignableObject = ( + parts: Partial = {} +): AssignableObject => ({ + type: 'type', + id: 'id', + title: 'title', + tags: [], + ...parts, +}); diff --git a/x-pack/plugins/saved_objects_tagging/kibana.json b/x-pack/plugins/saved_objects_tagging/kibana.json index 134e48a671f28..5e8bb47bbc3a2 100644 --- a/x-pack/plugins/saved_objects_tagging/kibana.json +++ b/x-pack/plugins/saved_objects_tagging/kibana.json @@ -7,5 +7,5 @@ "configPath": ["xpack", "saved_object_tagging"], "requiredPlugins": ["features", "management", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaReact"], - "optionalPlugins": ["usageCollection"] + "optionalPlugins": ["usageCollection", "security"] } diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/assign_flyout.scss b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/assign_flyout.scss new file mode 100644 index 0000000000000..63a80fc6fb583 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/assign_flyout.scss @@ -0,0 +1,12 @@ +.tagAssignFlyout__selectionIcon { + margin-right: $euiSizeM; + margin-left: $euiSizeM; +} + +.tagAssignFlyout_searchContainer { + padding: $euiSize $euiSizeL $euiSizeS; +} + +.tagAssignFlyout__actionBar { + margin-top: $euiSizeXS; +} diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/assign_flyout.tsx b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/assign_flyout.tsx new file mode 100644 index 0000000000000..32c1253a4fcce --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/assign_flyout.tsx @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useEffect, useCallback } from 'react'; +import { EuiFlyoutFooter, EuiFlyoutHeader, EuiFlexItem, Query } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'src/core/public'; +import { AssignableObject } from '../../../common/assignments'; +import { ITagAssignmentService, ITagsCache } from '../../services'; +import { parseQuery, computeRequiredChanges } from './lib'; +import { AssignmentOverrideMap, AssignmentStatus, AssignmentStatusMap } from './types'; +import { + AssignFlyoutHeader, + AssignFlyoutSearchBar, + AssignFlyoutResultList, + AssignFlyoutFooter, + AssignFlyoutActionBar, +} from './components'; +import { getKey, sortByStatusAndTitle } from './utils'; + +import './assign_flyout.scss'; + +interface AssignFlyoutProps { + tagIds: string[]; + allowedTypes: string[]; + assignmentService: ITagAssignmentService; + notifications: NotificationsStart; + tagCache: ITagsCache; + onClose: () => Promise; +} + +const getObjectStatus = (object: AssignableObject, assignedTags: string[]): AssignmentStatus => { + const assignedCount = assignedTags.reduce((count, tagId) => { + return count + (object.tags.includes(tagId) ? 1 : 0); + }, 0); + return assignedCount === 0 ? 'none' : assignedCount === assignedTags.length ? 'full' : 'partial'; +}; + +export const AssignFlyout: FC = ({ + tagIds, + tagCache, + allowedTypes, + notifications, + assignmentService, + onClose, +}) => { + const [results, setResults] = useState([]); + const [query, setQuery] = useState(Query.parse('')); + const [initialStatus, setInitialStatus] = useState({}); + const [overrides, setOverrides] = useState({}); + const [isLoading, setLoading] = useState(false); + const [isSaving, setSaving] = useState(false); + const [initiallyAssigned, setInitiallyAssigned] = useState(0); + const [pendingChangeCount, setPendingChangeCount] = useState(0); + + // refresh the results when `query` is updated + useEffect(() => { + const refreshResults = async () => { + setLoading(true); + const { queryText, selectedTypes } = parseQuery(query); + + const fetched = await assignmentService.findAssignableObjects({ + search: queryText ? `${queryText}*` : undefined, + types: selectedTypes, + maxResults: 1000, + }); + + const fetchedStatus = fetched.reduce((status, result) => { + return { + ...status, + [getKey(result)]: getObjectStatus(result, tagIds), + }; + }, {} as AssignmentStatusMap); + const assignedCount = Object.values(fetchedStatus).filter((status) => status !== 'none') + .length; + + setResults(sortByStatusAndTitle(fetched, fetchedStatus)); + setOverrides({}); + setInitialStatus(fetchedStatus); + setInitiallyAssigned(assignedCount); + setPendingChangeCount(0); + setLoading(false); + }; + + refreshResults(); + }, [query, assignmentService, tagIds]); + + // refresh the pending changes count when `overrides` is update + useEffect(() => { + const changes = computeRequiredChanges({ objects: results, initialStatus, overrides }); + setPendingChangeCount(changes.assigned.length + changes.unassigned.length); + }, [initialStatus, overrides, results]); + + const onSave = useCallback(async () => { + setSaving(true); + const changes = computeRequiredChanges({ objects: results, initialStatus, overrides }); + await assignmentService.updateTagAssignments({ + tags: tagIds, + assign: changes.assigned.map(({ type, id }) => ({ type, id })), + unassign: changes.unassigned.map(({ type, id }) => ({ type, id })), + }); + + notifications.toasts.addSuccess( + i18n.translate('xpack.savedObjectsTagging.assignFlyout.successNotificationTitle', { + defaultMessage: + 'Saved assignments to {count, plural, one {1 saved object} other {# saved objects}}', + values: { + count: changes.assigned.length + changes.unassigned.length, + }, + }) + ); + onClose(); + }, [tagIds, results, initialStatus, overrides, notifications, assignmentService, onClose]); + + const resetAll = useCallback(() => { + setOverrides({}); + }, []); + + const selectAll = useCallback(() => { + setOverrides( + results.reduce((status, result) => { + return { + ...status, + [getKey(result)]: 'selected', + }; + }, {} as AssignmentOverrideMap) + ); + }, [results]); + + const deselectAll = useCallback(() => { + setOverrides( + results.reduce((status, result) => { + return { + ...status, + [getKey(result)]: 'deselected', + }; + }, {} as AssignmentOverrideMap) + ); + }, [results]); + + return ( + <> + + + + + { + setQuery(newQuery); + }} + isLoading={isLoading} + types={allowedTypes} + /> + + + + { + setOverrides((oldOverrides) => ({ + ...oldOverrides, + ...newOverrides, + })); + }} + /> + + + 0} + onSave={onSave} + onCancel={onClose} + /> + + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/action_bar.tsx b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/action_bar.tsx new file mode 100644 index 0000000000000..1a3f93ceac51f --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/action_bar.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export interface AssignFlyoutActionBarProps { + resultCount: number; + initiallyAssigned: number; + pendingChanges: number; + onReset: () => void; + onSelectAll: () => void; + onDeselectAll: () => void; +} + +export const AssignFlyoutActionBar: FC = ({ + resultCount, + initiallyAssigned, + pendingChanges, + onReset, + onSelectAll, + onDeselectAll, +}) => { + return ( +
+ + + + + + + +
+ + + + {pendingChanges > 0 ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/footer.tsx b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/footer.tsx new file mode 100644 index 0000000000000..540f41eee5496 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/footer.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export interface AssignFlyoutFooterProps { + isSaving: boolean; + hasPendingChanges: boolean; + onCancel: () => void; + onSave: () => void; +} + +export const AssignFlyoutFooter: FC = ({ + isSaving, + hasPendingChanges, + onCancel, + onSave, +}) => { + return ( + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/header.tsx b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/header.tsx new file mode 100644 index 0000000000000..1d9dcdf37e2c0 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/header.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useMemo } from 'react'; +import { EuiTitle, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ITagsCache } from '../../../services/tags'; +import { TagList } from '../../base'; + +export interface AssignFlyoutHeaderProps { + tagIds: string[]; + tagCache: ITagsCache; +} + +export const AssignFlyoutHeader: FC = ({ tagCache, tagIds }) => { + const tags = useMemo(() => { + return tagCache.getState().filter((tag) => tagIds.includes(tag.id)); + }, [tagCache, tagIds]); + + return ( + <> + +

+ +

+
+ + + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/index.ts new file mode 100644 index 0000000000000..804a52c634f58 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AssignFlyoutHeader } from './header'; +export { AssignFlyoutActionBar } from './action_bar'; +export { AssignFlyoutFooter } from './footer'; +export { AssignFlyoutResultList } from './result_list'; +export { AssignFlyoutSearchBar } from './search_bar'; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/result_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/result_list.tsx new file mode 100644 index 0000000000000..245c3f56fc27b --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/result_list.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiIcon, EuiSelectable, EuiSelectableOption, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AssignableObject } from '../../../../common/assignments'; +import { AssignmentAction, AssignmentOverrideMap, AssignmentStatusMap } from '../types'; +import { getKey, getOverriddenStatus, getAssignmentAction } from '../utils'; + +export interface AssignFlyoutResultListProps { + isLoading: boolean; + results: AssignableObject[]; + initialStatus: AssignmentStatusMap; + overrides: AssignmentOverrideMap; + onChange: (newOverrides: AssignmentOverrideMap) => void; +} + +interface ResultInternals { + previously: 'on' | undefined; +} + +export const AssignFlyoutResultList: FC = ({ + results, + isLoading, + initialStatus, + overrides, + onChange, +}) => { + const options = results.map((result) => { + const key = getKey(result); + const overriddenStatus = getOverriddenStatus(initialStatus[key], overrides[key]); + const checkedStatus = overriddenStatus === 'full' ? 'on' : undefined; + const statusIcon = + overriddenStatus === 'full' ? 'check' : overriddenStatus === 'none' ? 'empty' : 'partial'; + const assignmentAction = getAssignmentAction(initialStatus[key], overrides[key]); + + return { + label: result.title, + key, + 'data-test-subj': `assign-result-${result.type}-${result.id}`, + checked: checkedStatus, + previously: checkedStatus, + showIcons: false, + prepend: ( + <> + + + + ), + append: , + } as EuiSelectableOption; + }); + + return ( + + height="full" + data-test-subj="assignFlyoutResultList" + options={options} + allowExclusions={false} + isLoading={isLoading} + onChange={(newOptions) => { + const newOverrides = newOptions.reduce((memo, option) => { + if (option.checked === option.previously) { + return memo; + } + return { + ...memo, + [option.key!]: option.checked === 'on' ? 'selected' : 'deselected', + }; + }, {}); + + onChange(newOverrides); + }} + > + {(list) => list} + + ); +}; + +const ResultActionLabel: FC<{ action: AssignmentAction }> = ({ action }) => { + if (action === 'unchanged') { + return null; + } + return ( + + {action === 'added' ? ( + + ) : ( + + )} + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/search_bar.tsx b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/search_bar.tsx new file mode 100644 index 0000000000000..822ea671c073c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/components/search_bar.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useMemo } from 'react'; +import { EuiSearchBar, SearchFilterConfig } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface AssignFlyoutSearchBarProps { + onChange: (args: any) => void | boolean; + isLoading: boolean; + types: string[]; +} + +export const AssignFlyoutSearchBar: FC = ({ + onChange, + types, + isLoading, +}) => { + const filters = useMemo(() => { + return [ + { + type: 'field_value_selection', + field: 'type', + name: i18n.translate('xpack.savedObjectsTagging.assignFlyout.typeFilterName', { + defaultMessage: 'Type', + }), + multiSelect: 'or', + options: types.map((type) => ({ + value: type, + name: type, + })), + } as SearchFilterConfig, + ]; + }, [types]); + + return ( + + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/index.ts new file mode 100644 index 0000000000000..c7a4af6ff5067 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + getAssignFlyoutOpener, + AssignFlyoutOpener, + GetAssignFlyoutOpenerOptions, + OpenAssignFlyoutOptions, +} from './open_assign_flyout'; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/compute_changes.test.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/compute_changes.test.ts new file mode 100644 index 0000000000000..aacbadbdb9fb1 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/compute_changes.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAssignableObject } from '../../../../common/test_utils'; +import { getKey } from '../../../../common/assignments'; +import { computeRequiredChanges } from './compute_changes'; +import { AssignmentOverrideMap, AssignmentStatusMap } from '../types'; + +describe('computeRequiredChanges', () => { + it('returns objects that need to be assigned', () => { + const obj1 = createAssignableObject({ type: 'test', id: '1' }); + const obj2 = createAssignableObject({ type: 'test', id: '2' }); + const obj3 = createAssignableObject({ type: 'test', id: '3' }); + + const initialStatus: AssignmentStatusMap = { + [getKey(obj1)]: 'full', + [getKey(obj2)]: 'partial', + [getKey(obj3)]: 'none', + }; + + const overrides: AssignmentOverrideMap = { + [getKey(obj1)]: 'selected', + [getKey(obj2)]: 'selected', + [getKey(obj3)]: 'selected', + }; + + const { assigned, unassigned } = computeRequiredChanges({ + objects: [obj1, obj2, obj3], + initialStatus, + overrides, + }); + + expect(assigned).toEqual([obj2, obj3]); + expect(unassigned).toEqual([]); + }); + + it('returns objects that need to be unassigned', () => { + const obj1 = createAssignableObject({ type: 'test', id: '1' }); + const obj2 = createAssignableObject({ type: 'test', id: '2' }); + const obj3 = createAssignableObject({ type: 'test', id: '3' }); + + const initialStatus: AssignmentStatusMap = { + [getKey(obj1)]: 'full', + [getKey(obj2)]: 'partial', + [getKey(obj3)]: 'none', + }; + + const overrides: AssignmentOverrideMap = { + [getKey(obj1)]: 'deselected', + [getKey(obj2)]: 'deselected', + [getKey(obj3)]: 'deselected', + }; + + const { assigned, unassigned } = computeRequiredChanges({ + objects: [obj1, obj2, obj3], + initialStatus, + overrides, + }); + + expect(assigned).toEqual([]); + expect(unassigned).toEqual([obj1, obj2]); + }); + + it('does not include objects that do not have specified override', () => { + const obj1 = createAssignableObject({ type: 'test', id: '1' }); + const obj2 = createAssignableObject({ type: 'test', id: '2' }); + const obj3 = createAssignableObject({ type: 'test', id: '3' }); + + const initialStatus: AssignmentStatusMap = { + [getKey(obj1)]: 'full', + [getKey(obj2)]: 'partial', + [getKey(obj3)]: 'none', + }; + + const overrides: AssignmentOverrideMap = {}; + + const { assigned, unassigned } = computeRequiredChanges({ + objects: [obj1, obj2, obj3], + initialStatus, + overrides, + }); + + expect(assigned).toEqual([]); + expect(unassigned).toEqual([]); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/compute_changes.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/compute_changes.ts new file mode 100644 index 0000000000000..a1e8890ae9475 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/compute_changes.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AssignableObject } from '../../../../common/assignments'; +import { AssignmentStatusMap, AssignmentOverrideMap } from '../types'; +import { getAssignmentAction, getKey } from '../utils'; + +/** + * Compute the list of objects that need to be added or removed from the + * tag assignation, given their initial status and their current manual override. + */ +export const computeRequiredChanges = ({ + objects, + initialStatus, + overrides, +}: { + objects: AssignableObject[]; + initialStatus: AssignmentStatusMap; + overrides: AssignmentOverrideMap; +}) => { + const assigned: AssignableObject[] = []; + const unassigned: AssignableObject[] = []; + + objects.forEach((object) => { + const key = getKey(object); + const status = initialStatus[key]; + const override = overrides[key]; + + const action = getAssignmentAction(status, override); + if (action === 'added') { + assigned.push(object); + } + if (action === 'removed') { + unassigned.push(object); + } + }); + + return { + assigned, + unassigned, + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/index.ts new file mode 100644 index 0000000000000..81b1872be8dfb --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { parseQuery } from './parse_query'; +export { computeRequiredChanges } from './compute_changes'; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/parse_query.test.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/parse_query.test.ts new file mode 100644 index 0000000000000..eacb5b7e1333e --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/parse_query.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Query } from '@elastic/eui'; +import { parseQuery } from './parse_query'; + +describe('parseQuery', () => { + it('parses the query text', () => { + const query = Query.parse('some search'); + + expect(parseQuery(query)).toEqual({ + queryText: 'some search', + }); + }); + + it('parses the types', () => { + const query = Query.parse('type:(index-pattern or dashboard) kibana'); + + expect(parseQuery(query)).toEqual({ + queryText: 'kibana', + selectedTypes: ['index-pattern', 'dashboard'], + }); + }); + + it('does not fail on unknown fields', () => { + const query = Query.parse('unknown:(hello or dolly) some search'); + + expect(parseQuery(query)).toEqual({ + queryText: 'some search', + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/parse_query.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/parse_query.ts new file mode 100644 index 0000000000000..460990a187059 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/lib/parse_query.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Query } from '@elastic/eui'; + +export interface ParsedQuery { + /** + * Combined value of the term clauses + */ + queryText?: string; + /** + * The values of the `type` field clause (that are populated by the `type` filter) + */ + selectedTypes?: string[]; +} + +export function parseQuery(query: Query): ParsedQuery { + let queryText: string | undefined; + let selectedTypes: string[] | undefined; + + if (query) { + if (query.ast.getTermClauses().length) { + queryText = query.ast + .getTermClauses() + .map((clause: any) => clause.value) + .join(' '); + } + if (query.ast.getFieldClauses('type')) { + selectedTypes = query.ast.getFieldClauses('type')[0].value as string[]; + } + } + + return { + queryText, + selectedTypes, + }; +} diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/open_assign_flyout.tsx b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/open_assign_flyout.tsx new file mode 100644 index 0000000000000..404831f3c949d --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/open_assign_flyout.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiDelayRender, EuiLoadingSpinner } from '@elastic/eui'; +import { NotificationsStart, OverlayStart, OverlayRef } from 'src/core/public'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { ITagAssignmentService, ITagsCache } from '../../services'; + +export interface GetAssignFlyoutOpenerOptions { + overlays: OverlayStart; + notifications: NotificationsStart; + tagCache: ITagsCache; + assignmentService: ITagAssignmentService; + assignableTypes: string[]; +} + +export interface OpenAssignFlyoutOptions { + /** + * The list of tag ids to change assignments to. + */ + tagIds: string[]; +} + +export type AssignFlyoutOpener = (options: OpenAssignFlyoutOptions) => Promise; + +const LoadingIndicator = () => ( + + + +); + +const LazyAssignFlyout = React.lazy(() => + import('./assign_flyout').then(({ AssignFlyout }) => ({ default: AssignFlyout })) +); + +export const getAssignFlyoutOpener = ({ + overlays, + notifications, + tagCache, + assignmentService, + assignableTypes, +}: GetAssignFlyoutOpenerOptions): AssignFlyoutOpener => async ({ tagIds }) => { + const flyout = overlays.openFlyout( + toMountPoint( + }> + flyout.close()} + /> + + ), + { size: 'm', maxWidth: 600 } + ); + + return flyout; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/types.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/types.ts new file mode 100644 index 0000000000000..435c07dac044d --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/types.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * The assignment status of an object against a list of tags + * - `full`: the object is assigned to all tags + * - `none`: the object is not assigned to any tag + * - `partial`: the object is assigned to some, but not all, tags + */ +export type AssignmentStatus = 'full' | 'none' | 'partial'; +/** + * The assignment override performed by the user in the UI + * - `selected`: user selected an object that was previously unselected + * - `deselected`: user deselected an object that was previously selected + */ +export type AssignmentOverride = 'selected' | 'deselected'; +/** + * The final action that was performed on a given object regarding tags assignment + * - `added`: the object was previously in status `none` or `partial` and got selected + * - `removed`: the object was previously in status `full` or `partial` and got deselected + * - `unchanged`: the object wasn't changed, or the new status matches the initial one + */ +export type AssignmentAction = 'added' | 'removed' | 'unchanged'; + +export type AssignmentStatusMap = Record; +export type AssignmentOverrideMap = Record; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/utils.test.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/utils.test.ts new file mode 100644 index 0000000000000..1be1d19c46eff --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/utils.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAssignableObject } from '../../../common/test_utils'; +import { sortByStatusAndTitle, getAssignmentAction, getOverriddenStatus, getKey } from './utils'; +import { AssignmentStatusMap } from './types'; + +describe('getOverriddenStatus', () => { + it('returns the initial status if no override is defined', () => { + expect(getOverriddenStatus('none', undefined)).toEqual('none'); + expect(getOverriddenStatus('partial', undefined)).toEqual('partial'); + expect(getOverriddenStatus('full', undefined)).toEqual('full'); + }); + + it('returns the status associated with the override', () => { + expect(getOverriddenStatus('none', 'selected')).toEqual('full'); + expect(getOverriddenStatus('partial', 'deselected')).toEqual('none'); + }); +}); + +describe('getAssignmentAction', () => { + it('returns the action that was performed on the object', () => { + expect(getAssignmentAction('none', 'selected')).toEqual('added'); + expect(getAssignmentAction('partial', 'deselected')).toEqual('removed'); + }); + + it('returns `unchanged` when the override matches the initial status', () => { + expect(getAssignmentAction('none', 'deselected')).toEqual('unchanged'); + expect(getAssignmentAction('full', 'selected')).toEqual('unchanged'); + }); + + it('returns `unchanged` when no override was applied', () => { + expect(getAssignmentAction('none', undefined)).toEqual('unchanged'); + expect(getAssignmentAction('partial', undefined)).toEqual('unchanged'); + expect(getAssignmentAction('full', undefined)).toEqual('unchanged'); + }); +}); + +describe('sortByStatusAndTitle', () => { + it('sort objects by assignment status', () => { + const obj1 = createAssignableObject({ type: 'test', id: '1', title: 'aaa' }); + const obj2 = createAssignableObject({ type: 'test', id: '2', title: 'bbb' }); + const obj3 = createAssignableObject({ type: 'test', id: '3', title: 'ccc' }); + + const statusMap: AssignmentStatusMap = { + [getKey(obj1)]: 'none', + [getKey(obj2)]: 'full', + [getKey(obj3)]: 'partial', + }; + + expect(sortByStatusAndTitle([obj1, obj2, obj3], statusMap)).toEqual([obj2, obj3, obj1]); + }); + + it('sort by title when objects have the same status', () => { + const obj1 = createAssignableObject({ type: 'test', id: '1', title: 'bbb' }); + const obj2 = createAssignableObject({ type: 'test', id: '2', title: 'ccc' }); + const obj3 = createAssignableObject({ type: 'test', id: '3', title: 'aaa' }); + + const statusMap: AssignmentStatusMap = { + [getKey(obj1)]: 'full', + [getKey(obj2)]: 'full', + [getKey(obj3)]: 'full', + }; + + expect(sortByStatusAndTitle([obj1, obj2, obj3], statusMap)).toEqual([obj3, obj1, obj2]); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/utils.ts b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/utils.ts new file mode 100644 index 0000000000000..5f1a65229ecdb --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/components/assign_flyout/utils.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sortBy } from 'lodash'; +import { AssignableObject, getKey } from '../../../common/assignments'; +import { + AssignmentOverride, + AssignmentStatus, + AssignmentAction, + AssignmentStatusMap, +} from './types'; + +export { getKey } from '../../../common/assignments'; + +/** + * Return the assignment status resulting from applying + * given `override` to given `initialStatus`. + */ +export const getOverriddenStatus = ( + initialStatus: AssignmentStatus, + override: AssignmentOverride | undefined +): AssignmentStatus => { + if (override) { + return override === 'selected' ? 'full' : 'none'; + } + return initialStatus; +}; + +/** + * Return the assignment action that was effectively performed, + * given an object's `initialStatus` and `override` + */ +export const getAssignmentAction = ( + initialStatus: AssignmentStatus, + override: AssignmentOverride | undefined +): AssignmentAction => { + const overriddenStatus = getOverriddenStatus(initialStatus, override); + if (initialStatus !== overriddenStatus) { + if (overriddenStatus === 'full') { + return 'added'; + } + if (overriddenStatus === 'none') { + return 'removed'; + } + } + return 'unchanged'; +}; + +const statusPriority: Record = { + full: 1, + partial: 2, + none: 3, +}; + +/** + * Return a new array sorted by assignment status (full->partial->none) and then + * by object title (desc). + */ +export const sortByStatusAndTitle = ( + objects: AssignableObject[], + statusMap: AssignmentStatusMap +) => { + return sortBy(objects, [ + (obj) => `${statusPriority[statusMap[getKey(obj)]]}-${obj.title}`, + ]); +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx index 53e5a27b9b5d7..a29ba6f18de4c 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx @@ -11,7 +11,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { SavedObjectSaveModalTagSelectorComponentProps } from '../../../../../../src/plugins/saved_objects_tagging_oss/public'; import { TagsCapabilities } from '../../../common'; import { TagSelector } from '../base'; -import { ITagsCache } from '../../tags'; +import { ITagsCache } from '../../services'; import { CreateModalOpener } from '../edition_modal'; interface GetConnectedTagSelectorOptions { diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx index 2ac3fe4fc9ad0..374c1c2b6916e 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx @@ -11,7 +11,7 @@ import { TagListComponentProps } from '../../../../../../src/plugins/saved_objec import { Tag } from '../../../common/types'; import { getObjectTags } from '../../utils'; import { TagList } from '../base'; -import { ITagsCache } from '../../tags'; +import { ITagsCache } from '../../services'; import { byNameTagSorter } from '../../utils'; interface SavedObjectTagListProps { diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx index 04e567c8d2f3b..1a880b53b74d9 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx @@ -9,7 +9,7 @@ import useObservable from 'react-use/lib/useObservable'; import { TagSelectorComponentProps } from '../../../../../../src/plugins/saved_objects_tagging_oss/public'; import { TagsCapabilities } from '../../../common'; import { TagSelector } from '../base'; -import { ITagsCache } from '../../tags'; +import { ITagsCache } from '../../services'; import { CreateModalOpener } from '../edition_modal'; interface GetConnectedTagSelectorOptions { diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx index d6ccce88e9b4a..b1afa4401719b 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useCallback } from 'react'; import { ITagsClient, Tag, TagAttributes } from '../../../common/types'; import { TagValidation } from '../../../common/validation'; -import { isServerValidationError } from '../../tags'; +import { isServerValidationError } from '../../services/tags'; import { getRandomColor, validateTag } from './utils'; import { CreateOrEditModal } from './create_or_edit_modal'; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx index b3898dde9e953..bf745c7e18db9 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useCallback } from 'react'; import { ITagsClient, Tag, TagAttributes } from '../../../common/types'; import { TagValidation } from '../../../common/validation'; -import { isServerValidationError } from '../../tags'; +import { isServerValidationError } from '../../services/tags'; import { CreateOrEditModal } from './create_or_edit_modal'; import { validateTag } from './utils'; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx index bfe17b88aa512..9a79c6e4a4716 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx @@ -5,10 +5,11 @@ */ import React from 'react'; +import { EuiDelayRender, EuiLoadingSpinner } from '@elastic/eui'; import { OverlayStart, OverlayRef } from 'src/core/public'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; import { Tag, TagAttributes } from '../../../common/types'; -import { ITagInternalClient } from '../../tags'; +import { ITagInternalClient } from '../../services'; interface GetModalOpenerOptions { overlays: OverlayStart; @@ -20,8 +21,22 @@ interface OpenCreateModalOptions { onCreate: (tag: Tag) => void; } +const LoadingIndicator = () => ( + + + +); + export type CreateModalOpener = (options: OpenCreateModalOptions) => Promise; +const LazyCreateTagModal = React.lazy(() => + import('./create_modal').then(({ CreateTagModal }) => ({ default: CreateTagModal })) +); + +const LazyEditTagModal = React.lazy(() => + import('./edit_modal').then(({ EditTagModal }) => ({ default: EditTagModal })) +); + export const getCreateModalOpener = ({ overlays, tagClient, @@ -29,20 +44,21 @@ export const getCreateModalOpener = ({ onCreate, defaultValues, }: OpenCreateModalOptions) => { - const { CreateTagModal } = await import('./create_modal'); const modal = overlays.openModal( toMountPoint( - { - modal.close(); - }} - onSave={(tag) => { - modal.close(); - onCreate(tag); - }} - tagClient={tagClient} - /> + }> + { + modal.close(); + }} + onSave={(tag) => { + modal.close(); + onCreate(tag); + }} + tagClient={tagClient} + /> + ) ); return modal; @@ -57,22 +73,23 @@ export const getEditModalOpener = ({ overlays, tagClient }: GetModalOpenerOption tagId, onUpdate, }: OpenEditModalOptions) => { - const { EditTagModal } = await import('./edit_modal'); const tag = await tagClient.get(tagId); const modal = overlays.openModal( toMountPoint( - { - modal.close(); - }} - onSave={(saved) => { - modal.close(); - onUpdate(saved); - }} - tagClient={tagClient} - /> + }> + { + modal.close(); + }} + onSave={(saved) => { + modal.close(); + onUpdate(saved); + }} + tagClient={tagClient} + /> + ) ); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/assign.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/assign.ts new file mode 100644 index 0000000000000..9c7c0effdf97c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/assign.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, from } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { i18n } from '@kbn/i18n'; +import { NotificationsStart, OverlayStart } from 'kibana/public'; +import { TagWithRelations } from '../../../common'; +import { ITagsCache } from '../../services/tags'; +import { getAssignFlyoutOpener } from '../../components/assign_flyout'; +import { ITagAssignmentService } from '../../services/assignments'; +import { TagAction } from './types'; + +interface GetAssignActionOptions { + overlays: OverlayStart; + notifications: NotificationsStart; + tagCache: ITagsCache; + assignmentService: ITagAssignmentService; + assignableTypes: string[]; + fetchTags: () => Promise; + canceled$: Observable; +} + +export const getAssignAction = ({ + notifications, + overlays, + assignableTypes, + assignmentService, + tagCache, + fetchTags, + canceled$, +}: GetAssignActionOptions): TagAction => { + const openFlyout = getAssignFlyoutOpener({ + overlays, + notifications, + tagCache, + assignmentService, + assignableTypes, + }); + + return { + id: 'assign', + name: ({ name }) => + i18n.translate('xpack.savedObjectsTagging.management.table.actions.assign.title', { + defaultMessage: 'Manage {name} assignments', + values: { name }, + }), + description: i18n.translate( + 'xpack.savedObjectsTagging.management.table.actions.assign.description', + { + defaultMessage: 'Manage assignments', + } + ), + type: 'icon', + icon: 'tag', + onClick: async (tag: TagWithRelations) => { + const flyout = await openFlyout({ + tagIds: [tag.id], + }); + + // close the flyout when the action is canceled + // this is required when the user navigates away from the page + canceled$.pipe(takeUntil(from(flyout.onClose))).subscribe(() => { + flyout.close(); + }); + + await flyout.onClose; + await fetchTags(); + }, + 'data-test-subj': 'tagsTableAction-assign', + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/delete.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/delete.ts new file mode 100644 index 0000000000000..6b810c365635c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/delete.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { NotificationsStart, OverlayStart } from 'kibana/public'; +import { TagWithRelations } from '../../../common'; +import { ITagInternalClient } from '../../services/tags'; +import { TagAction } from './types'; + +interface GetDeleteActionOptions { + overlays: OverlayStart; + notifications: NotificationsStart; + tagClient: ITagInternalClient; + fetchTags: () => Promise; +} + +export const getDeleteAction = ({ + notifications, + overlays, + tagClient, + fetchTags, +}: GetDeleteActionOptions): TagAction => { + return { + id: 'delete', + name: ({ name }) => + i18n.translate('xpack.savedObjectsTagging.management.table.actions.delete.title', { + defaultMessage: 'Delete {name} tag', + values: { name }, + }), + description: i18n.translate( + 'xpack.savedObjectsTagging.management.table.actions.delete.description', + { + defaultMessage: 'Delete this tag', + } + ), + type: 'icon', + icon: 'trash', + onClick: async (tag: TagWithRelations) => { + const confirmed = await overlays.openConfirm( + i18n.translate('xpack.savedObjectsTagging.modals.confirmDelete.text', { + defaultMessage: + 'By deleting this tag, you will no longer be able to assign it to saved objects. ' + + 'This tag will be removed from any saved objects that currently use it. ' + + 'Are you sure you wish to proceed?', + }), + { + title: i18n.translate('xpack.savedObjectsTagging.modals.confirmDelete.title', { + defaultMessage: 'Delete "{name}" tag', + values: { + name: tag.name, + }, + }), + confirmButtonText: i18n.translate( + 'xpack.savedObjectsTagging.modals.confirmDelete.confirmButtonText', + { + defaultMessage: 'Delete tag', + } + ), + buttonColor: 'danger', + maxWidth: 560, + } + ); + if (confirmed) { + await tagClient.delete(tag.id); + + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.savedObjectsTagging.notifications.deleteTagSuccessTitle', { + defaultMessage: 'Deleted "{name}" tag', + values: { + name: tag.name, + }, + }), + }); + + await fetchTags(); + } + }, + 'data-test-subj': 'tagsTableAction-delete', + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/edit.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/edit.ts new file mode 100644 index 0000000000000..7ef55d2f15757 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/edit.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { NotificationsStart, OverlayStart } from 'kibana/public'; +import { TagWithRelations } from '../../../common'; +import { ITagInternalClient } from '../../services/tags'; +import { getEditModalOpener } from '../../components/edition_modal'; +import { TagAction } from './types'; + +interface GetEditActionOptions { + overlays: OverlayStart; + notifications: NotificationsStart; + tagClient: ITagInternalClient; + fetchTags: () => Promise; +} + +export const getEditAction = ({ + notifications, + overlays, + tagClient, + fetchTags, +}: GetEditActionOptions): TagAction => { + const editModalOpener = getEditModalOpener({ overlays, tagClient }); + return { + id: 'edit', + name: ({ name }) => + i18n.translate('xpack.savedObjectsTagging.management.table.actions.edit.title', { + defaultMessage: 'Edit {name} tag', + values: { name }, + }), + isPrimary: true, + description: i18n.translate( + 'xpack.savedObjectsTagging.management.table.actions.edit.description', + { + defaultMessage: 'Edit this tag', + } + ), + type: 'icon', + icon: 'pencil', + onClick: (tag: TagWithRelations) => { + editModalOpener({ + tagId: tag.id, + onUpdate: (updatedTag) => { + fetchTags(); + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.savedObjectsTagging.notifications.editTagSuccessTitle', { + defaultMessage: 'Saved changes to "{name}" tag', + values: { + name: updatedTag.name, + }, + }), + }); + }, + }); + }, + 'data-test-subj': 'tagsTableAction-edit', + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/index.test.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/index.test.ts index 5325d4ee97cf8..808727a7fb339 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/actions/index.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/index.test.ts @@ -4,39 +4,51 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Observable } from 'rxjs'; +import { getTableActions } from './index'; import { coreMock } from '../../../../../../src/core/public/mocks'; import { createTagCapabilities } from '../../../common/test_utils'; import { TagsCapabilities } from '../../../common/capabilities'; -import { tagClientMock } from '../../tags/tags_client.mock'; -import { TagBulkAction } from '../types'; +import { tagClientMock } from '../../services/tags/tags_client.mock'; +import { tagsCacheMock } from '../../services/tags/tags_cache.mock'; +import { assignmentServiceMock } from '../../services/assignments/assignment_service.mock'; +import { TagAction } from './types'; -import { getBulkActions } from './index'; - -describe('getBulkActions', () => { +describe('getTableActions', () => { let core: ReturnType; let tagClient: ReturnType; - let clearSelection: jest.MockedFunction<() => void>; + let tagCache: ReturnType; + let assignmentService: ReturnType; let setLoading: jest.MockedFunction<(loading: boolean) => void>; + let fetchTags: jest.MockedFunction<() => Promise>; beforeEach(() => { core = coreMock.createStart(); tagClient = tagClientMock.create(); - clearSelection = jest.fn(); + tagCache = tagsCacheMock.create(); + assignmentService = assignmentServiceMock.create(); setLoading = jest.fn(); }); - const getActions = (caps: Partial) => - getBulkActions({ + const getActions = ( + caps: Partial, + { assignableTypes = ['foo', 'bar'] }: { assignableTypes?: string[] } = {} + ) => + getTableActions({ core, tagClient, - clearSelection, + tagCache, + assignmentService, setLoading, + assignableTypes, capabilities: createTagCapabilities(caps), + fetchTags, + canceled$: new Observable(), }); - const getIds = (actions: TagBulkAction[]) => actions.map((action) => action.id); + const getIds = (actions: TagAction[]) => actions.map((action) => action.id); - it('only returns the `delete` action if user got `delete` permission', () => { + it('only returns the `delete` action if user has `delete` permission', () => { let actions = getActions({ delete: true }); expect(getIds(actions)).toContain('delete'); @@ -45,4 +57,28 @@ describe('getBulkActions', () => { expect(getIds(actions)).not.toContain('delete'); }); + + it('only returns the `edit` action if user has `edit` permission', () => { + let actions = getActions({ edit: true }); + + expect(getIds(actions)).toContain('edit'); + + actions = getActions({ edit: false }); + + expect(getIds(actions)).not.toContain('edit'); + }); + + it('only returns the `assign` action if user has `assign` permission and there is at least one assignable type', () => { + let actions = getActions({ assign: true }, { assignableTypes: ['foo'] }); + + expect(getIds(actions)).toContain('assign'); + + actions = getActions({ assign: false }, { assignableTypes: ['foo'] }); + + expect(getIds(actions)).not.toContain('assign'); + + actions = getActions({ assign: true }, { assignableTypes: [] }); + + expect(getIds(actions)).not.toContain('assign'); + }); }); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/index.ts index 182f0013251df..e9e0365c87b0f 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/actions/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/index.ts @@ -4,39 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreStart } from 'src/core/public'; +import { Observable } from 'rxjs'; +import { CoreStart } from 'kibana/public'; import { TagsCapabilities } from '../../../common'; -import { ITagInternalClient } from '../../tags'; -import { TagBulkAction } from '../types'; -import { getBulkDeleteAction } from './bulk_delete'; -import { getClearSelectionAction } from './clear_selection'; +import { ITagInternalClient, ITagAssignmentService, ITagsCache } from '../../services'; +import { TagAction } from './types'; +import { getDeleteAction } from './delete'; +import { getEditAction } from './edit'; +import { getAssignAction } from './assign'; -interface GetBulkActionOptions { +export { TagAction } from './types'; + +interface GetActionsOptions { core: CoreStart; capabilities: TagsCapabilities; tagClient: ITagInternalClient; - clearSelection: () => void; + tagCache: ITagsCache; + assignmentService: ITagAssignmentService; setLoading: (loading: boolean) => void; + assignableTypes: string[]; + fetchTags: () => Promise; + canceled$: Observable; } -export const getBulkActions = ({ +export const getTableActions = ({ core: { notifications, overlays }, capabilities, tagClient, - clearSelection, + tagCache, + assignmentService, setLoading, -}: GetBulkActionOptions): TagBulkAction[] => { - const actions: TagBulkAction[] = []; + assignableTypes, + fetchTags, + canceled$, +}: GetActionsOptions): TagAction[] => { + const actions: TagAction[] = []; - if (capabilities.delete) { - actions.push(getBulkDeleteAction({ notifications, overlays, tagClient, setLoading })); + if (capabilities.edit) { + actions.push(getEditAction({ notifications, overlays, tagClient, fetchTags })); } - // only add clear selection if user has permission to perform any other action - // as having at least one action will show the bulk action menu, and the selection column on the table - // and we want to avoid doing that only for the 'unselect' action. - if (actions.length > 0) { - actions.push(getClearSelectionAction({ clearSelection })); + if (capabilities.assign && assignableTypes.length > 0) { + actions.push( + getAssignAction({ + tagCache, + assignmentService, + assignableTypes, + fetchTags, + notifications, + overlays, + canceled$, + }) + ); + } + + if (capabilities.delete) { + actions.push(getDeleteAction({ overlays, notifications, tagClient, fetchTags })); } return actions; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/types.ts b/x-pack/plugins/saved_objects_tagging/public/management/actions/types.ts new file mode 100644 index 0000000000000..baef690cc038c --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/actions/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action as EuiTableAction } from '@elastic/eui/src/components/basic_table/action_types'; +import { TagWithRelations } from '../../../common'; + +export type TagAction = EuiTableAction & { + id: string; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/bulk_assign.ts b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/bulk_assign.ts new file mode 100644 index 0000000000000..9720482b8d247 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/bulk_assign.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { from } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { OverlayStart, NotificationsStart } from 'src/core/public'; +import { i18n } from '@kbn/i18n'; +import { ITagsCache, ITagAssignmentService } from '../../services'; +import { TagBulkAction } from '../types'; +import { getAssignFlyoutOpener } from '../../components/assign_flyout'; + +interface GetBulkAssignActionOptions { + overlays: OverlayStart; + notifications: NotificationsStart; + tagCache: ITagsCache; + assignmentService: ITagAssignmentService; + assignableTypes: string[]; + setLoading: (loading: boolean) => void; +} + +export const getBulkAssignAction = ({ + overlays, + notifications, + tagCache, + assignmentService, + setLoading, + assignableTypes, +}: GetBulkAssignActionOptions): TagBulkAction => { + const openFlyout = getAssignFlyoutOpener({ + overlays, + notifications, + tagCache, + assignmentService, + assignableTypes, + }); + + return { + id: 'assign', + label: i18n.translate('xpack.savedObjectsTagging.management.actions.bulkAssign.label', { + defaultMessage: 'Manage tag assignments', + }), + icon: 'tag', + refreshAfterExecute: true, + execute: async (tagIds, { canceled$ }) => { + const flyout = await openFlyout({ + tagIds, + }); + + // close the flyout when the action is canceled + // this is required when the user navigates away from the page + canceled$.pipe(takeUntil(from(flyout.onClose))).subscribe(() => { + flyout.close(); + }); + + return flyout.onClose; + }, + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.test.ts b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/bulk_delete.test.ts similarity index 86% rename from x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.test.ts rename to x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/bulk_delete.test.ts index 42a4e628bef4e..3f658d7e84e54 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/bulk_delete.test.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Subject } from 'rxjs'; import { overlayServiceMock, notificationServiceMock, } from '../../../../../../src/core/public/mocks'; -import { tagClientMock } from '../../tags/tags_client.mock'; +import { tagClientMock } from '../../services/tags/tags_client.mock'; import { TagBulkAction } from '../types'; import { getBulkDeleteAction } from './bulk_delete'; @@ -18,6 +19,7 @@ describe('bulkDeleteAction', () => { let notifications: ReturnType; let setLoading: jest.MockedFunction<(loading: boolean) => void>; let action: TagBulkAction; + let canceled$: Subject; const tagIds = ['id-1', 'id-2', 'id-3']; @@ -25,6 +27,7 @@ describe('bulkDeleteAction', () => { tagClient = tagClientMock.create(); overlays = overlayServiceMock.createStartContract(); notifications = notificationServiceMock.createStartContract(); + canceled$ = new Subject(); setLoading = jest.fn(); action = getBulkDeleteAction({ tagClient, overlays, notifications, setLoading }); @@ -33,7 +36,7 @@ describe('bulkDeleteAction', () => { it('performs the operation if the confirmation is accepted', async () => { overlays.openConfirm.mockResolvedValue(true); - await action.execute(tagIds); + await action.execute(tagIds, { canceled$ }); expect(overlays.openConfirm).toHaveBeenCalledTimes(1); @@ -46,7 +49,7 @@ describe('bulkDeleteAction', () => { it('does not perform the operation if the confirmation is rejected', async () => { overlays.openConfirm.mockResolvedValue(false); - await action.execute(tagIds); + await action.execute(tagIds, { canceled$ }); expect(overlays.openConfirm).toHaveBeenCalledTimes(1); @@ -58,7 +61,7 @@ describe('bulkDeleteAction', () => { overlays.openConfirm.mockResolvedValue(true); tagClient.bulkDelete.mockRejectedValue(new Error('error calling bulkDelete')); - await expect(action.execute(tagIds)).rejects.toMatchInlineSnapshot( + await expect(action.execute(tagIds, { canceled$ })).rejects.toMatchInlineSnapshot( `[Error: error calling bulkDelete]` ); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.ts b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/bulk_delete.ts similarity index 98% rename from x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.ts rename to x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/bulk_delete.ts index 6d9c14d330007..d8de937521099 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/actions/bulk_delete.ts +++ b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/bulk_delete.ts @@ -6,7 +6,7 @@ import { OverlayStart, NotificationsStart } from 'src/core/public'; import { i18n } from '@kbn/i18n'; -import { ITagInternalClient } from '../../tags'; +import { ITagInternalClient } from '../../services'; import { TagBulkAction } from '../types'; interface GetBulkDeleteActionOptions { diff --git a/x-pack/plugins/saved_objects_tagging/public/management/actions/clear_selection.ts b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/clear_selection.ts similarity index 100% rename from x-pack/plugins/saved_objects_tagging/public/management/actions/clear_selection.ts rename to x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/clear_selection.ts diff --git a/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/index.test.ts b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/index.test.ts new file mode 100644 index 0000000000000..b0e763d15aa4a --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/index.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { createTagCapabilities } from '../../../common/test_utils'; +import { TagsCapabilities } from '../../../common/capabilities'; +import { tagClientMock } from '../../services/tags/tags_client.mock'; +import { tagsCacheMock } from '../../services/tags/tags_cache.mock'; +import { assignmentServiceMock } from '../../services/assignments/assignment_service.mock'; +import { TagBulkAction } from '../types'; + +import { getBulkActions } from './index'; + +describe('getBulkActions', () => { + let core: ReturnType; + let tagClient: ReturnType; + let tagCache: ReturnType; + let assignmentService: ReturnType; + let clearSelection: jest.MockedFunction<() => void>; + let setLoading: jest.MockedFunction<(loading: boolean) => void>; + + beforeEach(() => { + core = coreMock.createStart(); + tagClient = tagClientMock.create(); + tagCache = tagsCacheMock.create(); + assignmentService = assignmentServiceMock.create(); + clearSelection = jest.fn(); + setLoading = jest.fn(); + }); + + const getActions = ( + caps: Partial, + { assignableTypes = ['foo', 'bar'] }: { assignableTypes?: string[] } = {} + ) => + getBulkActions({ + core, + tagClient, + tagCache, + assignmentService, + clearSelection, + setLoading, + assignableTypes, + capabilities: createTagCapabilities(caps), + }); + + const getIds = (actions: TagBulkAction[]) => actions.map((action) => action.id); + + it('only returns the `delete` action if user has `delete` permission', () => { + let actions = getActions({ delete: true }); + + expect(getIds(actions)).toContain('delete'); + + actions = getActions({ delete: false }); + + expect(getIds(actions)).not.toContain('delete'); + }); + + it('only returns the `assign` action if user has `assign` permission and there is at least one assignable type', () => { + let actions = getActions({ assign: true }, { assignableTypes: ['foo'] }); + + expect(getIds(actions)).toContain('assign'); + + actions = getActions({ assign: false }, { assignableTypes: ['foo'] }); + + expect(getIds(actions)).not.toContain('assign'); + + actions = getActions({ assign: true }, { assignableTypes: [] }); + + expect(getIds(actions)).not.toContain('assign'); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/index.ts new file mode 100644 index 0000000000000..7ca8b2e6dbbea --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/management/bulk_actions/index.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart } from 'src/core/public'; +import { TagsCapabilities } from '../../../common'; +import { ITagInternalClient, ITagAssignmentService, ITagsCache } from '../../services'; +import { TagBulkAction } from '../types'; +import { getBulkDeleteAction } from './bulk_delete'; +import { getBulkAssignAction } from './bulk_assign'; +import { getClearSelectionAction } from './clear_selection'; + +interface GetBulkActionOptions { + core: CoreStart; + capabilities: TagsCapabilities; + tagClient: ITagInternalClient; + tagCache: ITagsCache; + assignmentService: ITagAssignmentService; + clearSelection: () => void; + setLoading: (loading: boolean) => void; + assignableTypes: string[]; +} + +export const getBulkActions = ({ + core: { notifications, overlays }, + capabilities, + tagClient, + tagCache, + assignmentService, + clearSelection, + setLoading, + assignableTypes, +}: GetBulkActionOptions): TagBulkAction[] => { + const actions: TagBulkAction[] = []; + + if (capabilities.assign && assignableTypes.length > 0) { + actions.push( + getBulkAssignAction({ + notifications, + overlays, + tagCache, + assignmentService, + assignableTypes, + setLoading, + }) + ); + } + if (capabilities.delete) { + actions.push(getBulkDeleteAction({ notifications, overlays, tagClient, setLoading })); + } + + // only add clear selection if user has permission to perform any other action + // as having at least one action will show the bulk action menu, and the selection column on the table + // and we want to avoid doing that only for the 'unselect' action. + if (actions.length > 0) { + actions.push(getClearSelectionAction({ clearSelection })); + } + + return actions; +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx b/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx index ed1903fca2495..562776ac0ed0c 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx @@ -6,11 +6,11 @@ import React, { useRef, useEffect, FC, ReactNode } from 'react'; import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink, Query } from '@elastic/eui'; -import { Action as EuiTableAction } from '@elastic/eui/src/components/basic_table/action_types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { TagsCapabilities, TagWithRelations } from '../../../common'; import { TagBadge } from '../../components'; +import { TagAction } from '../actions'; interface TagTableProps { loading: boolean; @@ -21,10 +21,9 @@ interface TagTableProps { onQueryChange: (query?: Query) => void; selectedTags: TagWithRelations[]; onSelectionChange: (selection: TagWithRelations[]) => void; - onEdit: (tag: TagWithRelations) => void; - onDelete: (tag: TagWithRelations) => void; getTagRelationUrl: (tag: TagWithRelations) => string; onShowRelations: (tag: TagWithRelations) => void; + actions: TagAction[]; actionBar: ReactNode; } @@ -52,11 +51,10 @@ export const TagTable: FC = ({ onQueryChange, selectedTags, onSelectionChange, - onEdit, - onDelete, onShowRelations, getTagRelationUrl, actionBar, + actions, }) => { const tableRef = useRef>(null); @@ -66,46 +64,6 @@ export const TagTable: FC = ({ } }, [selectedTags]); - const actions: Array> = []; - if (capabilities.edit) { - actions.push({ - name: ({ name }) => - i18n.translate('xpack.savedObjectsTagging.management.table.actions.edit.title', { - defaultMessage: 'Edit {name} tag', - values: { name }, - }), - description: i18n.translate( - 'xpack.savedObjectsTagging.management.table.actions.edit.description', - { - defaultMessage: 'Edit this tag', - } - ), - type: 'icon', - icon: 'pencil', - onClick: (object: TagWithRelations) => onEdit(object), - 'data-test-subj': 'tagsTableAction-edit', - }); - } - if (capabilities.delete) { - actions.push({ - name: ({ name }) => - i18n.translate('xpack.savedObjectsTagging.management.table.actions.delete.title', { - defaultMessage: 'Delete {name} tag', - values: { name }, - }), - description: i18n.translate( - 'xpack.savedObjectsTagging.management.table.actions.delete.description', - { - defaultMessage: 'Delete this tag', - } - ), - type: 'icon', - icon: 'trash', - onClick: (object: TagWithRelations) => onDelete(object), - 'data-test-subj': 'tagsTableAction-delete', - }); - } - const columns: Array> = [ { field: 'name', diff --git a/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx b/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx index 8d6296c194abd..a748208b86fea 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx @@ -11,11 +11,13 @@ import { CoreSetup, ApplicationStart } from 'src/core/public'; import { ManagementAppMountParams } from '../../../../../src/plugins/management/public'; import { getTagsCapabilities } from '../../common'; import { SavedObjectTaggingPluginStart } from '../types'; -import { ITagInternalClient } from '../tags'; +import { ITagInternalClient, ITagAssignmentService, ITagsCache } from '../services'; import { TagManagementPage } from './tag_management_page'; interface MountSectionParams { tagClient: ITagInternalClient; + tagCache: ITagsCache; + assignmentService: ITagAssignmentService; core: CoreSetup<{}, SavedObjectTaggingPluginStart>; mountParams: ManagementAppMountParams; } @@ -31,10 +33,17 @@ const RedirectToHomeIfUnauthorized: FC<{ return children! as React.ReactElement; }; -export const mountSection = async ({ tagClient, core, mountParams }: MountSectionParams) => { +export const mountSection = async ({ + tagClient, + tagCache, + assignmentService, + core, + mountParams, +}: MountSectionParams) => { const [coreStart] = await core.getStartServices(); const { element, setBreadcrumbs } = mountParams; const capabilities = getTagsCapabilities(coreStart.application.capabilities); + const assignableTypes = await assignmentService.getAssignableTypes(); ReactDOM.render( @@ -43,7 +52,10 @@ export const mountSection = async ({ tagClient, core, mountParams }: MountSectio setBreadcrumbs={setBreadcrumbs} core={coreStart} tagClient={tagClient} + tagCache={tagCache} + assignmentService={assignmentService} capabilities={capabilities} + assignableTypes={assignableTypes} /> , diff --git a/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx index 6b0e17a945c06..a0f2576eabfd0 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx @@ -5,30 +5,38 @@ */ import React, { useEffect, useCallback, useState, useMemo, FC } from 'react'; +import { Subject } from 'rxjs'; import useMount from 'react-use/lib/useMount'; import { EuiPageContent, Query } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ChromeBreadcrumb, CoreStart } from 'src/core/public'; import { TagWithRelations, TagsCapabilities } from '../../common'; -import { getCreateModalOpener, getEditModalOpener } from '../components/edition_modal'; -import { ITagInternalClient } from '../tags'; +import { getCreateModalOpener } from '../components/edition_modal'; +import { ITagInternalClient, ITagAssignmentService, ITagsCache } from '../services'; import { TagBulkAction } from './types'; import { Header, TagTable, ActionBar } from './components'; -import { getBulkActions } from './actions'; +import { getTableActions } from './actions'; +import { getBulkActions } from './bulk_actions'; import { getTagConnectionsUrl } from './utils'; interface TagManagementPageParams { setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; core: CoreStart; tagClient: ITagInternalClient; + tagCache: ITagsCache; + assignmentService: ITagAssignmentService; capabilities: TagsCapabilities; + assignableTypes: string[]; } export const TagManagementPage: FC = ({ setBreadcrumbs, core, tagClient, + tagCache, + assignmentService, capabilities, + assignableTypes, }) => { const { overlays, notifications, application, http } = core; const [loading, setLoading] = useState(false); @@ -40,25 +48,72 @@ export const TagManagementPage: FC = ({ return query ? Query.execute(query, allTags) : allTags; }, [allTags, query]); - const bulkActions = useMemo(() => { - return getBulkActions({ - core, - capabilities, - tagClient, - setLoading, - clearSelection: () => setSelectedTags([]), + const unmount$ = useMemo(() => { + return new Subject(); + }, []); + + useEffect(() => { + return () => { + unmount$.next(); + }; + }, [unmount$]); + + const fetchTags = useCallback(async () => { + setLoading(true); + const { tags } = await tagClient.find({ + page: 1, + perPage: 10000, }); - }, [core, capabilities, tagClient]); + setAllTags(tags); + setLoading(false); + }, [tagClient]); + + useMount(() => { + fetchTags(); + }); const createModalOpener = useMemo(() => getCreateModalOpener({ overlays, tagClient }), [ overlays, tagClient, ]); - const editModalOpener = useMemo(() => getEditModalOpener({ overlays, tagClient }), [ - overlays, + + const tableActions = useMemo(() => { + return getTableActions({ + core, + capabilities, + tagClient, + tagCache, + assignmentService, + setLoading, + assignableTypes, + fetchTags, + canceled$: unmount$, + }); + }, [ + core, + capabilities, tagClient, + tagCache, + assignmentService, + setLoading, + assignableTypes, + fetchTags, + unmount$, ]); + const bulkActions = useMemo(() => { + return getBulkActions({ + core, + capabilities, + tagClient, + tagCache, + assignmentService, + setLoading, + assignableTypes, + clearSelection: () => setSelectedTags([]), + }); + }, [core, capabilities, tagClient, tagCache, assignmentService, assignableTypes]); + useEffect(() => { setBreadcrumbs([ { @@ -70,20 +125,6 @@ export const TagManagementPage: FC = ({ ]); }, [setBreadcrumbs]); - const fetchTags = useCallback(async () => { - setLoading(true); - const { tags } = await tagClient.find({ - page: 1, - perPage: 10000, - }); - setAllTags(tags); - setLoading(false); - }, [tagClient]); - - useMount(() => { - fetchTags(); - }); - const openCreateModal = useCallback(() => { createModalOpener({ onCreate: (createdTag) => { @@ -100,26 +141,6 @@ export const TagManagementPage: FC = ({ }); }, [notifications, createModalOpener, fetchTags]); - const openEditModal = useCallback( - (tag: TagWithRelations) => { - editModalOpener({ - tagId: tag.id, - onUpdate: (updatedTag) => { - fetchTags(); - notifications.toasts.addSuccess({ - title: i18n.translate('xpack.savedObjectsTagging.notifications.editTagSuccessTitle', { - defaultMessage: 'Saved changes to "{name}" tag', - values: { - name: updatedTag.name, - }, - }), - }); - }, - }); - }, - [notifications, editModalOpener, fetchTags] - ); - const getTagRelationUrl = useCallback( (tag: TagWithRelations) => { return getTagConnectionsUrl(tag, http.basePath); @@ -134,54 +155,13 @@ export const TagManagementPage: FC = ({ [application, getTagRelationUrl] ); - const deleteTagWithConfirm = useCallback( - async (tag: TagWithRelations) => { - const confirmed = await overlays.openConfirm( - i18n.translate('xpack.savedObjectsTagging.modals.confirmDelete.text', { - defaultMessage: - 'By deleting this tag, you will no longer be able to assign it to saved objects. ' + - 'This tag will be removed from any saved objects that currently use it. ' + - 'Are you sure you wish to proceed?', - }), - { - title: i18n.translate('xpack.savedObjectsTagging.modals.confirmDelete.title', { - defaultMessage: 'Delete "{name}" tag', - values: { - name: tag.name, - }, - }), - confirmButtonText: i18n.translate( - 'xpack.savedObjectsTagging.modals.confirmDelete.confirmButtonText', - { - defaultMessage: 'Delete tag', - } - ), - buttonColor: 'danger', - maxWidth: 560, - } - ); - if (confirmed) { - await tagClient.delete(tag.id); - - notifications.toasts.addSuccess({ - title: i18n.translate('xpack.savedObjectsTagging.notifications.deleteTagSuccessTitle', { - defaultMessage: 'Deleted "{name}" tag', - values: { - name: tag.name, - }, - }), - }); - - await fetchTags(); - } - }, - [overlays, notifications, fetchTags, tagClient] - ); - const executeBulkAction = useCallback( async (action: TagBulkAction) => { try { - await action.execute(selectedTags.map(({ id }) => id)); + await action.execute( + selectedTags.map(({ id }) => id), + { canceled$: unmount$ } + ); } catch (e) { notifications.toasts.addError(e, { title: i18n.translate('xpack.savedObjectsTagging.notifications.bulkActionError', { @@ -195,7 +175,7 @@ export const TagManagementPage: FC = ({ await fetchTags(); } }, - [selectedTags, fetchTags, notifications] + [selectedTags, fetchTags, notifications, unmount$] ); const actionBar = useMemo( @@ -218,6 +198,7 @@ export const TagManagementPage: FC = ({ tags={filteredTags} capabilities={capabilities} actionBar={actionBar} + actions={tableActions} initialQuery={query} onQueryChange={(newQuery) => { setQuery(newQuery); @@ -228,12 +209,6 @@ export const TagManagementPage: FC = ({ onSelectionChange={(tags) => { setSelectedTags(tags); }} - onEdit={(tag) => { - openEditModal(tag); - }} - onDelete={(tag) => { - deleteTagWithConfirm(tag); - }} getTagRelationUrl={getTagRelationUrl} onShowRelations={(tag) => { showTagRelations(tag); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/types.ts b/x-pack/plugins/saved_objects_tagging/public/management/types.ts index fc15785142431..649894322344a 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/types.ts +++ b/x-pack/plugins/saved_objects_tagging/public/management/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Observable } from 'rxjs'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; /** @@ -29,7 +30,10 @@ export interface TagBulkAction { /** * Handler to execute this action against the given list of selected tag ids. */ - execute: (tagIds: string[]) => void | Promise; + execute: ( + tagIds: string[], + { canceled$ }: { canceled$: Observable } + ) => void | Promise; /** * If true, the list of tags will be reloaded after the action's execution. Defaults to false. */ diff --git a/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts index 812106b4e3bbf..1b5aa39b81b39 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts @@ -28,14 +28,14 @@ describe('getTagConnectionsUrl', () => { it('appends the basePath to the generated url', () => { const tag = createTag('myTag'); expect(getTagConnectionsUrl(tag, httpMock.basePath)).toMatchInlineSnapshot( - `"/my-base-path/app/management/kibana/objects?initialQuery=tag%3A(myTag)"` + `"/my-base-path/app/management/kibana/objects?initialQuery=tag%3A(%22myTag%22)"` ); }); it('escapes the query', () => { const tag = createTag('tag with spaces'); expect(getTagConnectionsUrl(tag, httpMock.basePath)).toMatchInlineSnapshot( - `"/my-base-path/app/management/kibana/objects?initialQuery=tag%3A(tag%20with%20spaces)"` + `"/my-base-path/app/management/kibana/objects?initialQuery=tag%3A(%22tag%20with%20spaces%22)"` ); }); }); diff --git a/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts index 808e0ddcf2d65..f65ee4ddcb425 100644 --- a/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts +++ b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts @@ -12,6 +12,6 @@ import { TagWithRelations } from '../../../common/types'; * already selected in the query/filter bar. */ export const getTagConnectionsUrl = (tag: TagWithRelations, basePath: IBasePath) => { - const query = encodeURIComponent(`tag:(${tag.name})`); + const query = encodeURIComponent(`tag:("${tag.name}")`); return basePath.prepend(`/app/management/kibana/objects?initialQuery=${query}`); }; diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts index cd14d70facf9b..64b9d3930818f 100644 --- a/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts @@ -11,10 +11,10 @@ import { managementPluginMock } from '../../../../src/plugins/management/public/ import { savedObjectTaggingOssPluginMock } from '../../../../src/plugins/saved_objects_tagging_oss/public/mocks'; import { SavedObjectTaggingPlugin } from './plugin'; import { SavedObjectsTaggingClientConfigRawType } from './config'; -import { TagsCache } from './tags'; -import { tagsCacheMock } from './tags/tags_cache.mock'; +import { TagsCache } from './services'; +import { tagsCacheMock } from './services/tags/tags_cache.mock'; -jest.mock('./tags/tags_cache'); +jest.mock('./services/tags/tags_cache'); const MockedTagsCache = (TagsCache as unknown) as jest.Mock>; describe('SavedObjectTaggingPlugin', () => { diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts index 9a684637f2e92..a8614f74125f4 100644 --- a/x-pack/plugins/saved_objects_tagging/public/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts @@ -11,7 +11,7 @@ import { SavedObjectTaggingOssPluginSetup } from '../../../../src/plugins/saved_ import { tagManagementSectionId } from '../common/constants'; import { getTagsCapabilities } from '../common/capabilities'; import { SavedObjectTaggingPluginStart } from './types'; -import { TagsClient, TagsCache } from './tags'; +import { TagsClient, TagsCache, TagAssignmentService } from './services'; import { getUiApi } from './ui_api'; import { SavedObjectsTaggingClientConfig, SavedObjectsTaggingClientConfigRawType } from './config'; @@ -24,6 +24,7 @@ export class SavedObjectTaggingPlugin implements Plugin<{}, SavedObjectTaggingPluginStart, SetupDeps, {}> { private tagClient?: TagsClient; private tagCache?: TagsCache; + private assignmentService?: TagAssignmentService; private readonly config: SavedObjectsTaggingClientConfig; constructor(context: PluginInitializerContext) { @@ -42,11 +43,13 @@ export class SavedObjectTaggingPlugin title: i18n.translate('xpack.savedObjectsTagging.management.sectionLabel', { defaultMessage: 'Tags', }), - order: 2, + order: 1.5, mount: async (mountParams) => { const { mountSection } = await import('./management'); return mountSection({ tagClient: this.tagClient!, + tagCache: this.tagCache!, + assignmentService: this.assignmentService!, core, mountParams, }); @@ -66,6 +69,7 @@ export class SavedObjectTaggingPlugin refreshInterval: this.config.cacheRefreshInterval, }); this.tagClient = new TagsClient({ http, changeListener: this.tagCache }); + this.assignmentService = new TagAssignmentService({ http }); // do not fetch tags on anonymous page if (!http.anonymousPaths.isAnonymous(window.location.pathname)) { diff --git a/x-pack/plugins/saved_objects_tagging/public/services/assignments/assignment_service.mock.ts b/x-pack/plugins/saved_objects_tagging/public/services/assignments/assignment_service.mock.ts new file mode 100644 index 0000000000000..102cf5ff0e39e --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/services/assignments/assignment_service.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ITagAssignmentService } from './assignment_service'; + +const createAssignmentServiceMock = () => { + const mock: jest.Mocked = { + findAssignableObjects: jest.fn(), + updateTagAssignments: jest.fn(), + getAssignableTypes: jest.fn(), + }; + + return mock; +}; + +export const assignmentServiceMock = { + create: createAssignmentServiceMock, +}; diff --git a/x-pack/plugins/saved_objects_tagging/public/services/assignments/assignment_service.test.ts b/x-pack/plugins/saved_objects_tagging/public/services/assignments/assignment_service.test.ts new file mode 100644 index 0000000000000..dffa5dba48796 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/services/assignments/assignment_service.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { createAssignableObject } from '../../../common/test_utils'; +import { TagAssignmentService } from './assignment_service'; + +describe('TagAssignmentService', () => { + let service: TagAssignmentService; + let http: ReturnType; + + beforeEach(() => { + http = httpServiceMock.createSetupContract(); + service = new TagAssignmentService({ http }); + + http.get.mockResolvedValue({}); + http.post.mockResolvedValue({}); + }); + + describe('#findAssignableObjects', () => { + it('calls `http.get` with the correct parameters', async () => { + await service.findAssignableObjects({ + maxResults: 50, + search: 'term', + types: ['dashboard', 'maps'], + }); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith( + '/internal/saved_objects_tagging/assignments/_find_assignable_objects', + { + query: { + max_results: 50, + search: 'term', + types: ['dashboard', 'maps'], + }, + } + ); + }); + it('returns the objects from the response', async () => { + const results = [ + createAssignableObject({ type: 'dashboard', id: '1' }), + createAssignableObject({ type: 'map', id: '2' }), + ]; + http.get.mockResolvedValue({ + objects: results, + }); + + const objects = await service.findAssignableObjects({}); + expect(objects).toEqual(results); + }); + }); + + describe('#updateTagAssignments', () => { + it('calls `http.post` with the correct parameters', async () => { + await service.updateTagAssignments({ + tags: ['tag-1', 'tag-2'], + assign: [{ type: 'dashboard', id: 'dash-1' }], + unassign: [{ type: 'map', id: 'map-1' }], + }); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith( + '/api/saved_objects_tagging/assignments/update_by_tags', + { + body: JSON.stringify({ + tags: ['tag-1', 'tag-2'], + assign: [{ type: 'dashboard', id: 'dash-1' }], + unassign: [{ type: 'map', id: 'map-1' }], + }), + } + ); + }); + }); + + describe('#getAssignableTypes', () => { + it('calls `http.get` with the correct parameters', async () => { + await service.getAssignableTypes(); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith( + '/internal/saved_objects_tagging/assignments/_assignable_types' + ); + }); + it('returns the types from the response', async () => { + http.get.mockResolvedValue({ + types: ['dashboard', 'maps'], + }); + + const types = await service.getAssignableTypes(); + expect(types).toEqual(['dashboard', 'maps']); + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/services/assignments/assignment_service.ts b/x-pack/plugins/saved_objects_tagging/public/services/assignments/assignment_service.ts new file mode 100644 index 0000000000000..4bcd3d7d877b3 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/services/assignments/assignment_service.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'src/core/public'; +import { + UpdateTagAssignmentsOptions, + FindAssignableObjectsOptions, + AssignableObject, +} from '../../../common/assignments'; +import { + FindAssignableObjectResponse, + GetAssignableTypesResponse, +} from '../../../common/http_api_types'; + +export interface ITagAssignmentService { + /** + * Search API that only returns objects that are effectively assignable to tags for the current user. + */ + findAssignableObjects(options: FindAssignableObjectsOptions): Promise; + /** + * Update the assignments for given tag ids, by adding or removing object assignments to them. + */ + updateTagAssignments(options: UpdateTagAssignmentsOptions): Promise; + /** + * Return the list of saved object types the user can assign tags to. + */ + getAssignableTypes(): Promise; +} + +export interface TagAssignmentServiceOptions { + http: HttpSetup; +} + +export class TagAssignmentService implements ITagAssignmentService { + private readonly http: HttpSetup; + + constructor({ http }: TagAssignmentServiceOptions) { + this.http = http; + } + + public async findAssignableObjects({ search, types, maxResults }: FindAssignableObjectsOptions) { + const { objects } = await this.http.get( + '/internal/saved_objects_tagging/assignments/_find_assignable_objects', + { + query: { + search, + types, + max_results: maxResults, + }, + } + ); + return objects; + } + + public async updateTagAssignments({ tags, assign, unassign }: UpdateTagAssignmentsOptions) { + await this.http.post<{}>('/api/saved_objects_tagging/assignments/update_by_tags', { + body: JSON.stringify({ + tags, + assign, + unassign, + }), + }); + } + + public async getAssignableTypes() { + const { types } = await this.http.get( + '/internal/saved_objects_tagging/assignments/_assignable_types' + ); + return types; + } +} diff --git a/x-pack/plugins/saved_objects_tagging/public/services/assignments/index.ts b/x-pack/plugins/saved_objects_tagging/public/services/assignments/index.ts new file mode 100644 index 0000000000000..11cd0c9cfcbc2 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/services/assignments/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ITagAssignmentService, TagAssignmentService } from './assignment_service'; diff --git a/x-pack/plugins/saved_objects_tagging/public/services/index.ts b/x-pack/plugins/saved_objects_tagging/public/services/index.ts new file mode 100644 index 0000000000000..636088bfa93bf --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/services/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + ITagInternalClient, + TagsCache, + ITagsCache, + TagsClient, + ITagsChangeListener, + isServerValidationError, + TagServerValidationError, +} from './tags'; +export { TagAssignmentService, ITagAssignmentService } from './assignments'; diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/errors.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/errors.ts similarity index 92% rename from x-pack/plugins/saved_objects_tagging/public/tags/errors.ts rename to x-pack/plugins/saved_objects_tagging/public/services/tags/errors.ts index d353109c151ec..55d783f1a992e 100644 --- a/x-pack/plugins/saved_objects_tagging/public/tags/errors.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/errors.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TagValidation } from '../../common/validation'; +import { TagValidation } from '../../../common/validation'; /** * Error returned from the server when attributes validation fails for `create` or `update` operations diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/index.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/index.ts similarity index 100% rename from x-pack/plugins/saved_objects_tagging/public/tags/index.ts rename to x-pack/plugins/saved_objects_tagging/public/services/tags/index.ts diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.mock.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.mock.ts similarity index 100% rename from x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.mock.ts rename to x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.mock.ts diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.test.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.test.ts similarity index 98% rename from x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.test.ts rename to x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.test.ts index 9260e89f464b7..42de40fdb56f3 100644 --- a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.test.ts @@ -5,7 +5,7 @@ */ import moment from 'moment'; -import { Tag, TagAttributes } from '../../common/types'; +import { Tag, TagAttributes } from '../../../common/types'; import { TagsCache, CacheRefreshHandler } from './tags_cache'; const createTag = (parts: Partial): Tag => ({ diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts similarity index 98% rename from x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.ts rename to x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts index b33961d51b48f..712b4665f32ef 100644 --- a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts @@ -7,7 +7,7 @@ import { Duration } from 'moment'; import { Observable, BehaviorSubject, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { Tag, TagAttributes } from '../../common/types'; +import { Tag, TagAttributes } from '../../../common/types'; export interface ITagsCache { getState(): Tag[]; diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.mock.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.mock.ts similarity index 100% rename from x-pack/plugins/saved_objects_tagging/public/tags/tags_client.mock.ts rename to x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.mock.ts diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts similarity index 98% rename from x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts rename to x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts index 576f89b796010..5ed8c7258146d 100644 --- a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { httpServiceMock } from '../../../../../src/core/public/mocks'; -import { Tag } from '../../common/types'; -import { createTag, createTagAttributes } from '../../common/test_utils'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { Tag } from '../../../common/types'; +import { createTag, createTagAttributes } from '../../../common/test_utils'; import { tagsCacheMock } from './tags_cache.mock'; import { TagsClient, FindTagsOptions } from './tags_client'; diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts similarity index 99% rename from x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts rename to x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts index a866ae82f9702..a0141f4b6c379 100644 --- a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts @@ -5,7 +5,7 @@ */ import { HttpSetup } from 'src/core/public'; -import { Tag, TagAttributes, ITagsClient, TagWithRelations } from '../../common/types'; +import { Tag, TagAttributes, ITagsClient, TagWithRelations } from '../../../common/types'; import { ITagsChangeListener } from './tags_cache'; export interface TagsClientOptions { diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts index 5b73ff906ecdd..bfdf47cb8a451 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts @@ -7,7 +7,7 @@ import { OverlayStart } from 'src/core/public'; import { SavedObjectsTaggingApiUiComponent } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; import { TagsCapabilities } from '../../common'; -import { ITagInternalClient, ITagsCache } from '../tags'; +import { ITagInternalClient, ITagsCache } from '../services'; import { getConnectedTagListComponent, getConnectedTagSelectorComponent, diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts index df207791aa197..7698c3decf2f1 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts @@ -5,7 +5,7 @@ */ import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; -import { ITagsCache } from '../tags'; +import { ITagsCache } from '../services'; import { convertTagNameToId } from '../utils'; export interface BuildConvertNameToReferenceOptions { diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts index f4a2413dab6e9..2468b7bd6022e 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts @@ -5,7 +5,7 @@ */ import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; -import { tagsCacheMock } from '../tags/tags_cache.mock'; +import { tagsCacheMock } from '../services/tags/tags_cache.mock'; import { Tag } from '../../common/types'; import { createTag } from '../../common/test_utils'; import { buildGetSearchBarFilter } from './get_search_bar_filter'; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx index 539759a0f1320..5e4b89384b912 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx @@ -10,7 +10,7 @@ import { SavedObjectsTaggingApiUi, GetSearchBarFilterOptions, } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; -import { ITagsCache } from '../tags'; +import { ITagsCache } from '../services'; import { TagSearchBarOption } from '../components'; import { byNameTagSorter } from '../utils'; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts index f1c26aca26c2f..58b28ada5f67a 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts @@ -6,7 +6,7 @@ import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; import { taggingApiMock } from '../../../../../src/plugins/saved_objects_tagging_oss/public/mocks'; -import { tagsCacheMock } from '../tags/tags_cache.mock'; +import { tagsCacheMock } from '../services/tags/tags_cache.mock'; import { createTagReference, createSavedObject, createTag } from '../../common/test_utils'; import { buildGetTableColumnDefinition } from './get_table_column_definition'; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx index e50c163a4814f..1be7dab454d46 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx @@ -11,7 +11,7 @@ import { SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent, } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; -import { ITagsCache } from '../tags'; +import { ITagsCache } from '../services'; import { getTagsFromReferences, byNameTagSorter } from '../utils'; export interface GetTableColumnDefinitionOptions { diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts index 5d48404fca2b7..4cadf6ea773e3 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts @@ -7,7 +7,7 @@ import { OverlayStart } from 'src/core/public'; import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; import { TagsCapabilities } from '../../common'; -import { ITagsCache, ITagInternalClient } from '../tags'; +import { ITagsCache, ITagInternalClient } from '../services'; import { getTagIdsFromReferences, updateTagsReferences, convertTagNameToId } from '../utils'; import { getComponents } from './components'; import { buildGetTableColumnDefinition } from './get_table_column_definition'; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts index 726e43e02e3b8..3f9889ff7834a 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts @@ -5,7 +5,7 @@ */ import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; -import { tagsCacheMock } from '../tags/tags_cache.mock'; +import { tagsCacheMock } from '../services/tags/tags_cache.mock'; import { createTag } from '../../common/test_utils'; import { buildParseSearchQuery } from './parse_search_query'; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts index 138b2a60ad15d..034018b36d28c 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts @@ -10,7 +10,7 @@ import { ParseSearchQueryOptions, SavedObjectsTaggingApiUi, } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; -import { ITagsCache } from '../tags'; +import { ITagsCache } from '../services'; export interface BuildParseSearchQueryOptions { cache: ITagsCache; diff --git a/x-pack/plugins/saved_objects_tagging/public/utils.test.ts b/x-pack/plugins/saved_objects_tagging/public/utils.test.ts index 601a30ce9c892..5a348892a4b9b 100644 --- a/x-pack/plugins/saved_objects_tagging/public/utils.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/utils.test.ts @@ -9,9 +9,7 @@ import { getObjectTags, convertTagNameToId, byNameTagSorter, - updateTagsReferences, getTagIdsFromReferences, - tagIdToReference, } from './utils'; const createTag = (id: string, name: string = id) => ({ @@ -88,16 +86,6 @@ describe('byNameTagSorter', () => { }); }); -describe('tagIdToReference', () => { - it('returns a reference for given tag id', () => { - expect(tagIdToReference('some-tag-id')).toEqual({ - id: 'some-tag-id', - type: 'tag', - name: 'tag-ref-some-tag-id', - }); - }); -}); - describe('getTagIdsFromReferences', () => { it('returns the tag ids from the given references', () => { expect( @@ -110,24 +98,3 @@ describe('getTagIdsFromReferences', () => { ).toEqual(['tag-1', 'tag-2']); }); }); - -describe('updateTagsReferences', () => { - it('updates the tag references', () => { - expect( - updateTagsReferences([tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')], ['tag-2', 'tag-4']) - ).toEqual([tagRef('tag-2'), tagRef('tag-4')]); - }); - it('leaves the non-tag references unchanged', () => { - expect( - updateTagsReferences( - [ref('dashboard', 'dash-1'), tagRef('tag-1'), ref('lens', 'lens-1'), tagRef('tag-2')], - ['tag-2', 'tag-4'] - ) - ).toEqual([ - ref('dashboard', 'dash-1'), - ref('lens', 'lens-1'), - tagRef('tag-2'), - tagRef('tag-4'), - ]); - }); -}); diff --git a/x-pack/plugins/saved_objects_tagging/public/utils.ts b/x-pack/plugins/saved_objects_tagging/public/utils.ts index c74011dc605b6..05f534a8ebe7f 100644 --- a/x-pack/plugins/saved_objects_tagging/public/utils.ts +++ b/x-pack/plugins/saved_objects_tagging/public/utils.ts @@ -10,6 +10,11 @@ import { Tag, tagSavedObjectTypeName } from '../common'; type SavedObjectReferenceLike = SavedObjectReference | SavedObjectsFindOptionsReference; +export { + tagIdToReference, + replaceTagReferences as updateTagsReferences, +} from '../common/references'; + export const getObjectTags = (object: SavedObject, allTags: Tag[]) => { return getTagsFromReferences(object.references, allTags); }; @@ -51,19 +56,3 @@ export const testSubjFriendly = (name: string) => { export const getTagIdsFromReferences = (references: SavedObjectReferenceLike[]): string[] => { return references.filter((ref) => ref.type === tagSavedObjectTypeName).map(({ id }) => id); }; - -export const tagIdToReference = (tagId: string): SavedObjectReference => ({ - type: tagSavedObjectTypeName, - id: tagId, - name: `tag-ref-${tagId}`, -}); - -export const updateTagsReferences = ( - references: SavedObjectReference[], - newTagIds: string[] -): SavedObjectReference[] => { - return [ - ...references.filter(({ type }) => type !== tagSavedObjectTypeName), - ...newTagIds.map(tagIdToReference), - ]; -}; diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.ts index 6eb8080793d0e..ce687866711e4 100644 --- a/x-pack/plugins/saved_objects_tagging/server/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/server/plugin.ts @@ -14,6 +14,7 @@ import { } from 'src/core/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { SecurityPluginSetup } from '../../security/server'; import { savedObjectsTaggingFeature } from './features'; import { tagType } from './saved_objects'; import { ITagsRequestHandlerContext } from './types'; @@ -24,6 +25,7 @@ import { createTagUsageCollector } from './usage'; interface SetupDeps { features: FeaturesPluginSetup; usageCollection?: UsageCollectionSetup; + security?: SecurityPluginSetup; } export class SavedObjectTaggingPlugin implements Plugin<{}, {}, SetupDeps, {}> { @@ -33,7 +35,10 @@ export class SavedObjectTaggingPlugin implements Plugin<{}, {}, SetupDeps, {}> { this.legacyConfig$ = context.config.legacy.globalConfig$; } - public setup({ savedObjects, http }: CoreSetup, { features, usageCollection }: SetupDeps) { + public setup( + { savedObjects, http }: CoreSetup, + { features, usageCollection, security }: SetupDeps + ) { savedObjects.registerType(tagType); const router = http.createRouter(); @@ -42,7 +47,7 @@ export class SavedObjectTaggingPlugin implements Plugin<{}, {}, SetupDeps, {}> { http.registerRouteHandlerContext( 'tags', async (context, req, res): Promise => { - return new TagsRequestHandlerContext(context.core); + return new TagsRequestHandlerContext(req, context.core, security); } ); diff --git a/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts b/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts index 08514a32d3e0c..bfc3e495ee1a3 100644 --- a/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts +++ b/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts @@ -4,15 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import type { RequestHandlerContext } from 'src/core/server'; +import type { RequestHandlerContext, KibanaRequest } from 'src/core/server'; +import { SecurityPluginSetup } from '../../security/server'; import { ITagsClient } from '../common/types'; import { ITagsRequestHandlerContext } from './types'; -import { TagsClient } from './tags'; +import { TagsClient, IAssignmentService, AssignmentService } from './services'; export class TagsRequestHandlerContext implements ITagsRequestHandlerContext { #client?: ITagsClient; + #assignmentService?: IAssignmentService; - constructor(private readonly coreContext: RequestHandlerContext['core']) {} + constructor( + private readonly request: KibanaRequest, + private readonly coreContext: RequestHandlerContext['core'], + private readonly security?: SecurityPluginSetup + ) {} public get tagsClient() { if (this.#client == null) { @@ -20,4 +26,16 @@ export class TagsRequestHandlerContext implements ITagsRequestHandlerContext { } return this.#client; } + + public get assignmentService() { + if (this.#assignmentService == null) { + this.#assignmentService = new AssignmentService({ + request: this.request, + client: this.coreContext.savedObjects.client, + typeRegistry: this.coreContext.savedObjects.typeRegistry, + authorization: this.security?.authz, + }); + } + return this.#assignmentService; + } } diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/assignments/find_assignable_objects.ts b/x-pack/plugins/saved_objects_tagging/server/routes/assignments/find_assignable_objects.ts new file mode 100644 index 0000000000000..dcfb2f801eba9 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/assignments/find_assignable_objects.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { FindAssignableObjectResponse } from '../../../common/http_api_types'; + +export const registerFindAssignableObjectsRoute = (router: IRouter) => { + router.get( + { + path: '/internal/saved_objects_tagging/assignments/_find_assignable_objects', + validate: { + query: schema.object({ + search: schema.maybe(schema.string()), + max_results: schema.number({ min: 0, defaultValue: 1000 }), + types: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const { assignmentService } = ctx.tags!; + const { query } = req; + + const results = await assignmentService.findAssignableObjects({ + search: query.search, + types: typeof query.types === 'string' ? [query.types] : query.types, + maxResults: query.max_results, + }); + + return res.ok({ + body: { + objects: results, + } as FindAssignableObjectResponse, + }); + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/assignments/get_assignable_types.ts b/x-pack/plugins/saved_objects_tagging/server/routes/assignments/get_assignable_types.ts new file mode 100644 index 0000000000000..182aa6d5ce43d --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/assignments/get_assignable_types.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { GetAssignableTypesResponse } from '../../../common/http_api_types'; + +export const registerGetAssignableTypesRoute = (router: IRouter) => { + router.get( + { + path: '/internal/saved_objects_tagging/assignments/_assignable_types', + validate: {}, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const { assignmentService } = ctx.tags!; + const types = await assignmentService.getAssignableTypes(); + + return res.ok({ + body: { + types, + } as GetAssignableTypesResponse, + }); + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/assignments/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/assignments/index.ts new file mode 100644 index 0000000000000..b56069cd881e1 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/assignments/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { registerFindAssignableObjectsRoute } from './find_assignable_objects'; +export { registerUpdateTagsAssignmentsRoute } from './update_tags_assignments'; +export { registerGetAssignableTypesRoute } from './get_assignable_types'; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/assignments/update_tags_assignments.ts b/x-pack/plugins/saved_objects_tagging/server/routes/assignments/update_tags_assignments.ts new file mode 100644 index 0000000000000..2144b7ffd99a9 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/assignments/update_tags_assignments.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { AssignmentError } from '../../services'; + +export const registerUpdateTagsAssignmentsRoute = (router: IRouter) => { + const objectReferenceSchema = schema.object({ + type: schema.string(), + id: schema.string(), + }); + + router.post( + { + path: '/api/saved_objects_tagging/assignments/update_by_tags', + validate: { + body: schema.object( + { + tags: schema.arrayOf(schema.string(), { minSize: 1 }), + assign: schema.arrayOf(objectReferenceSchema, { defaultValue: [] }), + unassign: schema.arrayOf(objectReferenceSchema, { defaultValue: [] }), + }, + { + validate: ({ assign, unassign }) => { + if (assign.length === 0 && unassign.length === 0) { + return 'either `assign` or `unassign` must be specified'; + } + }, + } + ), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + try { + const { assignmentService } = ctx.tags!; + const { tags, assign, unassign } = req.body; + + await assignmentService.updateTagAssignments({ + tags, + assign, + unassign, + }); + + return res.ok({ + body: {}, + }); + } catch (e) { + if (e instanceof AssignmentError) { + return res.customError({ + statusCode: e.status, + body: e.message, + }); + } + throw e; + } + }) + ); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/index.ts index facfb3f690a28..bba2673b1ce0a 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/index.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/index.ts @@ -5,20 +5,31 @@ */ import { IRouter } from 'src/core/server'; -import { registerCreateTagRoute } from './create_tag'; -import { registerDeleteTagRoute } from './delete_tag'; -import { registerGetAllTagsRoute } from './get_all_tags'; -import { registerGetTagRoute } from './get_tag'; -import { registerUpdateTagRoute } from './update_tag'; +import { + registerUpdateTagRoute, + registerGetAllTagsRoute, + registerGetTagRoute, + registerDeleteTagRoute, + registerCreateTagRoute, +} from './tags'; +import { + registerFindAssignableObjectsRoute, + registerUpdateTagsAssignmentsRoute, + registerGetAssignableTypesRoute, +} from './assignments'; import { registerInternalFindTagsRoute, registerInternalBulkDeleteRoute } from './internal'; export const registerRoutes = ({ router }: { router: IRouter }) => { - // public API + // tags API registerCreateTagRoute(router); registerUpdateTagRoute(router); registerDeleteTagRoute(router); registerGetAllTagsRoute(router); registerGetTagRoute(router); + // assignment API + registerFindAssignableObjectsRoute(router); + registerUpdateTagsAssignmentsRoute(router); + registerGetAssignableTypesRoute(router); // internal API registerInternalFindTagsRoute(router); registerInternalBulkDeleteRoute(router); diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts b/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts index 2b7515a93acab..6e095eb6e4a6e 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts @@ -8,7 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'src/core/server'; import { tagSavedObjectTypeName } from '../../../common/constants'; import { TagAttributes } from '../../../common/types'; -import { savedObjectToTag } from '../../tags'; +import { savedObjectToTag } from '../../services/tags'; import { addConnectionCount } from '../lib'; export const registerInternalFindTagsRoute = (router: IRouter) => { diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/create_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/tags/create_tag.ts similarity index 95% rename from x-pack/plugins/saved_objects_tagging/server/routes/create_tag.ts rename to x-pack/plugins/saved_objects_tagging/server/routes/tags/create_tag.ts index 2db9ed33972fe..499f73c3e0470 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/create_tag.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/tags/create_tag.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'src/core/server'; -import { TagValidationError } from '../tags'; +import { TagValidationError } from '../../services/tags'; export const registerCreateTagRoute = (router: IRouter) => { router.post( diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/delete_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/tags/delete_tag.ts similarity index 100% rename from x-pack/plugins/saved_objects_tagging/server/routes/delete_tag.ts rename to x-pack/plugins/saved_objects_tagging/server/routes/tags/delete_tag.ts diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/get_all_tags.ts b/x-pack/plugins/saved_objects_tagging/server/routes/tags/get_all_tags.ts similarity index 100% rename from x-pack/plugins/saved_objects_tagging/server/routes/get_all_tags.ts rename to x-pack/plugins/saved_objects_tagging/server/routes/tags/get_all_tags.ts diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/get_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/tags/get_tag.ts similarity index 100% rename from x-pack/plugins/saved_objects_tagging/server/routes/get_tag.ts rename to x-pack/plugins/saved_objects_tagging/server/routes/tags/get_tag.ts diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/tags/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/tags/index.ts new file mode 100644 index 0000000000000..a4e497b3de2e1 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/routes/tags/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { registerCreateTagRoute } from './create_tag'; +export { registerDeleteTagRoute } from './delete_tag'; +export { registerGetAllTagsRoute } from './get_all_tags'; +export { registerGetTagRoute } from './get_tag'; +export { registerUpdateTagRoute } from './update_tag'; diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/update_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/tags/update_tag.ts similarity index 95% rename from x-pack/plugins/saved_objects_tagging/server/routes/update_tag.ts rename to x-pack/plugins/saved_objects_tagging/server/routes/tags/update_tag.ts index 2377e86aca3a1..fe8a48ae6e855 100644 --- a/x-pack/plugins/saved_objects_tagging/server/routes/update_tag.ts +++ b/x-pack/plugins/saved_objects_tagging/server/routes/tags/update_tag.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'src/core/server'; -import { TagValidationError } from '../tags'; +import { TagValidationError } from '../../services/tags'; export const registerUpdateTagRoute = (router: IRouter) => { router.post( diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.mock.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.mock.ts new file mode 100644 index 0000000000000..635a44b913681 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IAssignmentService } from './assignment_service'; + +const getAssigmentServiceMock = () => { + const mock: jest.Mocked = { + findAssignableObjects: jest.fn(), + updateTagAssignments: jest.fn(), + getAssignableTypes: jest.fn(), + }; + + return mock; +}; + +export const assigmentServiceMock = { + create: getAssigmentServiceMock, +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.test.mocks.ts new file mode 100644 index 0000000000000..f579c992a52ba --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.test.mocks.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getUpdatableSavedObjectTypesMock = jest.fn(); +jest.doMock('./get_updatable_types', () => ({ + getUpdatableSavedObjectTypes: getUpdatableSavedObjectTypesMock, +})); diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.test.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.test.ts new file mode 100644 index 0000000000000..1c782b51b5dd7 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.test.ts @@ -0,0 +1,271 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getUpdatableSavedObjectTypesMock } from './assignment_service.test.mocks'; +import { + httpServerMock, + savedObjectsClientMock, + savedObjectsTypeRegistryMock, +} from '../../../../../../src/core/server/mocks'; +import { securityMock } from '../../../../security/server/mocks'; +import { createSavedObject, createReference } from '../../../common/test_utils'; +import { taggableTypes } from '../../../common/constants'; +import { AssignmentService } from './assignment_service'; + +describe('AssignmentService', () => { + let service: AssignmentService; + let savedObjectClient: ReturnType; + let request: ReturnType; + let authorization: ReturnType['authz']; + let typeRegistry: ReturnType; + + beforeEach(() => { + request = httpServerMock.createKibanaRequest(); + authorization = securityMock.createSetup().authz; + savedObjectClient = savedObjectsClientMock.create(); + typeRegistry = savedObjectsTypeRegistryMock.create(); + + service = new AssignmentService({ + request, + typeRegistry, + authorization, + client: savedObjectClient, + }); + }); + + afterEach(() => { + getUpdatableSavedObjectTypesMock.mockReset(); + }); + + describe('#updateTagAssignments', () => { + beforeEach(() => { + getUpdatableSavedObjectTypesMock.mockImplementation(({ types }) => Promise.resolve(types)); + + savedObjectClient.bulkGet.mockResolvedValue({ + saved_objects: [], + }); + }); + + it('throws an error if trying to assign non-taggable types', async () => { + await expect( + service.updateTagAssignments({ + tags: ['tag-1', 'tag-2'], + assign: [ + { type: 'dashboard', id: 'dash-1' }, + { type: 'not-supported', id: 'foo' }, + ], + unassign: [], + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Unsupported type [not-supported]"`); + }); + + it('throws an error if trying to assign non-assignable types', async () => { + getUpdatableSavedObjectTypesMock.mockResolvedValue(['dashboard']); + + await expect( + service.updateTagAssignments({ + tags: ['tag-1', 'tag-2'], + assign: [ + { type: 'dashboard', id: 'dash-1' }, + { type: 'map', id: 'map-1' }, + ], + unassign: [], + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Forbidden type [map]"`); + }); + + it('calls `soClient.bulkGet` with the correct parameters', async () => { + await service.updateTagAssignments({ + tags: ['tag-1', 'tag-2'], + assign: [{ type: 'dashboard', id: 'dash-1' }], + unassign: [{ type: 'map', id: 'map-1' }], + }); + + expect(savedObjectClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkGet).toHaveBeenCalledWith([ + { type: 'dashboard', id: 'dash-1', fields: [] }, + { type: 'map', id: 'map-1', fields: [] }, + ]); + }); + + it('throws an error if any result from `soClient.bulkGet` has an error', async () => { + savedObjectClient.bulkGet.mockResolvedValue({ + saved_objects: [ + createSavedObject({ type: 'dashboard', id: 'dash-1' }), + createSavedObject({ + type: 'map', + id: 'map-1', + error: { + statusCode: 404, + message: 'not found', + error: 'object was not found', + }, + }), + ], + }); + + await expect( + service.updateTagAssignments({ + tags: ['tag-1', 'tag-2'], + assign: [{ type: 'dashboard', id: 'dash-1' }], + unassign: [{ type: 'map', id: 'map-1' }], + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"not found"`); + }); + + it('calls `soClient.bulkUpdate` to update the references', async () => { + savedObjectClient.bulkGet.mockResolvedValue({ + saved_objects: [ + createSavedObject({ + type: 'dashboard', + id: 'dash-1', + references: [], + }), + createSavedObject({ + type: 'map', + id: 'map-1', + references: [createReference('dashboard', 'dash-1'), createReference('tag', 'tag-1')], + }), + ], + }); + + await service.updateTagAssignments({ + tags: ['tag-1', 'tag-2'], + assign: [{ type: 'dashboard', id: 'dash-1' }], + unassign: [{ type: 'map', id: 'map-1' }], + }); + + expect(savedObjectClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkUpdate).toHaveBeenCalledWith([ + { + type: 'dashboard', + id: 'dash-1', + attributes: {}, + references: [createReference('tag', 'tag-1'), createReference('tag', 'tag-2')], + }, + { + type: 'map', + id: 'map-1', + attributes: {}, + references: [createReference('dashboard', 'dash-1')], + }, + ]); + }); + }); + + describe('#findAssignableObjects', () => { + beforeEach(() => { + getUpdatableSavedObjectTypesMock.mockImplementation(({ types }) => Promise.resolve(types)); + typeRegistry.getType.mockImplementation( + (name) => + ({ + management: { + defaultSearchField: `${name}-search-field`, + }, + } as any) + ); + savedObjectClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + page: 1, + per_page: 20, + }); + }); + + it('calls `soClient.find` with the correct parameters', async () => { + await service.findAssignableObjects({ + types: ['dashboard', 'map'], + search: 'term', + maxResults: 20, + }); + + expect(savedObjectClient.find).toHaveBeenCalledTimes(1); + expect(savedObjectClient.find).toHaveBeenCalledWith({ + page: 1, + perPage: 20, + search: 'term', + type: ['dashboard', 'map'], + searchFields: ['dashboard-search-field', 'map-search-field'], + }); + }); + + it('filters the non-assignable types', async () => { + getUpdatableSavedObjectTypesMock.mockResolvedValue(['dashboard']); + + await service.findAssignableObjects({ + types: ['dashboard', 'map'], + search: 'term', + maxResults: 20, + }); + + expect(savedObjectClient.find).toHaveBeenCalledTimes(1); + expect(savedObjectClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: ['dashboard'], + }) + ); + }); + + it('converts the results returned from `soClient.find`', async () => { + savedObjectClient.find.mockResolvedValue({ + saved_objects: [ + createSavedObject({ + type: 'dashboard', + id: 'dash-1', + }), + createSavedObject({ + type: 'map', + id: 'dash-2', + }), + ] as any[], + total: 2, + page: 1, + per_page: 20, + }); + + const results = await service.findAssignableObjects({ + types: ['dashboard', 'map'], + search: 'term', + maxResults: 20, + }); + + expect(results.map(({ type, id }) => ({ type, id }))).toEqual([ + { type: 'dashboard', id: 'dash-1' }, + { type: 'map', id: 'dash-2' }, + ]); + }); + }); + + describe('#getAssignableTypes', () => { + it('calls `getUpdatableSavedObjectTypes` with the correct parameters', async () => { + await service.getAssignableTypes(['type-a', 'type-b']); + + expect(getUpdatableSavedObjectTypesMock).toHaveBeenCalledTimes(1); + expect(getUpdatableSavedObjectTypesMock).toHaveBeenCalledWith({ + request, + authorization, + types: ['type-a', 'type-b'], + }); + }); + it('calls `getUpdatableSavedObjectTypes` with `taggableTypes` when `types` is not specified ', async () => { + await service.getAssignableTypes(); + + expect(getUpdatableSavedObjectTypesMock).toHaveBeenCalledTimes(1); + expect(getUpdatableSavedObjectTypesMock).toHaveBeenCalledWith({ + request, + authorization, + types: taggableTypes, + }); + }); + it('forward the result of `getUpdatableSavedObjectTypes`', async () => { + getUpdatableSavedObjectTypesMock.mockReturnValue(['updatable-a', 'updatable-b']); + + const assignableTypes = await service.getAssignableTypes(); + + expect(assignableTypes).toEqual(['updatable-a', 'updatable-b']); + }); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.ts new file mode 100644 index 0000000000000..949c9206e51fd --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/assignment_service.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uniq, difference } from 'lodash'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { + SavedObjectsClientContract, + ISavedObjectTypeRegistry, + KibanaRequest, + SavedObjectsBulkGetObject, +} from 'src/core/server'; +import { SecurityPluginSetup } from '../../../../security/server'; +import { + AssignableObject, + UpdateTagAssignmentsOptions, + FindAssignableObjectsOptions, + getKey, + ObjectReference, +} from '../../../common/assignments'; +import { updateTagReferences } from '../../../common/references'; +import { taggableTypes } from '../../../common/constants'; +import { getUpdatableSavedObjectTypes } from './get_updatable_types'; +import { AssignmentError } from './errors'; +import { toAssignableObject } from './utils'; + +interface AssignmentServiceOptions { + request: KibanaRequest; + client: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; + authorization?: SecurityPluginSetup['authz']; +} + +export type IAssignmentService = PublicMethodsOf; + +export class AssignmentService { + private readonly soClient: SavedObjectsClientContract; + private readonly typeRegistry: ISavedObjectTypeRegistry; + private readonly authorization?: SecurityPluginSetup['authz']; + private readonly request: KibanaRequest; + + constructor({ client, typeRegistry, authorization, request }: AssignmentServiceOptions) { + this.soClient = client; + this.typeRegistry = typeRegistry; + this.authorization = authorization; + this.request = request; + } + + public async findAssignableObjects({ + search, + types, + maxResults = 100, + }: FindAssignableObjectsOptions): Promise { + const searchedTypes = (types + ? types.filter((type) => taggableTypes.includes(type)) + : taggableTypes + ).filter((type) => this.typeRegistry.getType(type) !== undefined); + const assignableTypes = await this.getAssignableTypes(searchedTypes); + + // if no provided type was assignable, return an empty list instead of throwing an error + if (assignableTypes.length === 0) { + return []; + } + + const searchFields = uniq( + assignableTypes.map( + (name) => this.typeRegistry.getType(name)?.management!.defaultSearchField! + ) + ); + + const findResponse = await this.soClient.find({ + page: 1, + perPage: maxResults, + search, + type: assignableTypes, + searchFields, + }); + + return findResponse.saved_objects.map((object) => + toAssignableObject(object, this.typeRegistry.getType(object.type)!) + ); + } + + public async getAssignableTypes(types?: string[]) { + return getUpdatableSavedObjectTypes({ + request: this.request, + types: types ?? taggableTypes, + authorization: this.authorization, + }); + } + + public async updateTagAssignments({ tags, assign, unassign }: UpdateTagAssignmentsOptions) { + const updatedTypes = uniq([...assign, ...unassign].map(({ type }) => type)); + + const untaggableTypes = difference(updatedTypes, taggableTypes); + if (untaggableTypes.length) { + throw new AssignmentError(`Unsupported type [${untaggableTypes.join(', ')}]`, 400); + } + + const assignableTypes = await this.getAssignableTypes(); + const forbiddenTypes = difference(updatedTypes, assignableTypes); + if (forbiddenTypes.length) { + throw new AssignmentError(`Forbidden type [${forbiddenTypes.join(', ')}]`, 403); + } + + const { saved_objects: objects } = await this.soClient.bulkGet([ + ...assign.map(referenceToBulkGet), + ...unassign.map(referenceToBulkGet), + ]); + + // if we failed to fetch any object, just halt and throw an error + const firstObjWithError = objects.find((obj) => !!obj.error); + if (firstObjWithError) { + const firstError = firstObjWithError.error!; + throw new AssignmentError(firstError.message, firstError.statusCode); + } + + const toAssign = new Set(assign.map(getKey)); + const toUnassign = new Set(unassign.map(getKey)); + + const updatedObjects = objects.map((object) => { + return { + id: object.id, + type: object.type, + // partial update. this will not update any attribute + attributes: {}, + references: updateTagReferences({ + references: object.references, + toAdd: toAssign.has(getKey(object)) ? tags : [], + toRemove: toUnassign.has(getKey(object)) ? tags : [], + }), + }; + }); + + await this.soClient.bulkUpdate(updatedObjects); + } +} + +const referenceToBulkGet = ({ type, id }: ObjectReference): SavedObjectsBulkGetObject => ({ + type, + id, + // we only need `type`, `id` and `references` that are included by default. + fields: [], +}); diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/errors.test.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/errors.test.ts new file mode 100644 index 0000000000000..636e0eda3c7f1 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/errors.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AssignmentError } from './errors'; + +describe('AssignmentError', () => { + it('is assignable to its instances', () => { + // this test is here to ensure that the `Object.setPrototypeOf` constructor workaround for TS is not removed. + const error = new AssignmentError('message', 403); + + expect(error instanceof AssignmentError).toBe(true); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/errors.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/errors.ts new file mode 100644 index 0000000000000..c84fee5cc31cc --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/errors.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; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Error returned from {@link AssignmentService#updateTagAssignments} + */ +export class AssignmentError extends Error { + constructor(message: string, public readonly status: number) { + super(message); + Object.setPrototypeOf(this, AssignmentError.prototype); + } +} diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/get_updatable_types.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/get_updatable_types.ts new file mode 100644 index 0000000000000..192429d5d0ae7 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/get_updatable_types.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest } from 'kibana/server'; +import { SecurityPluginSetup } from '../../../../security/server'; + +export const getUpdatableSavedObjectTypes = async ({ + request, + types, + authorization, +}: { + types: string[]; + request: KibanaRequest; + authorization?: SecurityPluginSetup['authz']; +}) => { + // Don't bother authorizing if the security plugin is disabled, or if security is disabled in ES + const shouldAuthorize = authorization?.mode.useRbacForRequest(request) ?? false; + if (!shouldAuthorize) { + return types; + } + + // Each Saved Object type has a distinct privilege/action that we need to check + const typeActionMap = types.reduce((acc, type) => { + return { + ...acc, + [type]: authorization!.actions.savedObject.get(type, 'update'), + }; + }, {} as Record); + + // Perform the privilege check + const checkPrivileges = authorization!.checkPrivilegesDynamicallyWithRequest(request); + const { privileges } = await checkPrivileges({ kibana: Object.values(typeActionMap) }); + + // Filter results to only include the types that passed the authorization check above. + return types.filter((type) => { + const requiredPrivilege = typeActionMap[type]; + + const hasRequiredPrivilege = privileges.kibana.some( + (kibanaPrivilege) => + kibanaPrivilege.privilege === requiredPrivilege && kibanaPrivilege.authorized === true + ); + + return hasRequiredPrivilege; + }); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/index.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/index.ts new file mode 100644 index 0000000000000..a49c2eef176fb --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AssignmentService, IAssignmentService } from './assignment_service'; +export { AssignmentError } from './errors'; diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/utils.test.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/utils.test.ts new file mode 100644 index 0000000000000..4a616747d8d43 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/utils.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { SavedObjectsType } from 'kibana/server'; +import { createSavedObject, createReference } from '../../../common/test_utils'; +import { toAssignableObject } from './utils'; + +export const createType = (parts: Partial = {}): SavedObjectsType => ({ + name: 'type', + hidden: false, + namespaceType: 'single', + mappings: { + properties: {}, + }, + ...parts, +}); + +describe('toAssignableObject', () => { + it('gets the correct values from the object', () => { + expect( + toAssignableObject( + createSavedObject({ + type: 'dashboard', + id: 'foo', + }), + createType({}) + ) + ).toEqual( + expect.objectContaining({ + type: 'dashboard', + id: 'foo', + }) + ); + }); + it('gets the correct values from the type', () => { + expect( + toAssignableObject( + createSavedObject({}), + createType({ + management: { + getTitle: (obj) => 'some title', + icon: 'myIcon', + }, + }) + ) + ).toEqual( + expect.objectContaining({ + title: 'some title', + icon: 'myIcon', + }) + ); + }); + it('extracts the tag ids from the object references', () => { + expect( + toAssignableObject( + createSavedObject({ + references: [ + createReference('tag', 'tag-1'), + createReference('dashboard', 'dash-1'), + createReference('tag', 'tag-2'), + ], + }), + createType({}) + ) + ).toEqual( + expect.objectContaining({ + tags: ['tag-1', 'tag-2'], + }) + ); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/server/services/assignments/utils.ts b/x-pack/plugins/saved_objects_tagging/server/services/assignments/utils.ts new file mode 100644 index 0000000000000..d6348ea422e93 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/assignments/utils.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { SavedObject, SavedObjectsType } from 'kibana/server'; +import { AssignableObject } from '../../../common/assignments'; +import { tagSavedObjectTypeName } from '../../../common'; + +export const toAssignableObject = ( + object: SavedObject, + typeDef: SavedObjectsType +): AssignableObject => { + return { + id: object.id, + type: object.type, + title: typeDef.management?.getTitle ? typeDef.management.getTitle(object) : object.id, + icon: typeDef.management?.icon, + tags: object.references + .filter(({ type }) => type === tagSavedObjectTypeName) + .map(({ id }) => id), + }; +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/services/index.ts b/x-pack/plugins/saved_objects_tagging/server/services/index.ts new file mode 100644 index 0000000000000..f6a78fbd718f3 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/services/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TagsClient, savedObjectToTag, TagValidationError } from './tags'; +export { IAssignmentService, AssignmentService, AssignmentError } from './assignments'; diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/errors.test.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/errors.test.ts similarity index 94% rename from x-pack/plugins/saved_objects_tagging/server/tags/errors.test.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/errors.test.ts index a120b2f5ed557..3b0f3fb04e4c8 100644 --- a/x-pack/plugins/saved_objects_tagging/server/tags/errors.test.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/errors.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TagValidation } from '../../common/validation'; +import { TagValidation } from '../../../common/validation'; import { TagValidationError } from './errors'; const createValidation = (errors: TagValidation['errors'] = {}): TagValidation => ({ diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/errors.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/errors.ts similarity index 92% rename from x-pack/plugins/saved_objects_tagging/server/tags/errors.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/errors.ts index ee1f247dcf56b..0dbc7a37ddd11 100644 --- a/x-pack/plugins/saved_objects_tagging/server/tags/errors.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/errors.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TagValidation } from '../../common'; +import { TagValidation } from '../../../common'; /** * Error returned from {@link TagsClient#create} or {@link TagsClient#update} when tag diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/index.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/index.ts similarity index 100% rename from x-pack/plugins/saved_objects_tagging/server/tags/index.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/index.ts diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.mock.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.mock.ts similarity index 90% rename from x-pack/plugins/saved_objects_tagging/server/tags/tags_client.mock.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.mock.ts index a5eafb127e5c7..4b03d5e22cd37 100644 --- a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.mock.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.mock.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ITagsClient } from '../../common/types'; +import { ITagsClient } from '../../../common/types'; const createClientMock = () => { const mock: jest.Mocked = { diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.mocks.ts similarity index 100% rename from x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.mocks.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.mocks.ts diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts similarity index 97% rename from x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts index 7e656acb0204c..8f4be6db25306 100644 --- a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts @@ -6,9 +6,9 @@ import { validateTagMock } from './tags_client.test.mocks'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { TagAttributes, TagSavedObject } from '../../common/types'; -import { TagValidation } from '../../common/validation'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { TagAttributes, TagSavedObject } from '../../../common/types'; +import { TagValidation } from '../../../common/validation'; import { TagsClient } from './tags_client'; import { TagValidationError } from './errors'; diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts similarity index 96% rename from x-pack/plugins/saved_objects_tagging/server/tags/tags_client.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts index ef4ad6f128346..74c1cf1a5598d 100644 --- a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.ts @@ -5,8 +5,8 @@ */ import { SavedObjectsClientContract } from 'src/core/server'; -import { TagSavedObject, TagAttributes, ITagsClient } from '../../common/types'; -import { tagSavedObjectTypeName } from '../../common/constants'; +import { TagSavedObject, TagAttributes, ITagsClient } from '../../../common/types'; +import { tagSavedObjectTypeName } from '../../../common/constants'; import { TagValidationError } from './errors'; import { validateTag } from './validate_tag'; import { savedObjectToTag } from './utils'; diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/utils.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/utils.ts similarity index 86% rename from x-pack/plugins/saved_objects_tagging/server/tags/utils.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/utils.ts index bd9dece0eaf61..fd79b6566a03a 100644 --- a/x-pack/plugins/saved_objects_tagging/server/tags/utils.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/utils.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Tag, TagSavedObject } from '../../common/types'; +import { Tag, TagSavedObject } from '../../../common/types'; export const savedObjectToTag = (savedObject: TagSavedObject): Tag => { return { diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/validate_tag.test.mocks.ts similarity index 91% rename from x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.mocks.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/validate_tag.test.mocks.ts index 62b6b203f42cf..420cc5bcc64ea 100644 --- a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.mocks.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/validate_tag.test.mocks.ts @@ -8,7 +8,7 @@ export const validateTagNameMock = jest.fn(); export const validateTagColorMock = jest.fn(); export const validateTagDescriptionMock = jest.fn(); -jest.doMock('../../common/validation', () => ({ +jest.doMock('../../../common/validation', () => ({ validateTagName: validateTagNameMock, validateTagColor: validateTagColorMock, validateTagDescription: validateTagDescriptionMock, diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/validate_tag.test.ts similarity index 98% rename from x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/validate_tag.test.ts index 2e8201d560245..6393e451cabf8 100644 --- a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/validate_tag.test.ts @@ -10,7 +10,7 @@ import { validateTagDescriptionMock, } from './validate_tag.test.mocks'; -import { TagAttributes } from '../../common/types'; +import { TagAttributes } from '../../../common/types'; import { validateTag } from './validate_tag'; const createAttributes = (parts: Partial = {}): TagAttributes => ({ diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/validate_tag.ts similarity index 90% rename from x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.ts rename to x-pack/plugins/saved_objects_tagging/server/services/tags/validate_tag.ts index e49c4cee504b8..74156c52f2c25 100644 --- a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/validate_tag.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TagAttributes } from '../../common/types'; +import { TagAttributes } from '../../../common/types'; import { TagValidation, validateTagColor, validateTagName, validateTagDescription, -} from '../../common/validation'; +} from '../../../common/validation'; export const validateTag = (attributes: TagAttributes): TagValidation => { const validation: TagValidation = { diff --git a/x-pack/plugins/saved_objects_tagging/server/types.ts b/x-pack/plugins/saved_objects_tagging/server/types.ts index 9997be0c3cb22..de5997b84a75c 100644 --- a/x-pack/plugins/saved_objects_tagging/server/types.ts +++ b/x-pack/plugins/saved_objects_tagging/server/types.ts @@ -5,9 +5,11 @@ */ import { ITagsClient } from '../common/types'; +import { IAssignmentService } from './services'; export interface ITagsRequestHandlerContext { tagsClient: ITagsClient; + assignmentService: IAssignmentService; } declare module 'src/core/server' { diff --git a/x-pack/plugins/saved_objects_tagging/server/usage/schema.test.ts b/x-pack/plugins/saved_objects_tagging/server/usage/schema.test.ts new file mode 100644 index 0000000000000..b0c9cf08d4044 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/usage/schema.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { tagUsageCollectorSchema } from './schema'; +import { taggableTypes } from '../../common/constants'; + +describe('usage collector schema', () => { + // this test is there to assert than when a new type is added to `taggableTypes`, + // it is also added to the usage collector schema. + it('contains entry for every taggable type', () => { + const schemaTypes = Object.keys(tagUsageCollectorSchema.types); + expect(schemaTypes.sort()).toEqual(taggableTypes.sort()); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts b/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts index 8132c60daf964..b5a0ce39bbe44 100644 --- a/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts +++ b/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts @@ -18,6 +18,7 @@ export const tagUsageCollectorSchema: MakeSchemaFrom = { types: { dashboard: perTypeSchema, + lens: perTypeSchema, visualization: perTypeSchema, map: perTypeSchema, }, diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 6aba78c936071..2e003b1d55eac 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -45,7 +45,7 @@ export interface AuditEvent { */ saved_object?: { type: string; - id?: string; + id: string; }; /** * Any additional event specific fields. @@ -178,7 +178,9 @@ export enum SavedObjectAction { REMOVE_REFERENCES = 'saved_object_remove_references', } -const eventVerbs = { +type VerbsTuple = [string, string, string]; + +const eventVerbs: Record = { saved_object_create: ['create', 'creating', 'created'], saved_object_get: ['access', 'accessing', 'accessed'], saved_object_update: ['update', 'updating', 'updated'], @@ -193,7 +195,7 @@ const eventVerbs = { ], }; -const eventTypes = { +const eventTypes: Record = { saved_object_create: EventType.CREATION, saved_object_get: EventType.ACCESS, saved_object_update: EventType.CHANGE, @@ -204,10 +206,10 @@ const eventTypes = { saved_object_remove_references: EventType.CHANGE, }; -export interface SavedObjectParams { +export interface SavedObjectEventParams { action: SavedObjectAction; outcome?: EventOutcome; - savedObject?: Required['kibana']>['saved_object']; + savedObject?: NonNullable['saved_object']; addToSpaces?: readonly string[]; deleteFromSpaces?: readonly string[]; error?: Error; @@ -220,12 +222,12 @@ export function savedObjectEvent({ deleteFromSpaces, outcome, error, -}: SavedObjectParams): AuditEvent | undefined { +}: SavedObjectEventParams): AuditEvent | undefined { const doc = savedObject ? `${savedObject.type} [id=${savedObject.id}]` : 'saved objects'; const [present, progressive, past] = eventVerbs[action]; const message = error ? `Failed attempt to ${present} ${doc}` - : outcome === 'unknown' + : outcome === EventOutcome.UNKNOWN ? `User is ${progressive} ${doc}` : `User has ${past} ${doc}`; const type = eventTypes[action]; diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 04db65f88cda0..d99fbc702a078 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -27,7 +27,14 @@ export { SAMLLogin, OIDCLogin, } from './authentication'; -export { LegacyAuditLogger } from './audit'; +export { + LegacyAuditLogger, + AuditLogger, + AuditEvent, + EventCategory, + EventType, + EventOutcome, +} from './audit'; export { SecurityPluginSetup }; export { AuthenticatedUser } from '../common/model'; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index c6f4ca6dd8afe..15ca8bac89bd6 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -12,6 +12,18 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { SavedObjectActions } from '../authorization/actions/saved_object'; import { AuditEvent, EventOutcome } from '../audit'; +jest.mock('../../../../../src/core/server/saved_objects/service/lib/utils', () => { + const { SavedObjectsUtils } = jest.requireActual( + '../../../../../src/core/server/saved_objects/service/lib/utils' + ); + return { + SavedObjectsUtils: { + createEmptyFindResponse: SavedObjectsUtils.createEmptyFindResponse, + generateId: () => 'mock-saved-object-id', + }, + }; +}); + let clientOpts: ReturnType; let client: SecureSavedObjectsClientWrapper; const USERNAME = Symbol(); @@ -551,7 +563,7 @@ describe('#bulkGet', () => { }); test(`adds audit event when successful`, async () => { - const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + const apiCallReturnValue = { saved_objects: [obj1, obj2], foo: 'bar' }; clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; const options = { namespace }; @@ -686,7 +698,7 @@ describe('#create', () => { }); test(`throws decorated ForbiddenError when unauthorized`, async () => { - const options = { namespace }; + const options = { id: 'mock-saved-object-id', namespace }; await expectForbiddenError(client.create, { type, attributes, options }); }); @@ -694,8 +706,12 @@ describe('#create', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); - const options = { namespace }; - const result = await expectSuccess(client.create, { type, attributes, options }); + const options = { id: 'mock-saved-object-id', namespace }; + const result = await expectSuccess(client.create, { + type, + attributes, + options, + }); expect(result).toBe(apiCallReturnValue); }); @@ -721,17 +737,17 @@ describe('#create', () => { test(`adds audit event when successful`, async () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); - const options = { namespace }; + const options = { id: 'mock-saved-object-id', namespace }; await expectSuccess(client.create, { type, attributes, options }); expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type }); + expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type, id: expect.any(String) }); }); test(`adds audit event when not successful`, async () => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); await expect(() => client.create(type, attributes, { namespace })).rejects.toThrow(); expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type }); + expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type, id: expect.any(String) }); }); }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index e6e34de4ac9ab..765274a839efa 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -96,15 +96,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: T = {} as T, options: SavedObjectsCreateOptions = {} ) { - const namespaces = [options.namespace, ...(options.initialNamespaces || [])]; + const optionsWithId = { ...options, id: options.id ?? SavedObjectsUtils.generateId() }; + const namespaces = [optionsWithId.namespace, ...(optionsWithId.initialNamespaces || [])]; try { - const args = { type, attributes, options }; + const args = { type, attributes, options: optionsWithId }; await this.ensureAuthorized(type, 'create', namespaces, { args }); } catch (error) { this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.CREATE, - savedObject: { type, id: options.id }, + savedObject: { type, id: optionsWithId.id }, error, }) ); @@ -114,11 +115,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra savedObjectEvent({ action: SavedObjectAction.CREATE, outcome: EventOutcome.UNKNOWN, - savedObject: { type, id: options.id }, + savedObject: { type, id: optionsWithId.id }, }) ); - const savedObject = await this.baseClient.create(type, attributes, options); + const savedObject = await this.baseClient.create(type, attributes, optionsWithId); return await this.redactSavedObjectNamespaces(savedObject, namespaces); } @@ -141,17 +142,26 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: Array>, options: SavedObjectsBaseOptions = {} ) { - const namespaces = objects.reduce( + const objectsWithId = objects.map((obj) => ({ + ...obj, + id: obj.id ?? SavedObjectsUtils.generateId(), + })); + const namespaces = objectsWithId.reduce( (acc, { initialNamespaces = [] }) => acc.concat(initialNamespaces), [options.namespace] ); try { - const args = { objects, options }; - await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_create', namespaces, { - args, - }); + const args = { objects: objectsWithId, options }; + await this.ensureAuthorized( + this.getUniqueObjectTypes(objectsWithId), + 'bulk_create', + namespaces, + { + args, + } + ); } catch (error) { - objects.forEach(({ type, id }) => + objectsWithId.forEach(({ type, id }) => this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.CREATE, @@ -162,7 +172,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); throw error; } - objects.forEach(({ type, id }) => + objectsWithId.forEach(({ type, id }) => this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.CREATE, @@ -172,7 +182,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) ); - const response = await this.baseClient.bulkCreate(objects, options); + const response = await this.baseClient.bulkCreate(objectsWithId, options); return await this.redactSavedObjectsNamespaces(response, namespaces); } @@ -284,14 +294,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const response = await this.baseClient.bulkGet(objects, options); - objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.GET, - savedObject: { type, id }, - }) - ) - ); + response.saved_objects.forEach(({ error, type, id }) => { + if (!error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type, id }, + }) + ); + } + }); return await this.redactSavedObjectsNamespaces(response, [options.namespace]); } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index c47ec70341845..cc7e8df757c1d 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -194,5 +194,3 @@ export const showAllOthersBucket: string[] = [ 'destination.ip', 'user.name', ]; - -export const ENABLE_NEW_TIMELINE = false; diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts index b516f7c57a96d..1b70a13935b7d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts @@ -12,6 +12,7 @@ describe('7.11.0 Endpoint Package Policy migration', () => { const migration = migratePackagePolicyToV7110; it('adds malware notification checkbox and optional message and adds AV registration config', () => { const doc: SavedObjectUnsanitizedDoc = { + id: 'mock-saved-object-id', attributes: { name: 'Some Policy Name', package: { @@ -100,11 +101,13 @@ describe('7.11.0 Endpoint Package Policy migration', () => { ], }, type: ' nested', + id: 'mock-saved-object-id', }); }); it('does not modify non-endpoint package policies', () => { const doc: SavedObjectUnsanitizedDoc = { + id: 'mock-saved-object-id', attributes: { name: 'Some Policy Name', package: { @@ -164,6 +167,7 @@ describe('7.11.0 Endpoint Package Policy migration', () => { ], }, type: ' nested', + id: 'mock-saved-object-id', }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/cypress.json b/x-pack/plugins/security_solution/cypress/cypress.json index 364db54b4b5d9..d934afec127c2 100644 --- a/x-pack/plugins/security_solution/cypress/cypress.json +++ b/x-pack/plugins/security_solution/cypress/cypress.json @@ -8,5 +8,7 @@ "screenshotsFolder": "../../../target/kibana-security-solution/cypress/screenshots", "trashAssetsBeforeRuns": false, "video": false, - "videosFolder": "../../../target/kibana-security-solution/cypress/videos" + "videosFolder": "../../../target/kibana-security-solution/cypress/videos", + "viewportHeight": 900, + "viewportWidth": 1440 } diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts index 31d8e4666d91d..1cece57c2fea5 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts @@ -35,7 +35,7 @@ describe('Alerts timeline', () => { .invoke('text') .then((eventId) => { investigateFirstAlertInTimeline(); - cy.get(PROVIDER_BADGE).should('have.text', `_id: "${eventId}"`); + cy.get(PROVIDER_BADGE).filter(':visible').should('have.text', `_id: "${eventId}"`); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts index b32402851ac7c..6716186cddd45 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts @@ -8,10 +8,10 @@ import { case1 } from '../objects/case'; import { ALL_CASES_CLOSE_ACTION, - ALL_CASES_CLOSED_CASES_COUNT, ALL_CASES_CLOSED_CASES_STATS, ALL_CASES_COMMENTS_COUNT, ALL_CASES_DELETE_ACTION, + ALL_CASES_IN_PROGRESS_CASES_STATS, ALL_CASES_NAME, ALL_CASES_OPEN_CASES_COUNT, ALL_CASES_OPEN_CASES_STATS, @@ -70,8 +70,8 @@ describe('Cases', () => { cy.get(ALL_CASES_PAGE_TITLE).should('have.text', 'Cases'); cy.get(ALL_CASES_OPEN_CASES_STATS).should('have.text', 'Open cases1'); cy.get(ALL_CASES_CLOSED_CASES_STATS).should('have.text', 'Closed cases0'); - cy.get(ALL_CASES_OPEN_CASES_COUNT).should('have.text', 'Open cases (1)'); - cy.get(ALL_CASES_CLOSED_CASES_COUNT).should('have.text', 'Closed cases (0)'); + cy.get(ALL_CASES_IN_PROGRESS_CASES_STATS).should('have.text', 'In progress cases0'); + cy.get(ALL_CASES_OPEN_CASES_COUNT).should('have.text', 'Open (1)'); cy.get(ALL_CASES_REPORTERS_COUNT).should('have.text', 'Reporter1'); cy.get(ALL_CASES_TAGS_COUNT).should('have.text', 'Tags2'); cy.get(ALL_CASES_NAME).should('have.text', case1.name); @@ -89,7 +89,7 @@ describe('Cases', () => { const expectedTags = case1.tags.join(''); cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', case1.name); - cy.get(CASE_DETAILS_STATUS).should('have.text', 'open'); + cy.get(CASE_DETAILS_STATUS).should('have.text', 'Open'); cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', case1.reporter); cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description'); cy.get(CASE_DETAILS_DESCRIPTION).should( @@ -103,8 +103,8 @@ describe('Cases', () => { openCaseTimeline(); - cy.get(TIMELINE_TITLE).should('have.attr', 'value', case1.timeline.title); - cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', case1.timeline.description); + cy.get(TIMELINE_TITLE).contains(case1.timeline.title); + cy.get(TIMELINE_DESCRIPTION).contains(case1.timeline.description); cy.get(TIMELINE_QUERY).invoke('text').should('eq', case1.timeline.query); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts index c19e51c3ada40..b84b668a28502 100644 --- a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts @@ -13,11 +13,7 @@ import { import { closesModal, openStatsAndTables } from '../tasks/inspect'; import { loginAndWaitForPage } from '../tasks/login'; import { openTimelineUsingToggle } from '../tasks/security_main'; -import { - executeTimelineKQL, - openTimelineInspectButton, - openTimelineSettings, -} from '../tasks/timeline'; +import { executeTimelineKQL, openTimelineInspectButton } from '../tasks/timeline'; import { HOSTS_URL, NETWORK_URL } from '../urls/navigation'; @@ -60,7 +56,6 @@ describe('Inspect', () => { loginAndWaitForPage(HOSTS_URL); openTimelineUsingToggle(); executeTimelineKQL(hostExistsQuery); - openTimelineSettings(); openTimelineInspectButton(); cy.get(INSPECT_MODAL).should('be.visible'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts index f62db083172a4..8bfb6eba3e1fd 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts @@ -24,23 +24,20 @@ import { createNewTimeline } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; -describe('timeline data providers', () => { +// FLAKY: https://github.com/elastic/kibana/issues/62060 +describe.skip('timeline data providers', () => { before(() => { loginAndWaitForPage(HOSTS_URL); waitForAllHostsToBeLoaded(); }); - beforeEach(() => { - openTimelineUsingToggle(); - }); - afterEach(() => { createNewTimeline(); }); it('renders the data provider of a host dragged from the All Hosts widget on the hosts page', () => { dragAndDropFirstHostToTimeline(); - + openTimelineUsingToggle(); cy.get(TIMELINE_DROPPED_DATA_PROVIDERS) .first() .invoke('text') @@ -57,26 +54,28 @@ describe('timeline data providers', () => { it('sets the background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers', () => { dragFirstHostToTimeline(); - cy.get(TIMELINE_DATA_PROVIDERS).should( - 'have.css', - 'background', - 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' - ); + cy.get(TIMELINE_DATA_PROVIDERS) + .filter(':visible') + .should( + 'have.css', + 'background', + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + ); }); it('sets the background to euiColorSuccess with a 20% alpha channel and renders the dashed border color as euiColorSuccess when the user starts dragging a host AND is hovering over the data providers', () => { dragFirstHostToEmptyTimelineDataProviders(); - cy.get(TIMELINE_DATA_PROVIDERS_EMPTY).should( - 'have.css', - 'background', - 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' - ); + cy.get(TIMELINE_DATA_PROVIDERS_EMPTY) + .filter(':visible') + .should( + 'have.css', + 'background', + 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' + ); - cy.get(TIMELINE_DATA_PROVIDERS).should( - 'have.css', - 'border', - '3.1875px dashed rgb(1, 125, 115)' - ); + cy.get(TIMELINE_DATA_PROVIDERS) + .filter(':visible') + .should('have.css', 'border', '3.1875px dashed rgb(1, 125, 115)'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts index 9b3434b5521d4..33e8cc40b1239 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TIMELINE_FLYOUT_HEADER, TIMELINE_NOT_READY_TO_DROP_BUTTON } from '../screens/timeline'; +import { TIMELINE_FLYOUT_HEADER, TIMELINE_DATA_PROVIDERS } from '../screens/timeline'; import { dragFirstHostToTimeline, waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimelineUsingToggle, openTimelineIfClosed } from '../tasks/security_main'; -import { createNewTimeline } from '../tasks/timeline'; +import { openTimelineUsingToggle, closeTimelineUsingToggle } from '../tasks/security_main'; import { HOSTS_URL } from '../urls/navigation'; @@ -19,23 +18,21 @@ describe('timeline flyout button', () => { waitForAllHostsToBeLoaded(); }); - afterEach(() => { - openTimelineIfClosed(); - createNewTimeline(); - }); - it('toggles open the timeline', () => { openTimelineUsingToggle(); cy.get(TIMELINE_FLYOUT_HEADER).should('have.css', 'visibility', 'visible'); + closeTimelineUsingToggle(); }); - it('sets the flyout button background to euiColorSuccess with a 20% alpha channel when the user starts dragging a host, but is not hovering over the flyout button', () => { + it('sets the data providers background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers area', () => { dragFirstHostToTimeline(); - cy.get(TIMELINE_NOT_READY_TO_DROP_BUTTON).should( - 'have.css', - 'background', - 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' - ); + cy.get(TIMELINE_DATA_PROVIDERS) + .filter(':visible') + .should( + 'have.css', + 'background', + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + ); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts index 8dcb5e144c24f..bf8a01f6cf072 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts @@ -10,7 +10,6 @@ import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; import { timeline as timelineTemplate } from '../objects/timeline'; import { TIMELINE_TEMPLATES_URL } from '../urls/navigation'; -import { openTimelineUsingToggle } from '../tasks/security_main'; import { addNameToTimeline, closeTimeline, createNewTimelineTemplate } from '../tasks/timeline'; describe('Export timelines', () => { @@ -23,7 +22,6 @@ describe('Export timelines', () => { it('Exports a custom timeline template', async () => { loginAndWaitForPageWithoutDateRange(TIMELINE_TEMPLATES_URL); - openTimelineUsingToggle(); createNewTimelineTemplate(); addNameToTimeline(timelineTemplate.title); closeTimeline(); diff --git a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts index 906fba28a7721..3a941209de736 100644 --- a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts @@ -228,6 +228,7 @@ describe('url state', () => { cy.server(); cy.route('PATCH', '**/api/timeline').as('timeline'); + waitForTimelineChanges(); addNameToTimeline(timeline.title); waitForTimelineChanges(); @@ -242,7 +243,7 @@ describe('url state', () => { cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).should('not.have.text', 'Updating'); cy.get(TIMELINE).should('be.visible'); cy.get(TIMELINE_TITLE).should('be.visible'); - cy.get(TIMELINE_TITLE).should('have.attr', 'value', timeline.title); + cy.get(TIMELINE_TITLE).should('have.text', timeline.title); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts index dc0e764744f84..1b801f6a45459 100644 --- a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts +++ b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts @@ -10,8 +10,6 @@ export const ALL_CASES_CASE = (id: string) => { export const ALL_CASES_CLOSE_ACTION = '[data-test-subj="action-close"]'; -export const ALL_CASES_CLOSED_CASES_COUNT = '[data-test-subj="closed-case-count"]'; - export const ALL_CASES_CLOSED_CASES_STATS = '[data-test-subj="closedStatsHeader"]'; export const ALL_CASES_COMMENTS_COUNT = '[data-test-subj="case-table-column-commentCount"]'; @@ -22,9 +20,11 @@ export const ALL_CASES_CREATE_NEW_CASE_TABLE_BTN = '[data-test-subj="cases-table export const ALL_CASES_DELETE_ACTION = '[data-test-subj="action-delete"]'; +export const ALL_CASES_IN_PROGRESS_CASES_STATS = '[data-test-subj="inProgressStatsHeader"]'; + export const ALL_CASES_NAME = '[data-test-subj="case-details-link"]'; -export const ALL_CASES_OPEN_CASES_COUNT = '[data-test-subj="open-case-count"]'; +export const ALL_CASES_OPEN_CASES_COUNT = '[data-test-subj="case-status-filter"]'; export const ALL_CASES_OPEN_CASES_STATS = '[data-test-subj="openStatsHeader"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index 02ec74aaed29c..e9a258c70cb23 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -14,7 +14,7 @@ export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; export const CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN = '[data-test-subj="push-to-external-service"]'; -export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]'; +export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status-dropdown"]'; export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/date_picker.ts b/x-pack/plugins/security_solution/cypress/screens/date_picker.ts index e49f5afa7bd0c..967a56fc6f63d 100644 --- a/x-pack/plugins/security_solution/cypress/screens/date_picker.ts +++ b/x-pack/plugins/security_solution/cypress/screens/date_picker.ts @@ -10,7 +10,7 @@ export const DATE_PICKER_APPLY_BUTTON = '[data-test-subj="globalDatePicker"] button[data-test-subj="querySubmitButton"]'; export const DATE_PICKER_APPLY_BUTTON_TIMELINE = - '[data-test-subj="timeline-properties"] button[data-test-subj="superDatePickerApplyTimeButton"]'; + '[data-test-subj="timeline-date-picker-container"] button[data-test-subj="superDatePickerApplyTimeButton"]'; export const DATE_PICKER_ABSOLUTE_TAB = '[data-test-subj="superDatePickerAbsoluteTab"]'; @@ -18,10 +18,10 @@ export const DATE_PICKER_END_DATE_POPOVER_BUTTON = '[data-test-subj="globalDatePicker"] [data-test-subj="superDatePickerendDatePopoverButton"]'; export const DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE = - '[data-test-subj="timeline-properties"] [data-test-subj="superDatePickerendDatePopoverButton"]'; + '[data-test-subj="timeline-date-picker-container"] [data-test-subj="superDatePickerendDatePopoverButton"]'; export const DATE_PICKER_START_DATE_POPOVER_BUTTON = 'div[data-test-subj="globalDatePicker"] button[data-test-subj="superDatePickerstartDatePopoverButton"]'; export const DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE = - '[data-test-subj="timeline-properties"] [data-test-subj="superDatePickerstartDatePopoverButton"]'; + '[data-test-subj="timeline-date-picker-container"] [data-test-subj="superDatePickerstartDatePopoverButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/security_main.ts b/x-pack/plugins/security_solution/cypress/screens/security_main.ts index d4eeeb036ee95..c6c1067825f16 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_main.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_main.ts @@ -7,3 +7,5 @@ export const MAIN_PAGE = '[data-test-subj="kibanaChrome"]'; export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="flyoutOverlay"]'; + +export const TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON = `[data-test-subj="flyoutBottomBar"] ${TIMELINE_TOGGLE_BUTTON}`; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 98e6502ffe94f..ea0e132bf07b5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -10,7 +10,9 @@ export const ADD_NOTE_BUTTON = '[data-test-subj="add-note"]'; export const ADD_FILTER = '[data-test-subj="timeline"] [data-test-subj="addFilter"]'; -export const ATTACH_TIMELINE_TO_NEW_CASE_ICON = '[data-test-subj="attach-timeline-case"]'; +export const ATTACH_TIMELINE_TO_CASE_BUTTON = '[data-test-subj="attach-timeline-case-button"]'; + +export const ATTACH_TIMELINE_TO_NEW_CASE_ICON = '[data-test-subj="attach-timeline-new-case"]'; export const ATTACH_TIMELINE_TO_EXISTING_CASE_ICON = '[data-test-subj="attach-timeline-existing-case"]'; @@ -90,6 +92,8 @@ export const TIMELINE_DATA_PROVIDERS_EMPTY = export const TIMELINE_DESCRIPTION = '[data-test-subj="timeline-description"]'; +export const TIMELINE_DESCRIPTION_INPUT = '[data-test-subj="timeline-description-input"]'; + export const TIMELINE_DROPPED_DATA_PROVIDERS = '[data-test-subj="providerContainer"]'; export const TIMELINE_FIELDS_BUTTON = @@ -108,23 +112,28 @@ export const TIMELINE_FILTER_OPERATOR = '[data-test-subj="filterOperatorList"]'; export const TIMELINE_FILTER_VALUE = '[data-test-subj="filterParamsComboBox phraseParamsComboxBox"]'; +export const TIMELINE_FLYOUT = '[data-test-subj="eui-flyout"]'; + export const TIMELINE_FLYOUT_HEADER = '[data-test-subj="eui-flyout-header"]'; export const TIMELINE_FLYOUT_BODY = '[data-test-subj="eui-flyout-body"]'; -export const TIMELINE_INSPECT_BUTTON = '[data-test-subj="inspect-empty-button"]'; - -export const TIMELINE_NOT_READY_TO_DROP_BUTTON = - '[data-test-subj="flyout-button-not-ready-to-drop"]'; +export const TIMELINE_INSPECT_BUTTON = `${TIMELINE_FLYOUT} [data-test-subj="inspect-icon-button"]`; export const TIMELINE_QUERY = '[data-test-subj="timelineQueryInput"]'; -export const TIMELINE_SETTINGS_ICON = '[data-test-subj="settings-gear"]'; +export const TIMELINE_SETTINGS_ICON = '[data-test-subj="settings-plus-in-circle"]'; export const TIMELINE_TITLE = '[data-test-subj="timeline-title"]'; +export const TIMELINE_TITLE_INPUT = '[data-test-subj="timeline-title-input"]'; + export const TIMESTAMP_HEADER_FIELD = '[data-test-subj="header-text-@timestamp"]'; export const TIMESTAMP_TOGGLE_FIELD = '[data-test-subj="toggle-field-@timestamp"]'; export const TOGGLE_TIMELINE_EXPAND_EVENT = '[data-test-subj="expand-event"]'; + +export const TIMELINE_EDIT_MODAL_OPEN_BUTTON = '[data-test-subj="save-timeline-button-icon"]'; + +export const TIMELINE_EDIT_MODAL_SAVE_BUTTON = '[data-test-subj="save-button"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts b/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts index 27d17f966d8fc..c52ca0b968c37 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts @@ -13,7 +13,9 @@ export const dragAndDropFirstHostToTimeline = () => { cy.get(HOSTS_NAMES_DRAGGABLE) .first() .then((firstHost) => drag(firstHost)); - cy.get(TIMELINE_DATA_PROVIDERS).then((dataProvidersDropArea) => drop(dataProvidersDropArea)); + cy.get(TIMELINE_DATA_PROVIDERS) + .filter(':visible') + .then((dataProvidersDropArea) => drop(dataProvidersDropArea)); }; export const dragFirstHostToEmptyTimelineDataProviders = () => { @@ -21,9 +23,9 @@ export const dragFirstHostToEmptyTimelineDataProviders = () => { .first() .then((host) => drag(host)); - cy.get(TIMELINE_DATA_PROVIDERS_EMPTY).then((dataProvidersDropArea) => - dragWithoutDrop(dataProvidersDropArea) - ); + cy.get(TIMELINE_DATA_PROVIDERS_EMPTY) + .filter(':visible') + .then((dataProvidersDropArea) => dragWithoutDrop(dataProvidersDropArea)); }; export const dragFirstHostToTimeline = () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index 9f385d9ccd2fc..d927ac5cd9d2b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -219,7 +219,6 @@ const loginViaConfig = () => { */ export const loginAndWaitForPage = (url: string, role?: RolesType) => { login(role); - cy.viewport('macbook-15'); cy.visit( `${url}?timerange=(global:(linkTo:!(timeline),timerange:(from:1547914976217,fromStr:'2019-01-19T16:22:56.217Z',kind:relative,to:1579537385745,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1547914976217,fromStr:'2019-01-19T16:22:56.217Z',kind:relative,to:1579537385745,toStr:now)))` ); @@ -228,7 +227,6 @@ export const loginAndWaitForPage = (url: string, role?: RolesType) => { export const loginAndWaitForPageWithoutDateRange = (url: string, role?: RolesType) => { login(role); - cy.viewport('macbook-15'); cy.visit(role ? getUrlWithRoute(role, url) : url); cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 }); }; @@ -237,7 +235,6 @@ export const loginAndWaitForTimeline = (timelineId: string, role?: RolesType) => const route = `/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t)`; login(role); - cy.viewport('macbook-15'); cy.visit(role ? getUrlWithRoute(role, route) : route); cy.get('[data-test-subj="headerGlobalNav"]'); cy.get(TIMELINE_FLYOUT_BODY).should('be.visible'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts index dd01159e3029f..eb03c56ef04e8 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts @@ -4,15 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MAIN_PAGE, TIMELINE_TOGGLE_BUTTON } from '../screens/security_main'; +import { + MAIN_PAGE, + TIMELINE_TOGGLE_BUTTON, + TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON, +} from '../screens/security_main'; export const openTimelineUsingToggle = () => { - cy.get(TIMELINE_TOGGLE_BUTTON).click(); + cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).click(); +}; + +export const closeTimelineUsingToggle = () => { + cy.get(TIMELINE_TOGGLE_BUTTON).filter(':visible').click(); }; export const openTimelineIfClosed = () => cy.get(MAIN_PAGE).then(($page) => { - if ($page.find(TIMELINE_TOGGLE_BUTTON).length === 1) { + if ($page.find(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).length === 1) { openTimelineUsingToggle(); } }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index b101793385488..10a2ff27666c0 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -11,6 +11,7 @@ import { ALL_CASES_CREATE_NEW_CASE_TABLE_BTN } from '../screens/all_cases'; import { ADD_FILTER, ADD_NOTE_BUTTON, + ATTACH_TIMELINE_TO_CASE_BUTTON, ATTACH_TIMELINE_TO_EXISTING_CASE_ICON, ATTACH_TIMELINE_TO_NEW_CASE_ICON, CASE, @@ -40,12 +41,14 @@ import { TIMELINE_FILTER_VALUE, TIMELINE_INSPECT_BUTTON, TIMELINE_SETTINGS_ICON, - TIMELINE_TITLE, + TIMELINE_TITLE_INPUT, TIMELINE_TITLE_BY_ID, TIMESTAMP_TOGGLE_FIELD, TOGGLE_TIMELINE_EXPAND_EVENT, CREATE_NEW_TIMELINE_TEMPLATE, OPEN_TIMELINE_TEMPLATE_ICON, + TIMELINE_EDIT_MODAL_OPEN_BUTTON, + TIMELINE_EDIT_MODAL_SAVE_BUTTON, } from '../screens/timeline'; import { TIMELINES_TABLE } from '../screens/timelines'; @@ -59,8 +62,10 @@ export const addDescriptionToTimeline = (description: string) => { }; export const addNameToTimeline = (name: string) => { - cy.get(TIMELINE_TITLE).type(`${name}{enter}`); - cy.get(TIMELINE_TITLE).should('have.attr', 'value', name); + cy.get(TIMELINE_EDIT_MODAL_OPEN_BUTTON).first().click(); + cy.get(TIMELINE_TITLE_INPUT).type(`${name}{enter}`); + cy.get(TIMELINE_TITLE_INPUT).should('have.attr', 'value', name); + cy.get(TIMELINE_EDIT_MODAL_SAVE_BUTTON).click(); }; export const addNotesToTimeline = (notes: string) => { @@ -85,12 +90,12 @@ export const addNewCase = () => { }; export const attachTimelineToNewCase = () => { - cy.get(TIMELINE_SETTINGS_ICON).click({ force: true }); + cy.get(ATTACH_TIMELINE_TO_CASE_BUTTON).click({ force: true }); cy.get(ATTACH_TIMELINE_TO_NEW_CASE_ICON).click({ force: true }); }; export const attachTimelineToExistingCase = () => { - cy.get(TIMELINE_SETTINGS_ICON).click({ force: true }); + cy.get(ATTACH_TIMELINE_TO_CASE_BUTTON).click({ force: true }); cy.get(ATTACH_TIMELINE_TO_EXISTING_CASE_ICON).click({ force: true }); }; @@ -107,17 +112,18 @@ export const closeNotes = () => { }; export const closeTimeline = () => { - cy.get(CLOSE_TIMELINE_BTN).click({ force: true }); + cy.get(CLOSE_TIMELINE_BTN).filter(':visible').click({ force: true }); }; export const createNewTimeline = () => { - cy.get(TIMELINE_SETTINGS_ICON).click({ force: true }); + cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click({ force: true }); + cy.get(CREATE_NEW_TIMELINE).should('be.visible'); cy.get(CREATE_NEW_TIMELINE).click(); - cy.get(CLOSE_TIMELINE_BTN).click({ force: true }); + cy.get(CLOSE_TIMELINE_BTN).filter(':visible').click({ force: true }); }; export const createNewTimelineTemplate = () => { - cy.get(TIMELINE_SETTINGS_ICON).click({ force: true }); + cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click({ force: true }); cy.get(CREATE_NEW_TIMELINE_TEMPLATE).click(); }; @@ -153,10 +159,6 @@ export const openTimelineTemplateFromSettings = (id: string) => { cy.get(TIMELINE_TITLE_BY_ID(id)).click({ force: true }); }; -export const openTimelineSettings = () => { - cy.get(TIMELINE_SETTINGS_ICON).trigger('click', { force: true }); -}; - export const pinFirstEvent = () => { cy.get(PIN_EVENT).first().click({ force: true }); }; diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index 6573457c5f39a..3b64c1f7f1f65 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -37,8 +37,6 @@ const Main = styled.main.attrs<{ paddingTop: number }>(({ paddingTop }) => ({ Main.displayName = 'Main'; -const usersViewing = ['elastic']; // TODO: get the users viewing this timeline from Elasticsearch (persistance) - interface HomePageProps { children: React.ReactNode; } @@ -89,7 +87,7 @@ const HomePageComponent: React.FC = ({ children }) => { {indicesExist && showTimeline && ( <> - + )} diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx index 9f7e2e73c5bbc..96d118fea1f55 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/actions.tsx @@ -3,12 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; + import { Dispatch } from 'react'; -import { Case } from '../../containers/types'; +import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; -import * as i18n from './translations'; +import { CaseStatuses } from '../../../../../case/common/api'; +import { Case } from '../../containers/types'; import { UpdateCase } from '../../containers/use_get_cases'; +import * as i18n from './translations'; interface GetActions { caseStatus: string; @@ -29,7 +31,7 @@ export const getActions = ({ type: 'icon', 'data-test-subj': 'action-delete', }, - caseStatus === 'open' + caseStatus === CaseStatuses.open ? { description: i18n.CLOSE_CASE, icon: 'folderCheck', @@ -37,7 +39,7 @@ export const getActions = ({ onClick: (theCase: Case) => dispatchUpdate({ updateKey: 'status', - updateValue: 'closed', + updateValue: CaseStatuses.closed, caseId: theCase.id, version: theCase.version, }), @@ -51,7 +53,7 @@ export const getActions = ({ onClick: (theCase: Case) => dispatchUpdate({ updateKey: 'status', - updateValue: 'open', + updateValue: CaseStatuses.open, caseId: theCase.id, version: theCase.version, }), diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx index 42b97d5f6130f..00873a497c934 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import React, { useCallback } from 'react'; import { EuiAvatar, @@ -16,6 +17,8 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; + +import { CaseStatuses } from '../../../../../case/common/api'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { Case } from '../../containers/types'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; @@ -59,7 +62,7 @@ export const getCasesColumns = ( ) : ( {theCase.title} ); - return theCase.status === 'open' ? ( + return theCase.status !== CaseStatuses.closed ? ( caseDetailsLinkComponent ) : ( <> @@ -127,7 +130,7 @@ export const getCasesColumns = ( ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) : getEmptyTagValue(), }, - filterStatus === 'open' + filterStatus === CaseStatuses.open ? { field: 'createdAt', name: i18n.OPENED_ON, diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index e301e80c9561d..9ea39f5ca99b9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -14,6 +14,7 @@ import { TestProviders } from '../../../common/mock'; import { useGetCasesMockState } from '../../containers/mock'; import * as i18n from './translations'; +import { CaseStatuses } from '../../../../../case/common/api'; import { useKibana } from '../../../common/lib/kibana'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; @@ -159,7 +160,7 @@ describe('AllCases', () => { expect(column.find('span').text()).toEqual(emptyTag); }; await waitFor(() => { - getCasesColumns([], 'open', false).map( + getCasesColumns([], CaseStatuses.open, false).map( (i, key) => i.name != null && checkIt(`${i.name}`, key) ); }); @@ -175,7 +176,9 @@ describe('AllCases', () => { const checkIt = (columnName: string) => { expect(columnName).not.toEqual(i18n.ACTIONS); }; - getCasesColumns([], 'open', true).map((i, key) => i.name != null && checkIt(`${i.name}`)); + getCasesColumns([], CaseStatuses.open, true).map( + (i, key) => i.name != null && checkIt(`${i.name}`) + ); expect(wrapper.find(`a[data-test-subj="case-details-link"]`).exists()).toBeFalsy(); }); }); @@ -208,7 +211,7 @@ describe('AllCases', () => { expect(dispatchUpdateCaseProperty).toBeCalledWith({ caseId: firstCase.id, updateKey: 'status', - updateValue: 'closed', + updateValue: CaseStatuses.closed, refetchCasesStatus: fetchCasesStatus, version: firstCase.version, }); @@ -217,7 +220,7 @@ describe('AllCases', () => { it('opens case when row action icon clicked', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: 'closed' }, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed }, }); const wrapper = mount( @@ -231,7 +234,7 @@ describe('AllCases', () => { expect(dispatchUpdateCaseProperty).toBeCalledWith({ caseId: firstCase.id, updateKey: 'status', - updateValue: 'open', + updateValue: CaseStatuses.open, refetchCasesStatus: fetchCasesStatus, version: firstCase.version, }); @@ -288,7 +291,7 @@ describe('AllCases', () => { await waitFor(() => { wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); wrapper.find('[data-test-subj="cases-bulk-close-button"]').first().simulate('click'); - expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed'); + expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.closed); }); }); it('Bulk open status update', async () => { @@ -297,7 +300,7 @@ describe('AllCases', () => { selectedCases: useGetCasesMockState.data.cases, filterOptions: { ...defaultGetCases.filterOptions, - status: 'closed', + status: CaseStatuses.closed, }, }); @@ -309,7 +312,7 @@ describe('AllCases', () => { await waitFor(() => { wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); wrapper.find('[data-test-subj="cases-bulk-open-button"]').first().simulate('click'); - expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open'); + expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.open); }); }); it('isDeleted is true, refetch', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index 42a87de2aa07b..05bc6d10d22a5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -19,6 +19,7 @@ import { isEmpty, memoize } from 'lodash/fp'; import styled, { css } from 'styled-components'; import * as i18n from './translations'; +import { CaseStatuses } from '../../../../../case/common/api'; import { getCasesColumns } from './columns'; import { Case, DeleteCase, FilterOptions, SortFieldCase } from '../../containers/types'; import { useGetCases, UpdateCase } from '../../containers/use_get_cases'; @@ -37,7 +38,6 @@ import { getCreateCaseUrl, useFormatUrl } from '../../../common/components/link_ import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; -import { OpenClosedStats } from '../open_closed_stats'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; import { useUpdateCases } from '../../containers/use_bulk_update_case'; @@ -50,6 +50,7 @@ import { LinkButton } from '../../../common/components/links'; import { SecurityPageName } from '../../../app/types'; import { useKibana } from '../../../common/lib/kibana'; import { APP_ID } from '../../../../common/constants'; +import { Stats } from '../status'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -91,8 +92,9 @@ export const AllCases = React.memo( const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); const { actionLicense } = useGetActionLicense(); const { - countClosedCases, countOpenCases, + countInProgressCases, + countClosedCases, isLoading: isCasesStatusLoading, fetchCasesStatus, } = useGetCasesStatus(); @@ -291,10 +293,15 @@ export const AllCases = React.memo( const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { - if (newFilterOptions.status && newFilterOptions.status === 'closed') { + if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.closed) { setQueryParams({ sortField: SortFieldCase.closedAt }); - } else if (newFilterOptions.status && newFilterOptions.status === 'open') { + } else if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.open) { setQueryParams({ sortField: SortFieldCase.createdAt }); + } else if ( + newFilterOptions.status && + newFilterOptions.status === CaseStatuses['in-progress'] + ) { + setQueryParams({ sortField: SortFieldCase.updatedAt }); } setFilters(newFilterOptions); refreshCases(false); @@ -375,18 +382,26 @@ export const AllCases = React.memo( data-test-subj="all-cases-header" > - + + + - @@ -422,6 +437,7 @@ export const AllCases = React.memo( ; + selectedStatus: CaseStatuses; + onStatusChanged: (status: CaseStatuses) => void; +} + +const StatusFilterComponent: React.FC = ({ stats, selectedStatus, onStatusChanged }) => { + const caseStatuses = Object.keys(statuses) as CaseStatuses[]; + const options: Array> = caseStatuses.map((status) => ({ + value: status, + inputDisplay: ( + + + + + {` (${stats[status]})`} + + ), + 'data-test-subj': `case-status-filter-${status}`, + })); + + return ( + + ); +}; + +export const StatusFilter = memo(StatusFilterComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx index 9b516f600e9e5..0c9a725f918e5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.test.tsx @@ -7,12 +7,13 @@ import React from 'react'; import { mount } from 'enzyme'; +import { CaseStatuses } from '../../../../../case/common/api'; import { CasesTableFilters } from './table_filters'; import { TestProviders } from '../../../common/mock'; - import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases'; + jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); @@ -24,10 +25,12 @@ const setFilterRefetch = jest.fn(); const props = { countClosedCases: 1234, countOpenCases: 99, + countInProgressCases: 54, onFilterChanged, initial: DEFAULT_FILTER_OPTIONS, setFilterRefetch, }; + describe('CasesTableFilters ', () => { beforeEach(() => { jest.resetAllMocks(); @@ -40,19 +43,17 @@ describe('CasesTableFilters ', () => { fetchReporters, }); }); - it('should render the initial case count', () => { + + it('should render the case status filter dropdown', () => { const wrapper = mount( ); - expect(wrapper.find(`[data-test-subj="open-case-count"]`).last().text()).toEqual( - 'Open cases (99)' - ); - expect(wrapper.find(`[data-test-subj="closed-case-count"]`).last().text()).toEqual( - 'Closed cases (1234)' - ); + + expect(wrapper.find(`[data-test-subj="case-status-filter"]`).first().exists()).toBeTruthy(); }); + it('should call onFilterChange when selected tags change', () => { const wrapper = mount( @@ -64,6 +65,7 @@ describe('CasesTableFilters ', () => { expect(onFilterChanged).toBeCalledWith({ tags: ['coke'] }); }); + it('should call onFilterChange when selected reporters change', () => { const wrapper = mount( @@ -79,6 +81,7 @@ describe('CasesTableFilters ', () => { expect(onFilterChanged).toBeCalledWith({ reporters: [{ username: 'casetester' }] }); }); + it('should call onFilterChange when search changes', () => { const wrapper = mount( @@ -92,16 +95,19 @@ describe('CasesTableFilters ', () => { .simulate('keyup', { key: 'Enter', target: { value: 'My search' } }); expect(onFilterChanged).toBeCalledWith({ search: 'My search' }); }); - it('should call onFilterChange when status toggled', () => { + + it('should call onFilterChange when changing status', () => { const wrapper = mount( ); - wrapper.find(`[data-test-subj="closed-case-count"]`).last().simulate('click'); - expect(onFilterChanged).toBeCalledWith({ status: 'closed' }); + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click'); + expect(onFilterChanged).toBeCalledWith({ status: CaseStatuses.closed }); }); + it('should call on load setFilterRefetch', () => { mount( @@ -110,6 +116,7 @@ describe('CasesTableFilters ', () => { ); expect(setFilterRefetch).toHaveBeenCalled(); }); + it('should remove tag from selected tags when tag no longer exists', () => { const ourProps = { ...props, @@ -125,6 +132,7 @@ describe('CasesTableFilters ', () => { ); expect(onFilterChanged).toHaveBeenCalledWith({ tags: ['pepsi'] }); }); + it('should remove reporter from selected reporters when reporter no longer exists', () => { const ourProps = { ...props, diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx index 63172bd6ad6bb..f5ec0bf144154 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx @@ -4,24 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { isEqual } from 'lodash/fp'; -import { - EuiFieldSearch, - EuiFilterButton, - EuiFilterGroup, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import * as i18n from './translations'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup } from '@elastic/eui'; +import { CaseStatuses } from '../../../../../case/common/api'; import { FilterOptions } from '../../containers/types'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; import { FilterPopover } from '../filter_popover'; +import { StatusFilter } from './status_filter'; +import * as i18n from './translations'; interface CasesTableFiltersProps { countClosedCases: number | null; + countInProgressCases: number | null; countOpenCases: number | null; onFilterChanged: (filterOptions: Partial) => void; initial: FilterOptions; @@ -35,11 +32,12 @@ interface CasesTableFiltersProps { * @param onFilterChanged change listener to be notified on filter changes */ -const defaultInitial = { search: '', reporters: [], status: 'open', tags: [] }; +const defaultInitial = { search: '', reporters: [], status: CaseStatuses.open, tags: [] }; const CasesTableFiltersComponent = ({ countClosedCases, countOpenCases, + countInProgressCases, onFilterChanged, initial = defaultInitial, setFilterRefetch, @@ -49,18 +47,20 @@ const CasesTableFiltersComponent = ({ ); const [search, setSearch] = useState(initial.search); const [selectedTags, setSelectedTags] = useState(initial.tags); - const [showOpenCases, setShowOpenCases] = useState(initial.status === 'open'); const { tags, fetchTags } = useGetTags(); const { reporters, respReporters, fetchReporters } = useGetReporters(); + const refetch = useCallback(() => { fetchTags(); fetchReporters(); }, [fetchReporters, fetchTags]); + useEffect(() => { if (setFilterRefetch != null) { setFilterRefetch(refetch); } }, [refetch, setFilterRefetch]); + useEffect(() => { if (selectedReporters.length) { const newReporters = selectedReporters.filter((r) => reporters.includes(r)); @@ -68,6 +68,7 @@ const CasesTableFiltersComponent = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [reporters]); + useEffect(() => { if (selectedTags.length) { const newTags = selectedTags.filter((t) => tags.includes(t)); @@ -100,6 +101,7 @@ const CasesTableFiltersComponent = ({ // eslint-disable-next-line react-hooks/exhaustive-deps [selectedTags] ); + const handleOnSearch = useCallback( (newSearch) => { const trimSearch = newSearch.trim(); @@ -111,19 +113,26 @@ const CasesTableFiltersComponent = ({ // eslint-disable-next-line react-hooks/exhaustive-deps [search] ); - const handleToggleFilter = useCallback( - (showOpen) => { - if (showOpen !== showOpenCases) { - setShowOpenCases(showOpen); - onFilterChanged({ status: showOpen ? 'open' : 'closed' }); - } + + const onStatusChanged = useCallback( + (status: CaseStatuses) => { + onFilterChanged({ status }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [showOpenCases] + [onFilterChanged] + ); + + const stats = useMemo( + () => ({ + [CaseStatuses.open]: countOpenCases ?? 0, + [CaseStatuses['in-progress']]: countInProgressCases ?? 0, + [CaseStatuses.closed]: countClosedCases ?? 0, + }), + [countClosedCases, countInProgressCases, countOpenCases] ); + return ( - + - + + + - - {i18n.OPEN_CASES} - {countOpenCases != null ? ` (${countOpenCases})` : ''} - - - {i18n.CLOSED_CASES} - {countClosedCases != null ? ` (${countClosedCases})` : ''} - { return [ - caseStatus === 'open' ? ( + caseStatus === CaseStatuses.open ? ( { closePopover(); - updateCaseStatus('closed'); + updateCaseStatus(CaseStatuses.closed); }} > {i18n.BULK_ACTION_CLOSE_SELECTED} @@ -45,7 +47,7 @@ export const getBulkItems = ({ icon="folderExclamation" onClick={() => { closePopover(); - updateCaseStatus('open'); + updateCaseStatus(CaseStatuses.open); }} > {i18n.BULK_ACTION_OPEN_SELECTED} diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts new file mode 100644 index 0000000000000..29c9e67c5b569 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CaseStatuses } from '../../../../../case/common/api'; +import { Case } from '../../containers/types'; +import { statuses } from '../status'; + +export const getStatusDate = (theCase: Case): string | null => { + if (theCase.status === CaseStatuses.open) { + return theCase.createdAt; + } else if (theCase.status === CaseStatuses['in-progress']) { + return theCase.updatedAt; + } else if (theCase.status === CaseStatuses.closed) { + return theCase.closedAt; + } + + return null; +}; + +export const getStatusTitle = (status: CaseStatuses) => statuses[status].actionBar.title; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_status/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx similarity index 65% rename from x-pack/plugins/security_solution/public/cases/components/case_status/index.tsx rename to x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx index 2d3a7850eb0b6..945458e92bc8a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_status/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { useMemo } from 'react'; import styled, { css } from 'styled-components'; import { - EuiBadge, - EuiButton, EuiButtonEmpty, EuiDescriptionList, EuiDescriptionListDescription, @@ -16,11 +14,14 @@ import { EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; +import { CaseStatuses } from '../../../../../case/common/api'; import * as i18n from '../case_view/translations'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; import { CaseViewActions } from '../case_view/actions'; import { Case } from '../../containers/types'; import { CaseService } from '../../containers/use_get_case_user_actions'; +import { StatusContextMenu } from './status_context_menu'; +import { getStatusDate, getStatusTitle } from './helpers'; const MyDescriptionList = styled(EuiDescriptionList)` ${({ theme }) => css` @@ -31,58 +32,46 @@ const MyDescriptionList = styled(EuiDescriptionList)` `} `; -interface CaseStatusProps { - 'data-test-subj': string; - badgeColor: string; - buttonLabel: string; +interface CaseActionBarProps { caseData: Case; currentExternalIncident: CaseService | null; disabled?: boolean; - icon: string; isLoading: boolean; - isSelected: boolean; onRefresh: () => void; - status: string; - title: string; - toggleStatusCase: (status: boolean) => void; - value: string | null; + onStatusChanged: (status: CaseStatuses) => void; } -const CaseStatusComp: React.FC = ({ - 'data-test-subj': dataTestSubj, - badgeColor, - buttonLabel, +const CaseActionBarComponent: React.FC = ({ caseData, currentExternalIncident, disabled = false, - icon, isLoading, - isSelected, onRefresh, - status, - title, - toggleStatusCase, - value, + onStatusChanged, }) => { - const handleToggleStatusCase = useCallback(() => { - toggleStatusCase(!isSelected); - }, [toggleStatusCase, isSelected]); + const date = useMemo(() => getStatusDate(caseData), [caseData]); + const title = useMemo(() => getStatusTitle(caseData.status), [caseData.status]); + return ( - + {i18n.STATUS} - - {status} - + {title} - + @@ -95,18 +84,6 @@ const CaseStatusComp: React.FC = ({ {i18n.CASE_REFRESH} - - - {buttonLabel} - - = ({ ); }; -export const CaseStatus = React.memo(CaseStatusComp); +export const CaseActionBar = React.memo(CaseActionBarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.tsx new file mode 100644 index 0000000000000..bce738aa2a029 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/status_context_menu.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { memoize } from 'lodash/fp'; +import { EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { CaseStatuses } from '../../../../../case/common/api'; +import { Status, statuses } from '../status'; + +interface Props { + currentStatus: CaseStatuses; + onStatusChanged: (status: CaseStatuses) => void; +} + +const StatusContextMenuComponent: React.FC = ({ currentStatus, onStatusChanged }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const openPopover = useCallback(() => setIsPopoverOpen(true), []); + const popOverButton = useMemo( + () => , + [currentStatus, openPopover] + ); + + const onContextMenuItemClick = useMemo( + () => + memoize<(status: CaseStatuses) => () => void>((status) => () => { + closePopover(); + onStatusChanged(status); + }), + [closePopover, onStatusChanged] + ); + + const caseStatuses = Object.keys(statuses) as CaseStatuses[]; + const panelItems = caseStatuses.map((status: CaseStatuses) => ( + + + + )); + + return ( + <> + + + + + ); +}; + +export const StatusContextMenu = memo(StatusContextMenuComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index 5cb6ede0d9d21..4dbfaa9669ece 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -114,8 +114,8 @@ describe('CaseView ', () => { data.title ); - expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual( - data.status + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual( + 'Open' ); expect( @@ -136,11 +136,9 @@ describe('CaseView ', () => { data.createdBy.username ); - expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); - - expect(wrapper.find(`[data-test-subj="case-view-createdAt"]`).first().prop('value')).toEqual( - data.createdAt - ); + expect( + wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value') + ).toEqual(data.createdAt); expect( wrapper @@ -156,6 +154,7 @@ describe('CaseView ', () => { ...defaultUpdateCaseState, caseData: basicCaseClosed, })); + const wrapper = mount( @@ -163,18 +162,18 @@ describe('CaseView ', () => { ); + await waitFor(() => { - expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); - expect(wrapper.find(`[data-test-subj="case-view-closedAt"]`).first().prop('value')).toEqual( - basicCaseClosed.closedAt - ); - expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual( - basicCaseClosed.status + expect( + wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value') + ).toEqual(basicCaseClosed.closedAt); + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual( + 'Closed' ); }); }); - it('should dispatch update state when button is toggled', async () => { + it('should dispatch update state when status is changed', async () => { const wrapper = mount( @@ -182,8 +181,14 @@ describe('CaseView ', () => { ); + await waitFor(() => { - wrapper.find('[data-test-subj="toggle-case-status"]').first().simulate('click'); + wrapper.find('[data-test-subj="case-view-status-dropdown"] button').first().simulate('click'); + wrapper.update(); + wrapper + .find('button[data-test-subj="case-view-status-dropdown-closed"]') + .first() + .simulate('click'); expect(updateCaseProperty).toHaveBeenCalled(); }); }); @@ -211,26 +216,6 @@ describe('CaseView ', () => { }); }); - it('should display Toggle Status isLoading', async () => { - useUpdateCaseMock.mockImplementation(() => ({ - ...defaultUpdateCaseState, - isLoading: true, - updateKey: 'status', - })); - const wrapper = mount( - - - - - - ); - await waitFor(() => { - expect( - wrapper.find('[data-test-subj="toggle-case-status"]').first().prop('isLoading') - ).toBeTruthy(); - }); - }); - it('should display description isLoading', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 7ee2b856f8786..a338f4af6cda3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -5,7 +5,6 @@ */ import { - EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingContent, @@ -16,7 +15,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; -import * as i18n from './translations'; +import { CaseStatuses } from '../../../../../case/common/api'; import { Case, CaseConnector } from '../../containers/types'; import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to'; import { gutterTimeline } from '../../../common/lib/helpers'; @@ -29,7 +28,7 @@ import { UserList } from '../user_list'; import { useUpdateCase } from '../../containers/use_update_case'; import { getTypedPayload } from '../../containers/utils'; import { WhitePageWrapper, HeaderWrapper } from '../wrappers'; -import { CaseStatus } from '../case_status'; +import { CaseActionBar } from '../case_action_bar'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; import { usePushToService } from '../use_push_to_service'; @@ -41,6 +40,9 @@ import { normalizeActionConnector, getNoneConnector, } from '../configure_cases/utils'; +import { StatusActionButton } from '../status/button'; + +import * as i18n from './translations'; interface Props { caseId: string; @@ -55,10 +57,8 @@ export interface OnUpdateFields { } const MyWrapper = styled.div` - padding: ${({ - theme, - }) => `${theme.eui.paddingSizes.l} ${gutterTimeline} ${theme.eui.paddingSizes.l} - ${theme.eui.paddingSizes.l}`}; + padding: ${({ theme }) => + `${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} ${gutterTimeline} ${theme.eui.paddingSizes.l}`}; `; const MyEuiFlexGroup = styled(EuiFlexGroup)` @@ -159,7 +159,7 @@ export const CaseComponent = React.memo( }); break; case 'status': - const statusUpdate = getTypedPayload(value); + const statusUpdate = getTypedPayload(value); if (caseData.status !== value) { updateCaseProperty({ fetchCaseUserActions, @@ -241,11 +241,11 @@ export const CaseComponent = React.memo( [onUpdateField] ); - const toggleStatusCase = useCallback( - (nextStatus) => + const changeStatus = useCallback( + (status: CaseStatuses) => onUpdateField({ key: 'status', - value: nextStatus ? 'closed' : 'open', + value: status, }), [onUpdateField] ); @@ -257,32 +257,6 @@ export const CaseComponent = React.memo( const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); - const caseStatusData = useMemo( - () => - caseData.status === 'open' - ? { - 'data-test-subj': 'case-view-createdAt', - value: caseData.createdAt, - title: i18n.CASE_OPENED, - buttonLabel: i18n.CLOSE_CASE, - status: caseData.status, - icon: 'folderCheck', - badgeColor: 'secondary', - isSelected: false, - } - : { - 'data-test-subj': 'case-view-closedAt', - value: caseData.closedAt ?? '', - title: i18n.CASE_CLOSED, - buttonLabel: i18n.REOPEN_CASE, - status: caseData.status, - icon: 'folderExclamation', - badgeColor: 'danger', - isSelected: true, - }, - [caseData.closedAt, caseData.createdAt, caseData.status] - ); - const emailContent = useMemo( () => ({ subject: i18n.EMAIL_SUBJECT(caseData.title), @@ -307,11 +281,6 @@ export const CaseComponent = React.memo( [allCasesLink] ); - const isSelected = useMemo(() => caseStatusData.isSelected, [caseStatusData]); - const handleToggleStatusCase = useCallback(() => { - toggleStatusCase(!isSelected); - }, [toggleStatusCase, isSelected]); - return ( <> @@ -329,14 +298,13 @@ export const CaseComponent = React.memo( } title={caseData.title} > - @@ -363,16 +331,12 @@ export const CaseComponent = React.memo( - - {caseStatusData.buttonLabel} - + /> {hasDataToPush && ( diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts index ac518a9cc2fb0..c0e4d1ee1c362 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts @@ -128,14 +128,6 @@ export const COMMENT = i18n.translate('xpack.securitySolution.case.caseView.comm defaultMessage: 'comment', }); -export const CASE_OPENED = i18n.translate('xpack.securitySolution.case.caseView.caseOpened', { - defaultMessage: 'Case opened', -}); - -export const CASE_CLOSED = i18n.translate('xpack.securitySolution.case.caseView.caseClosed', { - defaultMessage: 'Case closed', -}); - export const CASE_REFRESH = i18n.translate('xpack.securitySolution.case.caseView.caseRefresh', { defaultMessage: 'Refresh case', }); diff --git a/x-pack/plugins/security_solution/public/cases/components/open_closed_stats/index.tsx b/x-pack/plugins/security_solution/public/cases/components/open_closed_stats/index.tsx deleted file mode 100644 index e7d5299842494..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/open_closed_stats/index.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useMemo } from 'react'; -import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui'; -import * as i18n from '../all_cases/translations'; - -export interface Props { - caseCount: number | null; - caseStatus: 'open' | 'closed'; - isLoading: boolean; - dataTestSubj?: string; -} - -export const OpenClosedStats = React.memo( - ({ caseCount, caseStatus, isLoading, dataTestSubj }) => { - const openClosedStats = useMemo( - () => [ - { - title: caseStatus === 'open' ? i18n.OPEN_CASES : i18n.CLOSED_CASES, - description: isLoading ? : caseCount ?? 'N/A', - }, - ], - // eslint-disable-next-line react-hooks/exhaustive-deps - [caseCount, caseStatus, isLoading, dataTestSubj] - ); - return ( - - ); - } -); - -OpenClosedStats.displayName = 'OpenClosedStats'; diff --git a/x-pack/plugins/security_solution/public/cases/components/status/button.tsx b/x-pack/plugins/security_solution/public/cases/components/status/button.tsx new file mode 100644 index 0000000000000..18aa683ed451b --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/status/button.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback, useMemo } from 'react'; +import { EuiButton } from '@elastic/eui'; + +import { CaseStatuses, caseStatuses } from '../../../../../case/common/api'; +import { statuses } from './config'; + +interface Props { + status: CaseStatuses; + disabled: boolean; + isLoading: boolean; + onStatusChanged: (status: CaseStatuses) => void; +} + +// Rotate over the statuses. open -> in-progress -> closes -> open... +const getNextItem = (item: number) => (item + 1) % caseStatuses.length; + +const StatusActionButtonComponent: React.FC = ({ + status, + onStatusChanged, + disabled, + isLoading, +}) => { + const indexOfCurrentStatus = useMemo( + () => caseStatuses.findIndex((caseStatus) => caseStatus === status), + [status] + ); + const nextStatusIndex = useMemo(() => getNextItem(indexOfCurrentStatus), [indexOfCurrentStatus]); + + const onClick = useCallback(() => { + onStatusChanged(caseStatuses[nextStatusIndex]); + }, [nextStatusIndex, onStatusChanged]); + + return ( + + {statuses[caseStatuses[nextStatusIndex]].button.label} + + ); +}; +export const StatusActionButton = memo(StatusActionButtonComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/status/config.ts b/x-pack/plugins/security_solution/public/cases/components/status/config.ts new file mode 100644 index 0000000000000..50f2a17940edf --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/status/config.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { CaseStatuses } from '../../../../../case/common/api'; +import * as i18n from './translations'; + +type Statuses = Record< + CaseStatuses, + { + color: string; + label: string; + actionBar: { + title: string; + }; + button: { + label: string; + icon: string; + }; + stats: { + title: string; + }; + } +>; + +export const statuses: Statuses = { + [CaseStatuses.open]: { + color: 'primary', + label: i18n.OPEN, + actionBar: { + title: i18n.CASE_OPENED, + }, + button: { + label: i18n.REOPEN_CASE, + icon: 'folderCheck', + }, + stats: { + title: i18n.OPEN_CASES, + }, + }, + [CaseStatuses['in-progress']]: { + color: 'warning', + label: i18n.IN_PROGRESS, + actionBar: { + title: i18n.CASE_IN_PROGRESS, + }, + button: { + label: i18n.MARK_CASE_IN_PROGRESS, + icon: 'folderExclamation', + }, + stats: { + title: i18n.IN_PROGRESS_CASES, + }, + }, + [CaseStatuses.closed]: { + color: 'default', + label: i18n.CLOSED, + actionBar: { + title: i18n.CASE_CLOSED, + }, + button: { + label: i18n.CLOSE_CASE, + icon: 'folderCheck', + }, + stats: { + title: i18n.CLOSED_CASES, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/status/index.ts b/x-pack/plugins/security_solution/public/cases/components/status/index.ts new file mode 100644 index 0000000000000..890091535ada1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/status/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './status'; +export * from './config'; +export * from './stats'; diff --git a/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx b/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx new file mode 100644 index 0000000000000..0d217dc87f620 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui'; +import { CaseStatuses } from '../../../../../case/common/api'; +import { statuses } from './config'; + +export interface Props { + caseCount: number | null; + caseStatus: CaseStatuses; + isLoading: boolean; + dataTestSubj?: string; +} + +const StatsComponent: React.FC = ({ caseCount, caseStatus, isLoading, dataTestSubj }) => { + const statusStats = useMemo( + () => [ + { + title: statuses[caseStatus].stats.title, + description: isLoading ? : caseCount ?? 'N/A', + }, + ], + [caseCount, caseStatus, isLoading] + ); + return ( + + ); +}; + +StatsComponent.displayName = 'StatsComponent'; +export const Stats = memo(StatsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/status/status.tsx b/x-pack/plugins/security_solution/public/cases/components/status/status.tsx new file mode 100644 index 0000000000000..c76f525ac09b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/status/status.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useMemo } from 'react'; +import { noop } from 'lodash/fp'; +import { EuiBadge } from '@elastic/eui'; + +import { CaseStatuses } from '../../../../../case/common/api'; +import { statuses } from './config'; +import * as i18n from './translations'; + +interface Props { + type: CaseStatuses; + withArrow?: boolean; + onClick?: () => void; +} + +const StatusComponent: React.FC = ({ type, withArrow = false, onClick = noop }) => { + const props = useMemo( + () => ({ + color: statuses[type].color, + ...(withArrow ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + }), + [withArrow, type] + ); + + return ( + + {statuses[type].label} + + ); +}; + +export const Status = memo(StatusComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/status/translations.ts b/x-pack/plugins/security_solution/public/cases/components/status/translations.ts new file mode 100644 index 0000000000000..6cbc0d492f020 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/status/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +export * from '../../translations'; + +export const OPEN = i18n.translate('xpack.securitySolution.case.status.open', { + defaultMessage: 'Open', +}); + +export const IN_PROGRESS = i18n.translate('xpack.securitySolution.case.status.inProgress', { + defaultMessage: 'In progress', +}); + +export const CLOSED = i18n.translate('xpack.securitySolution.case.status.closed', { + defaultMessage: 'Closed', +}); + +export const STATUS_ICON_ARIA = i18n.translate('xpack.securitySolution.case.status.iconAria', { + defaultMessage: 'Change status', +}); + +export const CASE_OPENED = i18n.translate('xpack.securitySolution.case.caseView.caseOpened', { + defaultMessage: 'Case opened', +}); + +export const CASE_IN_PROGRESS = i18n.translate( + 'xpack.securitySolution.case.caseView.caseInProgress', + { + defaultMessage: 'Case in progress', + } +); + +export const CASE_CLOSED = i18n.translate('xpack.securitySolution.case.caseView.caseClosed', { + defaultMessage: 'Case closed', +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx index 3b203e81cd074..9b5a464bc2273 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx @@ -13,10 +13,22 @@ import { useKibana } from '../../../common/lib/kibana'; import '../../../common/mock/match_media'; import { TimelineId } from '../../../../common/types/timeline'; import { useAllCasesModal, UseAllCasesModalProps, UseAllCasesModalReturnedValues } from '.'; -import { TestProviders } from '../../../common/mock'; +import { mockTimelineModel, TestProviders } from '../../../common/mock'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/hooks/use_selector'); + const useKibanaMock = useKibana as jest.Mocked; describe('useAllCasesModal', () => { @@ -25,6 +37,7 @@ describe('useAllCasesModal', () => { beforeEach(() => { navigateToApp = jest.fn(); useKibanaMock().services.application.navigateToApp = navigateToApp; + (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); }); it('init', async () => { @@ -81,7 +94,7 @@ describe('useAllCasesModal', () => { act(() => rerender()); const result2 = result.current; - expect(result1).toBe(result2); + expect(Object.is(result1, result2)).toBe(true); }); it('closes the modal when clicking a row', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx index 445ae675007cc..f57009bccf956 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { pick } from 'lodash/fp'; import React, { useState, useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { APP_ID } from '../../../../common/constants'; import { SecurityPageName } from '../../../app/types'; import { useKibana } from '../../../common/lib/kibana'; @@ -16,6 +17,7 @@ import { setInsertTimeline } from '../../../timelines/store/timeline/actions'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { AllCasesModal } from './all_cases_modal'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; export interface UseAllCasesModalProps { timelineId: string; @@ -34,8 +36,11 @@ export const useAllCasesModal = ({ }: UseAllCasesModalProps): UseAllCasesModalReturnedValues => { const dispatch = useDispatch(); const { navigateToApp } = useKibana().services.application; - const timeline = useShallowEqualSelector((state) => - timelineSelectors.selectTimeline(state, timelineId) + const { graphEventId, savedObjectId, title } = useDeepEqualSelector((state) => + pick( + ['graphEventId', 'savedObjectId', 'title'], + timelineSelectors.selectTimeline(state, timelineId) ?? timelineDefaults + ) ); const [showModal, setShowModal] = useState(false); @@ -52,16 +57,14 @@ export const useAllCasesModal = ({ dispatch( setInsertTimeline({ - graphEventId: timeline.graphEventId ?? '', + graphEventId, timelineId, - timelineSavedObjectId: timeline.savedObjectId ?? '', - timelineTitle: timeline.title, + timelineSavedObjectId: savedObjectId, + timelineTitle: title, }) ); }, - // dispatch causes unnecessary rerenders - // eslint-disable-next-line react-hooks/exhaustive-deps - [timeline, navigateToApp, onCloseModal, timelineId] + [onCloseModal, navigateToApp, dispatch, graphEventId, timelineId, savedObjectId, title] ); const Modal: React.FC = useCallback( diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx index 9bb79e88be138..dc361d87bad0a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx @@ -11,6 +11,7 @@ import '../../../common/mock/match_media'; import { usePushToService, ReturnUsePushToService, UsePushToService } from '.'; import { TestProviders } from '../../../common/mock'; +import { CaseStatuses } from '../../../../../case/common/api'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { basicPush, actionLicenses } from '../../containers/mock'; import { useGetActionLicense } from '../../containers/use_get_action_license'; @@ -61,7 +62,7 @@ describe('usePushToService', () => { }, caseId, caseServices, - caseStatus: 'open', + caseStatus: CaseStatuses.open, connectors: connectorsMock, updateCase, userCanCrud: true, @@ -252,7 +253,7 @@ describe('usePushToService', () => { () => usePushToService({ ...defaultArgs, - caseStatus: 'closed', + caseStatus: CaseStatuses.closed, }), { wrapper: ({ children }) => {children}, diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx index 9ac0507d52c0b..15a01406c5724 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx @@ -16,7 +16,7 @@ import { getConfigureCasesUrl, useFormatUrl } from '../../../common/components/l import { CaseCallOut } from '../callout'; import { getLicenseError, getKibanaConfigError } from './helpers'; import * as i18n from './translations'; -import { CaseConnector, ActionConnector } from '../../../../../case/common/api'; +import { CaseConnector, ActionConnector, CaseStatuses } from '../../../../../case/common/api'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { LinkAnchor } from '../../../common/components/links'; import { SecurityPageName } from '../../../app/types'; @@ -133,7 +133,7 @@ export const usePushToService = ({ }, ]; } - if (caseStatus === 'closed') { + if (caseStatus === CaseStatuses.closed) { errors = [ ...errors, { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx index 6ac1ccb56f960..975f9b76556c8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx @@ -5,11 +5,13 @@ */ import React from 'react'; + +import { CaseStatuses } from '../../../../../case/common/api'; import { basicPush, getUserAction } from '../../containers/mock'; import { getLabelTitle, getPushedServiceLabelTitle, getConnectorLabelTitle } from './helpers'; -import * as i18n from '../case_view/translations'; import { mount } from 'enzyme'; import { connectorsMock } from '../../containers/configure/mock'; +import * as i18n from './translations'; describe('User action tree helpers', () => { const connectors = connectorsMock; @@ -54,24 +56,24 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); }); - it('label title generated for update status to open', () => { - const action = { ...getUserAction(['status'], 'update'), newValue: 'open' }; + it.skip('label title generated for update status to open', () => { + const action = { ...getUserAction(['status'], 'update'), newValue: CaseStatuses.open }; const result: string | JSX.Element = getLabelTitle({ action, field: 'status', }); - expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`); + expect(result).toEqual(`${i18n.REOPEN_CASE.toLowerCase()} ${i18n.CASE}`); }); - it('label title generated for update status to closed', () => { - const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' }; + it.skip('label title generated for update status to closed', () => { + const action = { ...getUserAction(['status'], 'update'), newValue: CaseStatuses.closed }; const result: string | JSX.Element = getLabelTitle({ action, field: 'status', }); - expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`); + expect(result).toEqual(`${i18n.CLOSE_CASE.toLowerCase()} ${i18n.CASE}`); }); it('label title generated for update comment', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index 2abcb70d676ef..533a55426831e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -7,22 +7,38 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiCommentProps } from '@elastic/eui'; import React from 'react'; -import { CaseFullExternalService, ActionConnector } from '../../../../../case/common/api'; +import { + CaseFullExternalService, + ActionConnector, + CaseStatuses, +} from '../../../../../case/common/api'; import { CaseUserActions } from '../../containers/types'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { Tags } from '../tag_list/tags'; -import * as i18n from '../case_view/translations'; import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; import { UserActionTimestamp } from './user_action_timestamp'; import { UserActionCopyLink } from './user_action_copy_link'; import { UserActionMoveToReference } from './user_action_move_to_reference'; +import { Status, statuses } from '../status'; +import * as i18n from '../case_view/translations'; interface LabelTitle { action: CaseUserActions; field: string; } +const getStatusTitle = (status: CaseStatuses) => { + return ( + + {i18n.MARKED_CASE_AS} + + + + + ); +}; + export const getLabelTitle = ({ action, field }: LabelTitle) => { if (field === 'tags') { return getTagsLabelTitle(action); @@ -33,9 +49,12 @@ export const getLabelTitle = ({ action, field }: LabelTitle) => { } else if (field === 'description' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; } else if (field === 'status' && action.action === 'update') { - return `${ - action.newValue === 'open' ? i18n.REOPENED_CASE.toLowerCase() : i18n.CLOSED_CASE.toLowerCase() - } ${i18n.CASE}`; + if (!Object.prototype.hasOwnProperty.call(statuses, action.newValue ?? '')) { + return ''; + } + + // The above check ensures that the newValue is of type CaseStatuses. + return getStatusTitle(action.newValue as CaseStatuses); } else if (field === 'comment' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; } @@ -120,6 +139,16 @@ export const getPushInfo = ( parsedConnectorName: 'none', }; +const getUpdateActionIcon = (actionField: string): string => { + if (actionField === 'tags') { + return 'tag'; + } else if (actionField === 'status') { + return 'folderClosed'; + } + + return 'dot'; +}; + export const getUpdateAction = ({ action, label, @@ -139,7 +168,7 @@ export const getUpdateAction = ({ event: label, 'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`, timestamp: , - timelineIcon: action.action === 'add' || action.action === 'delete' ? 'tag' : 'dot', + timelineIcon: getUpdateActionIcon(action.actionField[0]), actions: ( diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index 228f3a4319c33..01709ae55f483 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -380,10 +380,10 @@ export const UserActionTree = React.memo( ]; } - // title, description, comments, tags + // title, description, comments, tags, status if ( action.actionField.length === 1 && - ['title', 'description', 'comment', 'tags'].includes(action.actionField[0]) + ['title', 'description', 'comment', 'tags', 'status'].includes(action.actionField[0]) ) { const myField = action.actionField[0]; const label: string | JSX.Element = getLabelTitle({ diff --git a/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx b/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx index b824619800035..d498768a9f62a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx @@ -5,7 +5,6 @@ */ import styled from 'styled-components'; -import { gutterTimeline } from '../../../common/lib/helpers'; export const WhitePageWrapper = styled.div` background-color: ${({ theme }) => theme.eui.euiColorEmptyShade}; @@ -21,6 +20,6 @@ export const SectionWrapper = styled.div` `; export const HeaderWrapper = styled.div` - padding: ${({ theme }) => `${theme.eui.paddingSizes.l} ${gutterTimeline} 0 - ${theme.eui.paddingSizes.l}`}; + padding: ${({ theme }) => + `${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 0 ${theme.eui.paddingSizes.l}`}; `; diff --git a/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts index dcc31401564b1..7d82bd98c2e43 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts @@ -35,6 +35,7 @@ import { ServiceConnectorCaseParams, ServiceConnectorCaseResponse, User, + CaseStatuses, } from '../../../../../case/common/api'; export const getCase = async ( @@ -62,7 +63,7 @@ export const getCases = async ({ filterOptions = { search: '', reporters: [], - status: 'open', + status: CaseStatuses.open, tags: [], }, queryParams = { diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index 0d2df7c2de3ea..f60993fc9aa02 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -6,6 +6,7 @@ import { KibanaServices } from '../../common/lib/kibana'; +import { ConnectorTypes, CommentType, CaseStatuses } from '../../../../case/common/api'; import { CASES_URL } from '../../../../case/common/constants'; import { @@ -51,7 +52,6 @@ import { import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import * as i18n from './translations'; -import { ConnectorTypes, CommentType } from '../../../../case/common/api'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; @@ -138,7 +138,7 @@ describe('Case Configuration API', () => { ...DEFAULT_QUERY_PARAMS, reporters: [], tags: [], - status: 'open', + status: CaseStatuses.open, }, signal: abortCtrl.signal, }); @@ -149,7 +149,7 @@ describe('Case Configuration API', () => { ...DEFAULT_FILTER_OPTIONS, reporters: [...respReporters, { username: null, full_name: null, email: null }], tags, - status: '', + status: CaseStatuses.open, search: 'hello', }, queryParams: DEFAULT_QUERY_PARAMS, @@ -162,6 +162,7 @@ describe('Case Configuration API', () => { reporters, tags: ['"coke"', '"pepsi"'], search: 'hello', + status: CaseStatuses.open, }, signal: abortCtrl.signal, }); @@ -174,7 +175,7 @@ describe('Case Configuration API', () => { ...DEFAULT_FILTER_OPTIONS, reporters: [...respReporters, { username: null, full_name: null, email: null }], tags: weirdTags, - status: '', + status: CaseStatuses.open, search: 'hello', }, queryParams: DEFAULT_QUERY_PARAMS, @@ -187,6 +188,7 @@ describe('Case Configuration API', () => { reporters, tags: ['"("', '"\\"double\\""'], search: 'hello', + status: CaseStatuses.open, }, signal: abortCtrl.signal, }); @@ -310,7 +312,7 @@ describe('Case Configuration API', () => { }); const data = [ { - status: 'closed', + status: CaseStatuses.closed, id: basicCase.id, version: basicCase.version, }, diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index 6046c3716b3b5..5186dab6d62f5 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -19,6 +19,7 @@ import { ServiceConnectorCaseResponse, ActionTypeExecutorResult, CommentType, + CaseStatuses, } from '../../../../case/common/api'; import { @@ -120,7 +121,7 @@ export const getCases = async ({ filterOptions = { search: '', reporters: [], - status: 'open', + status: CaseStatuses.open, tags: [], }, queryParams = { @@ -134,7 +135,7 @@ export const getCases = async ({ const query = { reporters: filterOptions.reporters.map((r) => r.username ?? '').filter((r) => r !== ''), tags: filterOptions.tags.map((t) => `"${t.replace(/"/g, '\\"')}"`), - ...(filterOptions.status !== '' ? { status: filterOptions.status } : {}), + status: filterOptions.status, ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), ...queryParams, }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index c5b60041f5cac..151d0953dcb8e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -9,7 +9,7 @@ import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } import { CommentResponse, ServiceConnectorCaseResponse, - Status, + CaseStatuses, UserAction, UserActionField, CaseResponse, @@ -69,7 +69,7 @@ export const basicCase: Case = { }, description: 'Security banana Issue', externalService: null, - status: 'open', + status: CaseStatuses.open, tags, title: 'Another horrible breach!!', totalComment: 1, @@ -98,8 +98,9 @@ export const basicCaseCommentPatch = { }; export const casesStatus: CasesStatus = { - countClosedCases: 130, countOpenCases: 20, + countInProgressCases: 40, + countClosedCases: 130, }; export const basicPush = { @@ -203,7 +204,7 @@ export const basicCommentSnake: CommentResponse = { export const basicCaseSnake: CaseResponse = { ...basicCase, - status: 'open' as Status, + status: CaseStatuses.open, closed_at: null, closed_by: null, comments: [basicCommentSnake], @@ -222,6 +223,7 @@ export const basicCaseSnake: CaseResponse = { export const casesStatusSnake: CasesStatusResponse = { count_closed_cases: 130, + count_in_progress_cases: 40, count_open_cases: 20, }; @@ -325,5 +327,5 @@ export const basicCaseClosed: Case = { ...basicCase, closedAt: '2020-02-25T23:06:33.798Z', closedBy: elasticUser, - status: 'closed', + status: CaseStatuses.closed, }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index b9db356498a01..4458ee83f40d3 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -10,6 +10,7 @@ import { UserAction, CaseConnector, CommentType, + CaseStatuses, } from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; @@ -57,7 +58,7 @@ export interface Case { createdBy: ElasticUser; description: string; externalService: CaseExternalService | null; - status: string; + status: CaseStatuses; tags: string[]; title: string; totalComment: number; @@ -75,7 +76,7 @@ export interface QueryParams { export interface FilterOptions { search: string; - status: string; + status: CaseStatuses; tags: string[]; reporters: User[]; } @@ -83,6 +84,7 @@ export interface FilterOptions { export interface CasesStatus { countClosedCases: number | null; countOpenCases: number | null; + countInProgressCases: number | null; } export interface AllCases extends CasesStatus { @@ -95,6 +97,7 @@ export interface AllCases extends CasesStatus { export enum SortFieldCase { createdAt = 'createdAt', closedAt = 'closedAt', + updatedAt = 'updatedAt', } export interface ElasticUser { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.test.tsx index 329fda10424a8..777d1ef77bd6a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.test.tsx @@ -5,6 +5,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; +import { CaseStatuses } from '../../../../case/common/api'; import { useUpdateCases, UseUpdateCases } from './use_bulk_update_case'; import { basicCase } from './mock'; import * as api from './api'; @@ -43,12 +44,12 @@ describe('useUpdateCases', () => { ); await waitForNextUpdate(); - result.current.updateBulkStatus([basicCase], 'closed'); + result.current.updateBulkStatus([basicCase], CaseStatuses.closed); await waitForNextUpdate(); expect(spyOnPatchCases).toBeCalledWith( [ { - status: 'closed', + status: CaseStatuses.closed, id: basicCase.id, version: basicCase.version, }, @@ -64,7 +65,7 @@ describe('useUpdateCases', () => { useUpdateCases() ); await waitForNextUpdate(); - result.current.updateBulkStatus([basicCase], 'closed'); + result.current.updateBulkStatus([basicCase], CaseStatuses.closed); await waitForNextUpdate(); expect(result.current).toEqual({ isUpdated: true, @@ -82,7 +83,7 @@ describe('useUpdateCases', () => { useUpdateCases() ); await waitForNextUpdate(); - result.current.updateBulkStatus([basicCase], 'closed'); + result.current.updateBulkStatus([basicCase], CaseStatuses.closed); expect(result.current.isLoading).toBe(true); }); @@ -95,7 +96,7 @@ describe('useUpdateCases', () => { ); await waitForNextUpdate(); - result.current.updateBulkStatus([basicCase], 'closed'); + result.current.updateBulkStatus([basicCase], CaseStatuses.closed); await waitForNextUpdate(); expect(result.current.isUpdated).toBeTruthy(); result.current.dispatchResetIsUpdated(); @@ -114,7 +115,7 @@ describe('useUpdateCases', () => { useUpdateCases() ); await waitForNextUpdate(); - result.current.updateBulkStatus([basicCase], 'closed'); + result.current.updateBulkStatus([basicCase], CaseStatuses.closed); expect(result.current).toEqual({ isUpdated: false, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx index c333ff4207833..5a138f2a97667 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx @@ -5,6 +5,7 @@ */ import { useCallback, useReducer } from 'react'; +import { CaseStatuses } from '../../../../case/common/api'; import { displaySuccessToast, errorToToaster, @@ -86,7 +87,7 @@ export const useUpdateCases = (): UseUpdateCases => { caseTitle: resultCount === 1 ? firstTitle : '', }; const message = - resultCount && patchResponse[0].status === 'open' + resultCount && patchResponse[0].status === CaseStatuses.open ? i18n.REOPENED_CASES(messageArgs) : i18n.CLOSED_CASES(messageArgs); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 7072363c1185d..44166a14ad292 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -5,6 +5,7 @@ */ import { useEffect, useReducer, useCallback } from 'react'; +import { CaseStatuses } from '../../../../case/common/api'; import { Case } from './types'; import * as i18n from './translations'; @@ -66,7 +67,7 @@ export const initialData: Case = { }, description: '', externalService: null, - status: '', + status: CaseStatuses.open, tags: [], title: '', totalComment: 0, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx index 4e274e074b036..9b4bf966a1434 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx @@ -5,6 +5,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; +import { CaseStatuses } from '../../../../case/common/api'; import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS, @@ -157,7 +158,7 @@ describe('useGetCases', () => { const newFilters = { search: 'new', tags: ['new'], - status: 'closed', + status: CaseStatuses.closed, }; const { result, waitForNextUpdate } = renderHook(() => useGetCases()); await waitForNextUpdate(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx index fdf526a1e4d88..e773a25237d0a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx @@ -5,6 +5,7 @@ */ import { useCallback, useEffect, useReducer } from 'react'; +import { CaseStatuses } from '../../../../case/common/api'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case } from './types'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; @@ -94,7 +95,7 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS export const DEFAULT_FILTER_OPTIONS: FilterOptions = { search: '', reporters: [], - status: 'open', + status: CaseStatuses.open, tags: [], }; @@ -108,6 +109,7 @@ export const DEFAULT_QUERY_PARAMS: QueryParams = { export const initialData: AllCases = { cases: [], countClosedCases: null, + countInProgressCases: null, countOpenCases: null, page: 0, perPage: 0, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.test.tsx index bfbcbd2525e3b..ac202c50cb2b7 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.test.tsx @@ -27,6 +27,7 @@ describe('useGetCasesStatus', () => { expect(result.current).toEqual({ countClosedCases: null, countOpenCases: null, + countInProgressCases: null, isLoading: true, isError: false, fetchCasesStatus: result.current.fetchCasesStatus, @@ -56,6 +57,7 @@ describe('useGetCasesStatus', () => { expect(result.current).toEqual({ countClosedCases: casesStatus.countClosedCases, countOpenCases: casesStatus.countOpenCases, + countInProgressCases: casesStatus.countInProgressCases, isLoading: false, isError: false, fetchCasesStatus: result.current.fetchCasesStatus, @@ -79,6 +81,7 @@ describe('useGetCasesStatus', () => { expect(result.current).toEqual({ countClosedCases: 0, countOpenCases: 0, + countInProgressCases: 0, isLoading: false, isError: true, fetchCasesStatus: result.current.fetchCasesStatus, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx index 5260b6d5cc283..896fda4f5e255 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx @@ -18,6 +18,7 @@ interface CasesStatusState extends CasesStatus { const initialData: CasesStatusState = { countClosedCases: null, + countInProgressCases: null, countOpenCases: null, isLoading: true, isError: false, @@ -57,6 +58,7 @@ export const useGetCasesStatus = (): UseGetCasesStatus => { }); setCasesStatusState({ countClosedCases: 0, + countInProgressCases: 0, countOpenCases: 0, isLoading: false, isError: true, diff --git a/x-pack/plugins/security_solution/public/cases/containers/utils.ts b/x-pack/plugins/security_solution/public/cases/containers/utils.ts index 313c71375111c..6d0d9fa0f030d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/utils.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/utils.ts @@ -65,8 +65,9 @@ export const convertToCamelCase = (snakeCase: T): U => export const convertAllCasesToCamel = (snakeCases: CasesFindResponse): AllCases => ({ cases: snakeCases.cases.map((snakeCase) => convertToCamelCase(snakeCase)), - countClosedCases: snakeCases.count_closed_cases, countOpenCases: snakeCases.count_open_cases, + countInProgressCases: snakeCases.count_in_progress_cases, + countClosedCases: snakeCases.count_closed_cases, page: snakeCases.page, perPage: snakeCases.per_page, total: snakeCases.total, diff --git a/x-pack/plugins/security_solution/public/cases/pages/translations.ts b/x-pack/plugins/security_solution/public/cases/pages/translations.ts index 8ba4c4faf1876..ad4fa4df64584 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/pages/translations.ts @@ -115,10 +115,6 @@ export const CREATE_CASE = i18n.translate('xpack.securitySolution.case.caseView. defaultMessage: 'Create case', }); -export const CLOSED_CASE = i18n.translate('xpack.securitySolution.case.caseView.closedCase', { - defaultMessage: 'Closed case', -}); - export const CLOSE_CASE = i18n.translate('xpack.securitySolution.case.caseView.closeCase', { defaultMessage: 'Close case', }); @@ -127,10 +123,6 @@ export const REOPEN_CASE = i18n.translate('xpack.securitySolution.case.caseView. defaultMessage: 'Reopen case', }); -export const REOPENED_CASE = i18n.translate('xpack.securitySolution.case.caseView.reopenedCase', { - defaultMessage: 'Reopened case', -}); - export const CASE_NAME = i18n.translate('xpack.securitySolution.case.caseView.caseName', { defaultMessage: 'Case name', }); diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index 1d60310731d5e..a79f7a3af18bf 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -115,22 +115,21 @@ export const CREATE_CASE = i18n.translate('xpack.securitySolution.case.caseView. defaultMessage: 'Create case', }); -export const CLOSED_CASE = i18n.translate('xpack.securitySolution.case.caseView.closedCase', { - defaultMessage: 'Closed case', -}); - export const CLOSE_CASE = i18n.translate('xpack.securitySolution.case.caseView.closeCase', { defaultMessage: 'Close case', }); +export const MARK_CASE_IN_PROGRESS = i18n.translate( + 'xpack.securitySolution.case.caseView.markInProgress', + { + defaultMessage: 'Mark in progress', + } +); + export const REOPEN_CASE = i18n.translate('xpack.securitySolution.case.caseView.reopenCase', { defaultMessage: 'Reopen case', }); -export const REOPENED_CASE = i18n.translate('xpack.securitySolution.case.caseView.reopenedCase', { - defaultMessage: 'Reopened case', -}); - export const CASE_NAME = i18n.translate('xpack.securitySolution.case.caseView.caseName', { defaultMessage: 'Case name', }); @@ -238,3 +237,22 @@ export const NO_CONNECTOR = i18n.translate('xpack.securitySolution.case.common.n export const UNKNOWN = i18n.translate('xpack.securitySolution.case.caseView.unknown', { defaultMessage: 'Unknown', }); + +export const MARKED_CASE_AS = i18n.translate('xpack.securitySolution.case.caseView.markedCaseAs', { + defaultMessage: 'marked case as', +}); + +export const OPEN_CASES = i18n.translate('xpack.securitySolution.case.caseTable.openCases', { + defaultMessage: 'Open cases', +}); + +export const CLOSED_CASES = i18n.translate('xpack.securitySolution.case.caseTable.closedCases', { + defaultMessage: 'Closed cases', +}); + +export const IN_PROGRESS_CASES = i18n.translate( + 'xpack.securitySolution.case.caseTable.inProgressCases', + { + defaultMessage: 'In progress cases', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/types.ts b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/types.ts index 280b9111042d0..93c4f95723289 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/types.ts @@ -15,11 +15,12 @@ type CommonQueryProps = HostsComponentsQueryProps | NetworkComponentQueryProps; export interface AlertsComponentsProps extends Pick< CommonQueryProps, - 'deleteQuery' | 'endDate' | 'filterQuery' | 'indexNames' | 'skip' | 'setQuery' | 'startDate' + 'deleteQuery' | 'endDate' | 'filterQuery' | 'skip' | 'setQuery' | 'startDate' > { timelineId: TimelineIdLiteral; pageFilters: Filter[]; stackByOptions?: MatrixHistogramOption[]; defaultFilters?: Filter[]; defaultStackByOption?: MatrixHistogramOption; + indexNames: string[]; } 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 175682aa43e76..abbc168128831 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 @@ -5,18 +5,17 @@ */ import { noop } from 'lodash/fp'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { DropResult, DragDropContext } from 'react-beautiful-dnd'; -import { connect, ConnectedProps } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import deepEqual from 'fast-deep-equal'; import { BeforeCapture } from './drag_drop_context'; import { BrowserFields } from '../../containers/source'; -import { dragAndDropModel, dragAndDropSelectors } from '../../store'; +import { dragAndDropSelectors } from '../../store'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; -import { State } from '../../store/types'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; import { reArrangeProviders } from '../../../timelines/components/timeline/data_providers/helpers'; import { ADDED_TO_TIMELINE_MESSAGE } from '../../hooks/translations'; @@ -34,6 +33,8 @@ import { draggableIsField, userIsReArrangingProviders, } from './helpers'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; // @ts-expect-error window['__react-beautiful-dnd-disable-dev-warnings'] = true; @@ -41,7 +42,6 @@ window['__react-beautiful-dnd-disable-dev-warnings'] = true; interface Props { browserFields: BrowserFields; children: React.ReactNode; - dispatch: Dispatch; } interface OnDragEndHandlerParams { @@ -93,73 +93,63 @@ const sensors = [useAddToTimelineSensor]; /** * DragDropContextWrapperComponent handles all drag end events */ -export const DragDropContextWrapperComponent = React.memo( - ({ activeTimelineDataProviders, browserFields, children, dataProviders, dispatch }) => { - const [, dispatchToaster] = useStateToaster(); - const onAddedToTimeline = useCallback( - (fieldOrValue: string) => { - displaySuccessToast(ADDED_TO_TIMELINE_MESSAGE(fieldOrValue), dispatchToaster); - }, - [dispatchToaster] - ); - - const onDragEnd = useCallback( - (result: DropResult) => { - try { - enableScrolling(); - - if (dataProviders != null) { - onDragEndHandler({ - activeTimelineDataProviders, - browserFields, - dataProviders, - dispatch, - onAddedToTimeline, - result, - }); - } - } finally { - document.body.classList.remove(IS_DRAGGING_CLASS_NAME); - - if (draggableIsField(result)) { - document.body.classList.remove(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME); - } +export const DragDropContextWrapperComponent: React.FC = ({ browserFields, children }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const getDataProviders = useMemo(() => dragAndDropSelectors.getDataProvidersSelector(), []); + + const activeTimelineDataProviders = useDeepEqualSelector( + (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults)?.dataProviders + ); + const dataProviders = useDeepEqualSelector(getDataProviders); + + const [, dispatchToaster] = useStateToaster(); + const onAddedToTimeline = useCallback( + (fieldOrValue: string) => { + displaySuccessToast(ADDED_TO_TIMELINE_MESSAGE(fieldOrValue), dispatchToaster); + }, + [dispatchToaster] + ); + + const onDragEnd = useCallback( + (result: DropResult) => { + try { + enableScrolling(); + + if (dataProviders != null) { + onDragEndHandler({ + activeTimelineDataProviders, + browserFields, + dataProviders, + dispatch, + onAddedToTimeline, + result, + }); } - }, - [activeTimelineDataProviders, browserFields, dataProviders, dispatch, onAddedToTimeline] - ); - return ( - - {children} - - ); - }, - // prevent re-renders when data providers are added or removed, but all other props are the same - (prevProps, nextProps) => - prevProps.children === nextProps.children && - deepEqual(prevProps.dataProviders, nextProps.dataProviders) && - prevProps.activeTimelineDataProviders === nextProps.activeTimelineDataProviders -); - -DragDropContextWrapperComponent.displayName = 'DragDropContextWrapperComponent'; - -const emptyDataProviders: dragAndDropModel.IdToDataProvider = {}; // stable reference -const emptyActiveTimelineDataProviders: DataProvider[] = []; // stable reference + } finally { + document.body.classList.remove(IS_DRAGGING_CLASS_NAME); -const mapStateToProps = (state: State) => { - const activeTimelineDataProviders = - timelineSelectors.getTimelineByIdSelector()(state, TimelineId.active)?.dataProviders ?? - emptyActiveTimelineDataProviders; - const dataProviders = dragAndDropSelectors.dataProvidersSelector(state) ?? emptyDataProviders; - - return { activeTimelineDataProviders, dataProviders }; + if (draggableIsField(result)) { + document.body.classList.remove(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME); + } + } + }, + [activeTimelineDataProviders, browserFields, dataProviders, dispatch, onAddedToTimeline] + ); + return ( + + {children} + + ); }; -const connector = connect(mapStateToProps); - -type PropsFromRedux = ConnectedProps; +DragDropContextWrapperComponent.displayName = 'DragDropContextWrapperComponent'; -export const DragDropContextWrapper = connector(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'; 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 68032fb7dc512..53e248fd41cf4 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 @@ -22,7 +22,7 @@ import { draggableIsField, droppableIdPrefix, droppableTimelineColumnsPrefix, - droppableTimelineFlyoutButtonPrefix, + droppableTimelineFlyoutBottomBarPrefix, droppableTimelineProvidersPrefix, escapeDataProviderId, escapeFieldId, @@ -338,7 +338,7 @@ describe('helpers', () => { expect( destinationIsTimelineButton({ destination: { - droppableId: `${droppableTimelineFlyoutButtonPrefix}.timeline`, + droppableId: `${droppableTimelineFlyoutBottomBarPrefix}.timeline`, index: 0, }, draggableId: getDraggableId('685260508808089'), 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 a300f253de08d..ca8bb3d54f278 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 @@ -38,7 +38,7 @@ export const droppableTimelineProvidersPrefix = `${droppableIdPrefix}.timelinePr export const droppableTimelineColumnsPrefix = `${droppableIdPrefix}.timelineColumns.`; -export const droppableTimelineFlyoutButtonPrefix = `${droppableIdPrefix}.flyoutButton.`; +export const droppableTimelineFlyoutBottomBarPrefix = `${droppableIdPrefix}.flyoutButton.`; export const getDraggableId = (dataProviderId: string): string => `${draggableContentPrefix}${dataProviderId}`; @@ -106,7 +106,7 @@ export const destinationIsTimelineColumns = (result: DropResult): boolean => export const destinationIsTimelineButton = (result: DropResult): boolean => result.destination != null && - result.destination.droppableId.startsWith(droppableTimelineFlyoutButtonPrefix); + result.destination.droppableId.startsWith(droppableTimelineFlyoutBottomBarPrefix); export const getProviderIdFromDraggable = (result: DropResult): string => result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1); diff --git a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/__snapshots__/index.test.tsx.snap deleted file mode 100644 index d90a337bbeedf..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Error Toast Dispatcher rendering it renders 1`] = ` - -`; diff --git a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx index 45b75d0f33ac9..7e0d5ac2a3a90 100644 --- a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx @@ -48,7 +48,7 @@ describe('Error Toast Dispatcher', () => { ); - expect(wrapper.find('Connect(ErrorToastDispatcherComponent)')).toMatchSnapshot(); + expect(wrapper.find('ErrorToastDispatcherComponent').exists).toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.tsx b/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.tsx index d7e5a18dfb82e..fb2bbffcad560 100644 --- a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; -import { appSelectors, State } from '../../store'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { appSelectors } from '../../store'; import { appActions } from '../../store/app'; import { useStateToaster } from '../toasters'; @@ -15,14 +16,12 @@ interface OwnProps { toastLifeTimeMs?: number; } -type Props = OwnProps & PropsFromRedux; - -const ErrorToastDispatcherComponent = ({ - toastLifeTimeMs = 5000, - errors = [], - removeError, -}: Props) => { +const ErrorToastDispatcherComponent: React.FC = ({ toastLifeTimeMs = 5000 }) => { + const dispatch = useDispatch(); + const getErrorSelector = useMemo(() => appSelectors.errorsSelector(), []); + const errors = useDeepEqualSelector(getErrorSelector); const [{ toasts }, dispatchToaster] = useStateToaster(); + useEffect(() => { errors.forEach(({ id, title, message }) => { if (!toasts.some((toast) => toast.id === id)) { @@ -38,23 +37,13 @@ const ErrorToastDispatcherComponent = ({ }, }); } - removeError({ id }); + dispatch(appActions.removeError({ id })); }); - }); - return null; -}; + }, [dispatch, dispatchToaster, errors, toastLifeTimeMs, toasts]); -const makeMapStateToProps = () => { - const getErrorSelector = appSelectors.errorsSelector(); - return (state: State) => getErrorSelector(state); -}; - -const mapDispatchToProps = { - removeError: appActions.removeError, + return null; }; -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; +ErrorToastDispatcherComponent.displayName = 'ErrorToastDispatcherComponent'; -export const ErrorToastDispatcher = connector(ErrorToastDispatcherComponent); +export const ErrorToastDispatcher = React.memo(ErrorToastDispatcherComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap index 9ca9cd6cce389..8d807825c246a 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap @@ -1,15 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`EventDetails rendering should match snapshot 1`] = ` -
- + + , - "id": "table-view", - "name": "Table", - } + /> + , + "id": "table-view", + "name": "Table", } - tabs={ - Array [ - Object { - "content": + + , - "id": "table-view", - "name": "Table", - }, - Object { - "content": + , + "id": "table-view", + "name": "Table", + }, + Object { + "content": + + , - "id": "json-view", - "name": "JSON View", - }, - ] - } - /> -
+ /> + , + "id": "json-view", + "name": "JSON View", + }, + ] + } +/> `; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap index caa7853fd9ec0..af9fc61b9585c 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap @@ -1,18 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`JSON View rendering should match snapshot 1`] = ` - - - + width="100%" +/> `; 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 35cb8f7b1c91f..1a492eee4ae7a 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 @@ -89,21 +89,6 @@ export const getColumns = ({ ), }, - { - field: 'description', - name: '', - render: (description: string | null | undefined, data: EventFieldsData) => ( - - ), - sortable: true, - truncateText: true, - width: '30px', - }, { field: 'field', name: i18n.FIELD, @@ -167,6 +152,14 @@ export const getColumns = ({
+ + +
), }, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index a2a7182a768cc..92c3ff9b9fa97 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -4,14 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLink, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; +import { EuiSpacer, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; -import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; import { EventFieldsBrowser } from './event_fields_browser'; import { JsonView } from './json_view'; import * as i18n from './translations'; @@ -22,82 +20,84 @@ export enum EventsViewType { jsonView = 'json-view', } -const CollapseLink = styled(EuiLink)` - margin: 20px 0; -`; - -CollapseLink.displayName = 'CollapseLink'; - interface Props { browserFields: BrowserFields; - columnHeaders: ColumnHeaderOptions[]; data: TimelineEventsDetailsItem[]; id: string; view: EventsViewType; - onUpdateColumns: OnUpdateColumns; onViewSelected: (selected: EventsViewType) => void; timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; } -const Details = styled.div` - user-select: none; -`; +const StyledEuiTabbedContent = styled(EuiTabbedContent)` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; -Details.displayName = 'Details'; + > [role='tabpanel'] { + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + } +`; -export const EventDetails = React.memo( - ({ - browserFields, - columnHeaders, - data, - id, - view, - onUpdateColumns, +const EventDetailsComponent: React.FC = ({ + browserFields, + data, + id, + view, + onViewSelected, + timelineId, +}) => { + const handleTabClick = useCallback((e) => onViewSelected(e.id as EventsViewType), [ onViewSelected, - timelineId, - toggleColumn, - }) => { - const handleTabClick = useCallback((e) => onViewSelected(e.id as EventsViewType), [ - onViewSelected, - ]); + ]); - const tabs: EuiTabbedContentTab[] = useMemo( - () => [ - { - id: EventsViewType.tableView, - name: i18n.TABLE, - content: ( + const tabs: EuiTabbedContentTab[] = useMemo( + () => [ + { + id: EventsViewType.tableView, + name: i18n.TABLE, + content: ( + <> + - ), - }, - { - id: EventsViewType.jsonView, - name: i18n.JSON_VIEW, - content: , - }, - ], - [browserFields, columnHeaders, data, id, onUpdateColumns, timelineId, toggleColumn] - ); + + ), + }, + { + id: EventsViewType.jsonView, + name: i18n.JSON_VIEW, + content: ( + <> + + + + ), + }, + ], + [browserFields, data, id, timelineId] + ); - return ( -
- -
- ); - } -); + const selectedTab = view === EventsViewType.tableView ? tabs[0] : tabs[1]; + + return ( + + ); +}; + +EventDetailsComponent.displayName = 'EventDetailsComponent'; -EventDetails.displayName = 'EventDetails'; +export const EventDetails = React.memo(EventDetailsComponent); 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 0acf461828bc3..e4365c4b7b2d8 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 @@ -9,14 +9,23 @@ import React from 'react'; import '../../mock/match_media'; import { mockDetailItemData, mockDetailItemDataId } from '../../mock/mock_detail_item'; import { TestProviders } from '../../mock/test_providers'; - +import { timelineActions } from '../../../timelines/store/timeline'; import { EventFieldsBrowser } from './event_fields_browser'; import { mockBrowserFields } from '../../containers/source/mock'; -import { defaultHeaders } from '../../mock/header'; import { useMountAppended } from '../../utils/use_mount_appended'; jest.mock('../link_to'); +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + describe('EventFieldsBrowser', () => { const mount = useMountAppended(); @@ -27,12 +36,9 @@ describe('EventFieldsBrowser', () => { ); @@ -48,12 +54,9 @@ describe('EventFieldsBrowser', () => { ); @@ -74,12 +77,9 @@ describe('EventFieldsBrowser', () => { ); @@ -96,12 +96,9 @@ describe('EventFieldsBrowser', () => { ); @@ -113,18 +110,14 @@ describe('EventFieldsBrowser', () => { test('it invokes toggleColumn when the checkbox is clicked', () => { const field = '@timestamp'; - const toggleColumn = jest.fn(); const wrapper = mount( ); @@ -138,11 +131,12 @@ describe('EventFieldsBrowser', () => { }); wrapper.update(); - expect(toggleColumn).toBeCalledWith({ - columnHeaderType: 'not-filtered', - id: '@timestamp', - width: 180, - }); + expect(mockDispatch).toBeCalledWith( + timelineActions.removeColumn({ + columnId: '@timestamp', + id: 'test', + }) + ); }); }); @@ -152,12 +146,9 @@ describe('EventFieldsBrowser', () => { ); @@ -179,17 +170,36 @@ describe('EventFieldsBrowser', () => { ); expect(wrapper.find('[data-test-subj="field-name"]').at(0).text()).toEqual('@timestamp'); }); + + test('it renders the expected icon for description', () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find('.euiTableRow') + .find('.euiTableRowCell') + .at(1) + .find('[data-euiicon-type]') + .last() + .prop('data-euiicon-type') + ).toEqual('iInCircle'); + }); }); describe('value', () => { @@ -198,12 +208,9 @@ describe('EventFieldsBrowser', () => { ); @@ -219,12 +226,9 @@ describe('EventFieldsBrowser', () => { ); 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 79250ae9bec52..0dbdc98b6a8e9 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 @@ -6,29 +6,73 @@ import { sortBy } from 'lodash'; import { EuiInMemoryTable } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { rgba } from 'polished'; +import styled from 'styled-components'; +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 { OnUpdateColumns } from '../../../timelines/components/timeline/events'; +import { getColumnHeaders } from '../../../timelines/components/timeline/body/column_headers/helpers'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { getColumns } from './columns'; import { search } from './helpers'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; interface Props { browserFields: BrowserFields; - columnHeaders: ColumnHeaderOptions[]; data: TimelineEventsDetailsItem[]; eventId: string; - onUpdateColumns: OnUpdateColumns; timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; } +const TableWrapper = styled.div` + display: flex; + flex: 1; + overflow: hidden; + + > div { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + > .euiFlexGroup:first-of-type { + flex: 0; + } + } +`; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` + flex: 1; + overflow: auto; + + &::-webkit-scrollbar { + height: ${({ theme }) => theme.eui.euiScrollBar}; + width: ${({ theme }) => theme.eui.euiScrollBar}; + } + + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; + } + + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } +`; + /** Renders a table view or JSON view of the `ECS` `data` */ export const EventFieldsBrowser = React.memo( - ({ browserFields, columnHeaders, data, eventId, onUpdateColumns, timelineId, toggleColumn }) => { + ({ browserFields, data, eventId, timelineId }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const fieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); const items = useMemo( () => @@ -39,6 +83,40 @@ export const EventFieldsBrowser = React.memo( })), [data, fieldsByName] ); + + const columnHeaders = useDeepEqualSelector((state) => { + const { columns } = getTimeline(state, timelineId) ?? timelineDefaults; + + return getColumnHeaders(columns, browserFields); + }); + + const toggleColumn = useCallback( + (column: ColumnHeaderOptions) => { + if (columnHeaders.some((c) => c.id === column.id)) { + dispatch( + timelineActions.removeColumn({ + columnId: column.id, + id: timelineId, + }) + ); + } else { + dispatch( + timelineActions.upsertColumn({ + column, + id: timelineId, + index: 1, + }) + ); + } + }, + [columnHeaders, dispatch, timelineId] + ); + + const onUpdateColumns = useCallback( + (columns) => dispatch(timelineActions.updateColumns({ id: timelineId, columns })), + [dispatch, timelineId] + ); + const columns = useMemo( () => getColumns({ @@ -53,16 +131,15 @@ export const EventFieldsBrowser = React.memo( ); return ( -
- , column `render` callbacks expect complete BrowserField + + -
+ ); } ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx index 168fe6e65564d..bf548d04e780b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx @@ -6,7 +6,7 @@ import { EuiCodeEditor } from '@elastic/eui'; import { set } from '@elastic/safer-lodash-set/fp'; -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; @@ -16,27 +16,35 @@ interface Props { data: TimelineEventsDetailsItem[]; } -const JsonEditor = styled.div` - width: 100%; +const StyledEuiCodeEditor = styled(EuiCodeEditor)` + flex: 1; `; -JsonEditor.displayName = 'JsonEditor'; +const EDITOR_SET_OPTIONS = { fontSize: '12px' }; -export const JsonView = React.memo(({ data }) => ( - - (({ data }) => { + const value = useMemo( + () => + JSON.stringify( buildJsonView(data), omitTypenameAndEmpty, 2 // indent level - )} + ), + [data] + ); + + return ( + - -)); + ); +}); JsonView.displayName = 'JsonView'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx deleted file mode 100644 index 4730dc5c2264f..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useState } from 'react'; - -import { BrowserFields } from '../../containers/source'; -import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; -import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; - -import { EventDetails, EventsViewType, View } from './event_details'; - -interface Props { - browserFields: BrowserFields; - columnHeaders: ColumnHeaderOptions[]; - data: TimelineEventsDetailsItem[]; - id: string; - onUpdateColumns: OnUpdateColumns; - timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; -} - -export const StatefulEventDetails = React.memo( - ({ browserFields, columnHeaders, data, id, onUpdateColumns, timelineId, toggleColumn }) => { - // TODO: Move to the store - const [view, setView] = useState(EventsViewType.tableView); - - return ( - - ); - } -); - -StatefulEventDetails.displayName = 'StatefulEventDetails'; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx index ad332b2759048..b3a838ab088df 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx @@ -10,7 +10,6 @@ import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { useDispatch } from 'react-redux'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { timelineActions } from '../../../timelines/store/timeline'; import { BrowserFields, DocValueFields } from '../../containers/source'; import { @@ -20,32 +19,32 @@ import { import { useDeepEqualSelector } from '../../hooks/use_selector'; const StyledEuiFlyout = styled(EuiFlyout)` - z-index: 9999; + z-index: ${({ theme }) => theme.eui.euiZLevel7}; `; interface EventDetailsFlyoutProps { browserFields: BrowserFields; docValueFields: DocValueFields[]; timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; } +const emptyExpandedEvent = {}; + const EventDetailsFlyoutComponent: React.FC = ({ browserFields, docValueFields, timelineId, - toggleColumn, }) => { const dispatch = useDispatch(); const expandedEvent = useDeepEqualSelector( - (state) => state.timeline.timelineById[timelineId]?.expandedEvent ?? {} + (state) => state.timeline.timelineById[timelineId]?.expandedEvent ?? emptyExpandedEvent ); const handleClearSelection = useCallback(() => { dispatch( timelineActions.toggleExpandedEvent({ timelineId, - event: {}, + event: emptyExpandedEvent, }) ); }, [dispatch, timelineId]); @@ -65,7 +64,6 @@ const EventDetailsFlyoutComponent: React.FC = ({ docValueFields={docValueFields} event={expandedEvent} timelineId={timelineId} - toggleColumn={toggleColumn} /> @@ -77,6 +75,5 @@ export const EventDetailsFlyout = React.memo( (prevProps, nextProps) => deepEqual(prevProps.browserFields, nextProps.browserFields) && deepEqual(prevProps.docValueFields, nextProps.docValueFields) && - prevProps.timelineId === nextProps.timelineId && - prevProps.toggleColumn === nextProps.toggleColumn + prevProps.timelineId === nextProps.timelineId ); 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 aac1f4f2687eb..7132add229edb 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 @@ -26,6 +26,10 @@ import { AlertsTableFilterGroup } from '../../../detections/components/alerts_ta import { SourcererScopeName } from '../../store/sourcerer/model'; import { useTimelineEvents } from '../../../timelines/containers'; +jest.mock('../../../timelines/components/graph_overlay', () => ({ + GraphOverlay: jest.fn(() =>
), +})); + jest.mock('../../../timelines/containers', () => ({ useTimelineEvents: jest.fn(), })); @@ -70,7 +74,6 @@ const eventsViewerDefaultProps = { itemsPerPage: 10, itemsPerPageOptions: [], kqlMode: 'filter' as KqlMode, - onChangeItemsPerPage: jest.fn(), query: { query: '', language: 'kql', @@ -81,7 +84,6 @@ const eventsViewerDefaultProps = { sortDirection: 'none' as SortDirection, }, scopeId: SourcererScopeName.timeline, - toggleColumn: jest.fn(), utilityBar, }; 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 186083f1b05cd..208d60ac73865 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 @@ -18,9 +18,8 @@ import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/ import { HeaderSection } from '../header_section'; import { defaultHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers'; import { Sort } from '../../../timelines/components/timeline/body/sort'; -import { StatefulBody } from '../../../timelines/components/timeline/body/stateful_body'; +import { StatefulBody } from '../../../timelines/components/timeline/body'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; -import { OnChangeItemsPerPage } from '../../../timelines/components/timeline/events'; import { Footer, footerHeight } from '../../../timelines/components/timeline/footer'; import { combineQueries, resolverIsShowing } from '../../../timelines/components/timeline/helpers'; import { TimelineRefetch } from '../../../timelines/components/timeline/refetch_timeline'; @@ -36,7 +35,7 @@ import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { ExitFullScreen } from '../exit_full_screen'; import { useFullScreen } from '../../containers/use_full_screen'; -import { TimelineId, TimelineType } from '../../../../common/types/timeline'; +import { TimelineId } from '../../../../common/types/timeline'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px @@ -78,8 +77,8 @@ const EventsContainerLoading = styled.div` `; const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` - width: 100%; overflow: hidden; + margin: 0; display: ${({ $visible }) => ($visible ? 'flex' : 'none')}; `; @@ -113,12 +112,10 @@ interface Props { itemsPerPage: number; itemsPerPageOptions: number[]; kqlMode: KqlMode; - onChangeItemsPerPage: OnChangeItemsPerPage; query: Query; onRuleChange?: () => void; start: string; sort: Sort; - toggleColumn: (column: ColumnHeaderOptions) => void; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; // If truthy, the graph viewer (Resolver) is showing graphEventId: string | undefined; @@ -141,16 +138,14 @@ const EventsViewerComponent: React.FC = ({ itemsPerPage, itemsPerPageOptions, kqlMode, - onChangeItemsPerPage, query, onRuleChange, start, sort, - toggleColumn, utilityBar, graphEventId, }) => { - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen, timelineFullScreen } = useFullScreen(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); const [isQueryLoading, setIsQueryLoading] = useState(false); @@ -275,7 +270,7 @@ const EventsViewerComponent: React.FC = ({ id={!resolverIsShowing(graphEventId) ? id : undefined} height={headerFilterGroup ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT} subtitle={utilityBar ? undefined : subtitle} - title={inspect ? justTitle : titleWithExitFullScreen} + title={timelineFullScreen ? justTitle : titleWithExitFullScreen} > {HeaderSectionContent} @@ -291,26 +286,17 @@ const EventsViewerComponent: React.FC = ({ refetch={refetch} /> - {graphEventId && ( - - )} - + {graphEventId && } +
= ({ itemsCount={nonDeletedEvents.length} itemsPerPage={itemsPerPage} itemsPerPageOptions={itemsPerPageOptions} - onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadPage} totalCount={totalCountMinusDeleted} /> 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 58f81c9fb3c8b..ec3cbbdef98ad 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, useEffect } from 'react'; +import React, { useMemo, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; @@ -12,12 +12,7 @@ import styled from 'styled-components'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline'; -import { - ColumnHeaderOptions, - SubsetTimelineModel, - TimelineModel, -} from '../../../timelines/store/timeline/model'; -import { OnChangeItemsPerPage } from '../../../timelines/components/timeline/events'; +import { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/timeline/model'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { EventsViewer } from './events_viewer'; import { InspectButtonContainer } from '../inspect'; @@ -67,13 +62,10 @@ const StatefulEventsViewerComponent: React.FC = ({ pageFilters, query, onRuleChange, - removeColumn, start, scopeId, showCheckboxes, sort, - updateItemsPerPage, - upsertColumn, utilityBar, // If truthy, the graph viewer (Resolver) is showing graphEventId, @@ -105,33 +97,6 @@ const StatefulEventsViewerComponent: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback( - (itemsChangedPerPage) => updateItemsPerPage({ id, itemsPerPage: itemsChangedPerPage }), - [id, updateItemsPerPage] - ); - - const toggleColumn = useCallback( - (column: ColumnHeaderOptions) => { - const exists = columns.findIndex((c) => c.id === column.id) !== -1; - - if (!exists && upsertColumn != null) { - upsertColumn({ - column, - id, - index: 1, - }); - } - - if (exists && removeColumn != null) { - removeColumn({ - columnId: column.id, - id, - }); - } - }, - [columns, id, upsertColumn, removeColumn] - ); - const globalFilters = useMemo(() => [...filters, ...(pageFilters ?? [])], [filters, pageFilters]); return ( @@ -155,12 +120,10 @@ const StatefulEventsViewerComponent: React.FC = ({ itemsPerPage={itemsPerPage!} itemsPerPageOptions={itemsPerPageOptions!} kqlMode={kqlMode} - onChangeItemsPerPage={onChangeItemsPerPage} query={query} onRuleChange={onRuleChange} start={start} sort={sort} - toggleColumn={toggleColumn} utilityBar={utilityBar} graphEventId={graphEventId} /> @@ -170,7 +133,6 @@ const StatefulEventsViewerComponent: React.FC = ({ browserFields={browserFields} docValueFields={docValueFields} timelineId={id} - toggleColumn={toggleColumn} /> ); @@ -222,9 +184,6 @@ const makeMapStateToProps = () => { const mapDispatchToProps = { createTimeline: timelineActions.createTimeline, deleteEventQuery: inputsActions.deleteOneQuery, - updateItemsPerPage: timelineActions.updateItemsPerPage, - removeColumn: timelineActions.removeColumn, - upsertColumn: timelineActions.upsertColumn, }; const connector = connect(makeMapStateToProps, mapDispatchToProps); diff --git a/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx b/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx index c324b812a9ec2..0ec9926e7cf2a 100644 --- a/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx +++ b/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx @@ -5,26 +5,22 @@ */ import React from 'react'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import { InPortal } from 'react-reverse-portal'; import { useGlobalHeaderPortal } from '../../hooks/use_global_header_portal'; -import { gutterTimeline } from '../../lib/helpers'; const Wrapper = styled.aside` position: relative; z-index: ${({ theme }) => theme.eui.euiZNavigation}; background: ${({ theme }) => theme.eui.euiColorEmptyShade}; border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - padding: ${({ theme }) => theme.eui.paddingSizes.m} ${gutterTimeline} - ${({ theme }) => theme.eui.paddingSizes.m} ${({ theme }) => theme.eui.paddingSizes.l}; + padding: ${({ theme }) => theme.eui.paddingSizes.m} ${({ theme }) => theme.eui.paddingSizes.l}; `; Wrapper.displayName = 'Wrapper'; const FiltersGlobalContainer = styled.header<{ show: boolean }>` - ${({ show }) => css` - ${show ? '' : 'display: none;'}; - `} + display: ${({ show }) => (show ? 'block' : 'none')}; `; FiltersGlobalContainer.displayName = 'FiltersGlobalContainer'; diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx index 11623e1367574..7e8c93e86376a 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx @@ -10,7 +10,6 @@ import React, { forwardRef, useCallback } from 'react'; import styled from 'styled-components'; import { OutPortal } from 'react-reverse-portal'; -import { gutterTimeline } from '../../lib/helpers'; import { navTabs } from '../../../app/home/home_navigations'; import { useFullScreen } from '../../containers/use_full_screen'; import { SecurityPageName } from '../../../app/types'; @@ -54,7 +53,7 @@ const FlexGroup = styled(EuiFlexGroup)<{ $hasSibling: boolean }>` margin-bottom: 1px; padding-bottom: 4px; padding-left: ${theme.eui.paddingSizes.l}; - padding-right: ${gutterTimeline}; + padding-right: ${theme.eui.paddingSizes.l}; ${$hasSibling ? `border-bottom: ${theme.eui.euiBorderThin};` : 'border-bottom-width: 0px;'} `} `; @@ -64,11 +63,12 @@ interface HeaderGlobalProps { hideDetectionEngine?: boolean; isFixed?: boolean; } + export const HeaderGlobal = React.memo( forwardRef( ({ hideDetectionEngine = false, isFixed = true }, ref) => { const { globalHeaderPortalNode } = useGlobalHeaderPortal(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen, timelineFullScreen } = useFullScreen(); const search = useGetUrlSearch(navTabs.overview); const { application, http } = useKibana().services; const { navigateToApp } = application; @@ -82,7 +82,7 @@ export const HeaderGlobal = React.memo( ); return ( - + - + ); } diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts index da5099f61e9b2..36cdc807c4c0c 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts @@ -11,6 +11,7 @@ import { HostsTableType } from '../../../../hosts/store/model'; import { RouteSpyState, SiemRouteType } from '../../../utils/route/types'; import { TabNavigationProps } from '../tab_navigation/types'; import { NetworkRouteType } from '../../../../network/pages/navigation/types'; +import { TimelineTabs } from '../../../../timelines/store/timeline/model'; const setBreadcrumbsMock = jest.fn(); const chromeMock = { @@ -79,6 +80,7 @@ const getMockObject = ( query: { query: '', language: 'kuery' }, filters: [], timeline: { + activeTab: TimelineTabs.query, id: '', isOpen: false, graphEventId: '', diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx index 102ed7851e57d..158da3be3bbf7 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx @@ -14,6 +14,7 @@ import { navTabs } from '../../../app/home/home_navigations'; import { HostsTableType } from '../../../hosts/store/model'; import { RouteSpyState } from '../../utils/route/types'; import { SiemNavigationProps, SiemNavigationComponentProps } from './types'; +import { TimelineTabs } from '../../../timelines/store/timeline/model'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -80,6 +81,7 @@ describe('SIEM Navigation', () => { [CONSTANTS.filters]: [], [CONSTANTS.sourcerer]: {}, [CONSTANTS.timeline]: { + activeTab: TimelineTabs.query, id: '', isOpen: false, graphEventId: '', @@ -154,6 +156,7 @@ describe('SIEM Navigation', () => { flowTarget: undefined, savedQuery: undefined, timeline: { + activeTab: TimelineTabs.query, id: '', isOpen: false, graphEventId: '', @@ -257,7 +260,7 @@ describe('SIEM Navigation', () => { sourcerer: {}, state: undefined, tabName: 'authentications', - timeline: { id: '', isOpen: false, graphEventId: '' }, + timeline: { id: '', isOpen: false, activeTab: TimelineTabs.query, graphEventId: '' }, timerange: { global: { linkTo: ['timeline'], diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx index b149488ff38a7..db3416866d89f 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.tsx @@ -103,4 +103,6 @@ const SiemNavigationContainer: React.FC = (props) => { return ; }; -export const SiemNavigation = SiemNavigationContainer; +export const SiemNavigation = React.memo(SiemNavigationContainer, (prevProps, nextProps) => + deepEqual(prevProps.navTabs, nextProps.navTabs) +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx index 5c69edbabdc66..f4ffc25146be5 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx @@ -11,6 +11,7 @@ import { navTabs } from '../../../../app/home/home_navigations'; import { SecurityPageName } from '../../../../app/types'; import { navTabsHostDetails } from '../../../../hosts/pages/details/nav_tabs'; import { HostsTableType } from '../../../../hosts/store/model'; +import { TimelineTabs } from '../../../../timelines/store/timeline/model'; import { RouteSpyState } from '../../../utils/route/types'; import { CONSTANTS } from '../../url_state/constants'; import { TabNavigationComponent } from './'; @@ -70,6 +71,7 @@ describe('Tab Navigation', () => { [CONSTANTS.filters]: [], [CONSTANTS.sourcerer]: {}, [CONSTANTS.timeline]: { + activeTab: TimelineTabs.query, id: '', isOpen: false, graphEventId: '', @@ -129,6 +131,7 @@ describe('Tab Navigation', () => { [CONSTANTS.filters]: [], [CONSTANTS.sourcerer]: {}, [CONSTANTS.timeline]: { + activeTab: TimelineTabs.query, id: '', isOpen: false, graphEventId: '', diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx index 3eb66b5591b85..509e3744f09ef 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx @@ -7,6 +7,7 @@ import { EuiTab, EuiTabs } from '@elastic/eui'; import { getOr } from 'lodash/fp'; import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; +import deepEqual from 'fast-deep-equal'; import { APP_ID } from '../../../../../common/constants'; import { track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../lib/telemetry'; @@ -63,9 +64,18 @@ const TabNavigationItemComponent = ({ const TabNavigationItem = React.memo(TabNavigationItemComponent); -export const TabNavigationComponent = (props: TabNavigationProps) => { - const { display, navTabs, pageName, tabName } = props; - +export const TabNavigationComponent: React.FC = ({ + display, + filters, + query, + navTabs, + pageName, + savedQuery, + sourcerer, + tabName, + timeline, + timerange, +}) => { const mapLocationToTab = useCallback( (): string => getOr( @@ -94,7 +104,6 @@ export const TabNavigationComponent = (props: TabNavigationProps) => { () => Object.values(navTabs).map((tab) => { const isSelected = selectedTabId === tab.id; - const { filters, query, savedQuery, sourcerer, timeline, timerange } = props; const search = getSearch(tab, { filters, query, @@ -120,7 +129,7 @@ export const TabNavigationComponent = (props: TabNavigationProps) => { /> ); }), - [navTabs, selectedTabId, props] + [navTabs, selectedTabId, filters, query, savedQuery, sourcerer, timeline, timerange] ); return {renderTabs}; @@ -128,6 +137,19 @@ export const TabNavigationComponent = (props: TabNavigationProps) => { TabNavigationComponent.displayName = 'TabNavigationComponent'; -export const TabNavigation = React.memo(TabNavigationComponent); +export const TabNavigation = React.memo( + TabNavigationComponent, + (prevProps, nextProps) => + prevProps.display === nextProps.display && + prevProps.pageName === nextProps.pageName && + prevProps.savedQuery === nextProps.savedQuery && + prevProps.tabName === nextProps.tabName && + deepEqual(prevProps.filters, nextProps.filters) && + deepEqual(prevProps.query, nextProps.query) && + deepEqual(prevProps.navTabs, nextProps.navTabs) && + deepEqual(prevProps.sourcerer, nextProps.sourcerer) && + deepEqual(prevProps.timeline, nextProps.timeline) && + deepEqual(prevProps.timerange, nextProps.timerange) +); TabNavigation.displayName = 'TabNavigation'; diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index 6d114258224d1..55e60f545e642 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -36,6 +36,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta "gutterExtraSmall": "4px", "gutterSmall": "8px", }, + "euiBodyLineHeight": 1, "euiBorderColor": "#343741", "euiBorderEditable": "2px dotted #343741", "euiBorderRadius": "4px", @@ -68,7 +69,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiButtonHeightSmall": "32px", "euiButtonIconTypes": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "disabled": "#4c4e51", "ghost": "#ffffff", "primary": "#1ba9f5", @@ -139,6 +140,8 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiCodeBlockTitleColor": "#da8b45", "euiCodeBlockTypeColor": "#6092c0", "euiCodeFontFamily": "'Roboto Mono', 'Consolas', 'Menlo', 'Courier', monospace", + "euiCodeFontWeightBold": 700, + "euiCodeFontWeightRegular": 400, "euiCollapsibleNavGroupDarkBackgroundColor": "#131317", "euiCollapsibleNavGroupDarkHighContrastColor": "#1ba9f5", "euiCollapsibleNavGroupLightBackgroundColor": "#1a1b20", @@ -148,9 +151,11 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiColorChartBand": "#2a2b33", "euiColorChartLines": "#343741", "euiColorDanger": "#ff6666", - "euiColorDangerText": "#ff7575", + "euiColorDangerText": "#ff6666", "euiColorDarkShade": "#98a2b3", "euiColorDarkestShade": "#d4dae5", + "euiColorDisabled": "#434548", + "euiColorDisabledText": "#4c4e51", "euiColorEmptyShade": "#1d1e24", "euiColorFullShade": "#ffffff", "euiColorGhost": "#ffffff", @@ -159,6 +164,11 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiColorLightShade": "#343741", "euiColorLightestShade": "#25262e", "euiColorMediumShade": "#535966", + "euiColorPaletteDisplaySizes": Object { + "sizeExtraSmall": "4px", + "sizeMedium": "16px", + "sizeSmall": "8px", + }, "euiColorPickerIndicatorSize": "12px", "euiColorPickerSaturationRange0": "#000000", "euiColorPickerSaturationRange1": "rgba(0, 0, 0, 0)", @@ -220,7 +230,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, "euiExpressionColors": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "primary": "#1ba9f5", "secondary": "#7de2d1", "subdued": "#81858f", @@ -234,13 +244,14 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, "euiFilePickerTallHeight": "128px", "euiFlyoutBorder": "1px solid #343741", - "euiFocusBackgroundColor": "#232635", + "euiFocusBackgroundColor": "#08334a", "euiFocusRingAnimStartColor": "rgba(27, 169, 245, 0)", "euiFocusRingAnimStartSize": "6px", "euiFocusRingAnimStartSizeLarge": "10px", "euiFocusRingColor": "rgba(27, 169, 245, 0.3)", "euiFocusRingSize": "3px", "euiFocusRingSizeLarge": "4px", + "euiFocusTransparency": 0.3, "euiFontFamily": "'Inter UI', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", "euiFontFeatureSettings": "calt 1 kern 1 liga 1", "euiFontSize": "16px", @@ -314,6 +325,16 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiKeyPadMenuSize": "96px", "euiLineHeight": 1.5, "euiLinkColor": "#1ba9f5", + "euiLinkColors": Object { + "accent": "#f990c0", + "danger": "#ff6666", + "ghost": "#ffffff", + "primary": "#1ba9f5", + "secondary": "#7de2d1", + "subdued": "#81858f", + "text": "#dfe5ef", + "warning": "#ffce7a", + }, "euiListGroupGutterTypes": Object { "gutterMedium": "16px", "gutterSmall": "8px", @@ -524,8 +545,8 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiTableFocusClickableColor": "rgba(27, 169, 245, 0.09999999999999998)", "euiTableHoverClickableColor": "rgba(27, 169, 245, 0.050000000000000044)", "euiTableHoverColor": "#1e1e25", - "euiTableHoverSelectedColor": "#202230", - "euiTableSelectedColor": "#232635", + "euiTableHoverSelectedColor": "#072e43", + "euiTableSelectedColor": "#08334a", "euiTextColor": "#dfe5ef", "euiTextColors": Object { "accent": "#f990c0", @@ -721,16 +742,6 @@ exports[`Paginated Table Component rendering it renders the default load more ta "xs": "4px", "xxl": "40px", }, - "textColors": Object { - "accent": "#f990c0", - "danger": "#ff7575", - "ghost": "#ffffff", - "primary": "#1ba9f5", - "secondary": "#7de2d1", - "subdued": "#81858f", - "text": "#dfe5ef", - "warning": "#ffce7a", - }, "textareaResizing": Object { "both": "resizeBoth", "horizontal": "resizeHorizontal", diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index c0d540d01ee97..0dcd2b646b9e6 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx @@ -18,7 +18,7 @@ import { EuiPopover, } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { FC, memo, useState, useEffect, ComponentType } from 'react'; +import React, { FC, memo, useState, useMemo, useEffect, ComponentType } from 'react'; import styled from 'styled-components'; import { Direction } from '../../../../common/search_strategy'; @@ -228,6 +228,19 @@ const PaginatedTableComponent: FC = ({ )); const PaginationWrapper = showMorePagesIndicator ? PaginationEuiFlexItem : EuiFlexItem; + const tableSorting = useMemo( + () => + sorting + ? { + sort: { + field: sorting.field, + direction: sorting.direction, + }, + } + : undefined, + [sorting] + ); + return ( @@ -251,16 +264,7 @@ const PaginatedTableComponent: FC = ({ columns={columns} items={pageOfItems} onChange={onChange} - sorting={ - sorting - ? { - sort: { - field: sorting.field, - direction: sorting.direction, - }, - } - : undefined - } + sorting={tableSorting} /> diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx index dbc194054d3a6..22f3d067b1538 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx @@ -182,72 +182,6 @@ describe('QueryBar ', () => { }); }); - describe('state', () => { - test('clears draftQuery when filterQueryDraft has been cleared', async () => { - const wrapper = await getWrapper( - - ); - - let queryInput = wrapper.find(QueryBar).find('textarea[data-test-subj="queryInput"]'); - queryInput.simulate('change', { target: { value: 'host.name:*' } }); - - wrapper.update(); - queryInput = wrapper.find(QueryBar).find('textarea[data-test-subj="queryInput"]'); - expect(queryInput.props().children).toBe('host.name:*'); - - wrapper.setProps({ filterQueryDraft: null }); - wrapper.update(); - queryInput = wrapper.find(QueryBar).find('textarea[data-test-subj="queryInput"]'); - - expect(queryInput.props().children).toBe(''); - }); - }); - - describe('#onQueryChange', () => { - test(' is the only reference that changed when filterQueryDraft props get updated', async () => { - const wrapper = await getWrapper( - - ); - const searchBarProps = wrapper.find(SearchBar).props(); - const onChangedQueryRef = searchBarProps.onQueryChange; - const onSubmitQueryRef = searchBarProps.onQuerySubmit; - const onSavedQueryRef = searchBarProps.onSavedQueryUpdated; - - const queryInput = wrapper.find(QueryBar).find('textarea[data-test-subj="queryInput"]'); - queryInput.simulate('change', { target: { value: 'hello: world' } }); - wrapper.update(); - - expect(onChangedQueryRef).not.toEqual(wrapper.find(SearchBar).props().onQueryChange); - expect(onSubmitQueryRef).toEqual(wrapper.find(SearchBar).props().onQuerySubmit); - expect(onSavedQueryRef).toEqual(wrapper.find(SearchBar).props().onSavedQueryUpdated); - }); - }); - describe('#onQuerySubmit', () => { test(' is the only reference that changed when filterQuery props get updated', async () => { const wrapper = await getWrapper( diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx index 7555f6e734214..431a9b534fb91 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useState, useEffect, useMemo, useCallback } from 'react'; +import React, { memo, useMemo, useCallback } from 'react'; import deepEqual from 'fast-deep-equal'; import { @@ -19,7 +19,6 @@ import { SavedQueryTimeFilter, } from '../../../../../../../src/plugins/data/public'; import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; -import { KueryFilterQuery } from '../../store'; export interface QueryBarComponentProps { dataTestSubj?: string; @@ -30,14 +29,13 @@ export interface QueryBarComponentProps { isLoading?: boolean; isRefreshPaused?: boolean; filterQuery: Query; - filterQueryDraft?: KueryFilterQuery; filterManager: FilterManager; filters: Filter[]; - onChangedQuery: (query: Query) => void; + onChangedQuery?: (query: Query) => void; onSubmitQuery: (query: Query, timefilter?: SavedQueryTimeFilter) => void; refreshInterval?: number; - savedQuery?: SavedQuery | null; - onSavedQuery: (savedQuery: SavedQuery | null) => void; + savedQuery?: SavedQuery; + onSavedQuery: (savedQuery: SavedQuery | undefined) => void; } export const QueryBar = memo( @@ -49,7 +47,6 @@ export const QueryBar = memo( isLoading = false, isRefreshPaused, filterQuery, - filterQueryDraft, filterManager, filters, onChangedQuery, @@ -59,18 +56,6 @@ export const QueryBar = memo( onSavedQuery, dataTestSubj, }) => { - const [draftQuery, setDraftQuery] = useState(filterQuery); - - useEffect(() => { - setDraftQuery(filterQuery); - }, [filterQuery]); - - useEffect(() => { - if (filterQueryDraft == null) { - setDraftQuery(filterQuery); - } - }, [filterQuery, filterQueryDraft]); - const onQuerySubmit = useCallback( (payload: { dateRange: TimeRange; query?: Query }) => { if (payload.query != null && !deepEqual(payload.query, filterQuery)) { @@ -82,19 +67,11 @@ export const QueryBar = memo( const onQueryChange = useCallback( (payload: { dateRange: TimeRange; query?: Query }) => { - if (payload.query != null && !deepEqual(payload.query, draftQuery)) { - setDraftQuery(payload.query); + if (onChangedQuery && payload.query != null && !deepEqual(payload.query, filterQuery)) { onChangedQuery(payload.query); } }, - [draftQuery, onChangedQuery, setDraftQuery] - ); - - const onSaved = useCallback( - (newSavedQuery: SavedQuery) => { - onSavedQuery(newSavedQuery); - }, - [onSavedQuery] + [filterQuery, onChangedQuery] ); const onSavedQueryUpdated = useCallback( @@ -114,7 +91,7 @@ export const QueryBar = memo( language: savedQuery.attributes.query.language, }); filterManager.setFilters([]); - onSavedQuery(null); + onSavedQuery(undefined); } }, [filterManager, onSubmitQuery, onSavedQuery, savedQuery]); @@ -128,8 +105,6 @@ export const QueryBar = memo( const CustomButton = <>{null}; const indexPatterns = useMemo(() => [indexPattern], [indexPattern]); - const searchBarProps = savedQuery != null ? { savedQuery } : {}; - return ( ( indexPatterns={indexPatterns} isLoading={isLoading} isRefreshPaused={isRefreshPaused} - query={draftQuery} + query={filterQuery} onClearSavedQuery={onClearSavedQuery} onFiltersUpdated={onFiltersUpdated} onQueryChange={onQueryChange} onQuerySubmit={onQuerySubmit} - onSaved={onSaved} + onSaved={onSavedQuery} onSavedQueryUpdated={onSavedQueryUpdated} refreshInterval={refreshInterval} showAutoRefreshOnly={false} @@ -155,8 +130,10 @@ export const QueryBar = memo( showSaveQuery={true} timeHistory={new TimeHistory(new Storage(localStorage))} dataTestSubj={dataTestSubj} - {...searchBarProps} + savedQuery={savedQuery} /> ); } ); + +QueryBar.displayName = 'QueryBar'; diff --git a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx index acc01ac4f76aa..0837614c7f82c 100644 --- a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx @@ -294,7 +294,22 @@ export const SearchBarComponent = memo( /> ); - } + }, + (prevProps, nextProps) => + prevProps.end === nextProps.end && + prevProps.filterQuery === nextProps.filterQuery && + prevProps.fromStr === nextProps.fromStr && + prevProps.id === nextProps.id && + prevProps.isLoading === nextProps.isLoading && + prevProps.savedQuery === nextProps.savedQuery && + prevProps.setSavedQuery === nextProps.setSavedQuery && + prevProps.setSearchBarFilter === nextProps.setSearchBarFilter && + prevProps.start === nextProps.start && + prevProps.toStr === nextProps.toStr && + prevProps.updateSearch === nextProps.updateSearch && + prevProps.dataTestSubj === nextProps.dataTestSubj && + deepEqual(prevProps.indexPattern, nextProps.indexPattern) && + deepEqual(prevProps.queries, nextProps.queries) ); const makeMapStateToProps = () => { diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx index 34fb344eed3c4..cd7fdefdfac6a 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx @@ -17,6 +17,7 @@ import { import { get, getOr } from 'lodash/fp'; import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; +import deepEqual from 'fast-deep-equal'; import { HostsKpiStrategyResponse, @@ -284,7 +285,21 @@ export const StatItemsComponent = React.memo( ); - } + }, + (prevProps, nextProps) => + prevProps.description === nextProps.description && + prevProps.enableAreaChart === nextProps.enableAreaChart && + prevProps.enableBarChart === nextProps.enableBarChart && + prevProps.from === nextProps.from && + prevProps.grow === nextProps.grow && + prevProps.id === nextProps.id && + prevProps.index === nextProps.index && + prevProps.narrowDateRange === nextProps.narrowDateRange && + prevProps.statKey === nextProps.statKey && + prevProps.to === nextProps.to && + deepEqual(prevProps.areaChart, nextProps.areaChart) && + deepEqual(prevProps.barChart, nextProps.barChart) && + deepEqual(prevProps.fields, nextProps.fields) ); StatItemsComponent.displayName = 'StatItemsComponent'; diff --git a/x-pack/plugins/security_solution/public/common/components/subtitle/index.tsx b/x-pack/plugins/security_solution/public/common/components/subtitle/index.tsx index 1b7e042e90dbf..a5e39860a7c0f 100644 --- a/x-pack/plugins/security_solution/public/common/components/subtitle/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/subtitle/index.tsx @@ -12,7 +12,7 @@ const Wrapper = styled.div` margin-top: ${theme.eui.euiSizeS}; .siemSubtitle__item { - color: ${theme.eui.textColors.subdued}; + color: ${theme.eui.euiTextSubduedColor}; font-size: ${theme.eui.euiFontSizeXS}; line-height: ${theme.eui.euiLineHeight}; diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx index 97e023176647f..dae25d848fb5b 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx @@ -16,6 +16,7 @@ import { getOr, take, isEmpty } from 'lodash/fp'; import React, { useState, useCallback } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; +import deepEqual from 'fast-deep-equal'; import { DEFAULT_TIMEPICKER_QUICK_RANGES } from '../../../../common/constants'; import { timelineActions } from '../../../timelines/store/timeline'; @@ -79,7 +80,6 @@ export const SuperDatePickerComponent = React.memo( fromStr, id, isLoading, - kind, kqlQuery, policy, queries, @@ -202,7 +202,23 @@ export const SuperDatePickerComponent = React.memo( start={startDate} /> ); - } + }, + (prevProps, nextProps) => + prevProps.duration === nextProps.duration && + prevProps.end === nextProps.end && + prevProps.fromStr === nextProps.fromStr && + prevProps.id === nextProps.id && + prevProps.isLoading === nextProps.isLoading && + prevProps.policy === nextProps.policy && + prevProps.setDuration === nextProps.setDuration && + prevProps.start === nextProps.start && + prevProps.startAutoReload === nextProps.startAutoReload && + prevProps.stopAutoReload === nextProps.stopAutoReload && + prevProps.timelineId === nextProps.timelineId && + prevProps.toStr === nextProps.toStr && + prevProps.updateReduxTime === nextProps.updateReduxTime && + deepEqual(prevProps.kqlQuery, nextProps.kqlQuery) && + deepEqual(prevProps.queries, nextProps.queries) ); export const formatDate = ( 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 fd1fa1c29a807..b2fe8cc4e108a 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 @@ -149,10 +149,6 @@ const state: State = { serializedQuery: '{"bool":{"should":[{"exists":{"field":"host.name"}}],"minimum_should_match":1}}', }, - filterQueryDraft: { - kind: 'kuery', - expression: 'host.name : *', - }, }, }, }, diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx index c49c7228e521a..86769211d3ec1 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { useGlobalTime } from '../../containers/use_global_time'; @@ -17,7 +17,6 @@ import { IIndexPattern, } from '../../../../../../../src/plugins/data/public'; import { inputsModel, inputsSelectors, State } from '../../store'; -import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { TimelineModel } from '../../../timelines/store/timeline/model'; @@ -61,9 +60,7 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -const mapDispatchToProps = { setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker }; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); +const connector = connect(makeMapStateToProps); // * `indexToAdd`, which enables the alerts index to be appended to // the `indexPattern` returned by `useWithSource`, may only be populated when @@ -98,42 +95,59 @@ const StatefulTopNComponent: React.FC = ({ globalQuery = EMPTY_QUERY, kqlMode, onFilterAdded, - setAbsoluteRangeDatePicker, timelineId, toggleTopN, value, }) => { - const kibana = useKibana(); + const { uiSettings } = useKibana().services; const { from, deleteQuery, setQuery, to } = useGlobalTime(false); const options = getOptions( timelineId === TimelineId.active ? activeTimelineEventType : undefined ); + + const combinedQueries = useMemo( + () => + timelineId === TimelineId.active + ? combineQueries({ + browserFields, + config: esQuery.getEsQueryConfig(uiSettings), + dataProviders, + filters: activeTimelineFilters, + indexPattern, + kqlMode, + kqlQuery: { + language: 'kuery', + query: activeTimelineKqlQueryExpression ?? '', + }, + })?.filterQuery + : undefined, + [ + activeTimelineFilters, + activeTimelineKqlQueryExpression, + browserFields, + dataProviders, + indexPattern, + kqlMode, + timelineId, + uiSettings, + ] + ); + + const defaultView = useMemo( + () => + timelineId === TimelineId.detectionsPage || + timelineId === TimelineId.detectionsRulesDetailsPage + ? 'alert' + : options[0].value, + [options, timelineId] + ); + return ( = ({ indexNames={indexNames} options={options} query={timelineId === TimelineId.active ? EMPTY_QUERY : globalQuery} - setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setAbsoluteRangeDatePickerTarget={timelineId === TimelineId.active ? 'timeline' : 'global'} setQuery={setQuery} timelineId={timelineId} diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx index f7ad35f2c5a37..f7703e166e7d8 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { waitFor } from '@testing-library/react'; import '../../mock/match_media'; import { TestProviders, mockIndexPattern } from '../../mock'; -import { setAbsoluteRangeDatePicker } from '../../store/inputs/actions'; import { allEvents, defaultOptions } from './helpers'; import { TopN, Props as TopNProps } from './top_n'; @@ -105,7 +104,6 @@ describe('TopN', () => { indexPattern: mockIndexPattern, options: defaultOptions, query, - setAbsoluteRangeDatePicker, setAbsoluteRangeDatePickerTarget: 'global', setQuery: jest.fn(), to: '2020-04-15T00:31:47.695Z', diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx index 4f0a71dcc3ebb..ac03e6c5c0018 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx @@ -7,7 +7,6 @@ import { EuiButtonIcon, EuiSuperSelect } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { ActionCreator } from 'typescript-fsa'; import { GlobalTimeArgs } from '../../containers/use_global_time'; import { EventsByDataset } from '../../../overview/components/events_by_dataset'; @@ -52,11 +51,6 @@ export interface Props extends Pick; setAbsoluteRangeDatePickerTarget: InputsModelId; timelineId?: string; toggleTopN: () => void; @@ -78,7 +72,6 @@ const TopNComponent: React.FC = ({ indexNames, options, query = DEFAULT_QUERY, - setAbsoluteRangeDatePicker, setAbsoluteRangeDatePickerTarget, setQuery, timelineId, @@ -142,7 +135,6 @@ const TopNComponent: React.FC = ({ indexPattern={indexPattern} onlyField={field} query={query} - setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} setQuery={setQuery} timelineId={timelineId} diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index 2be9d27b3fecb..9932e52b6a1d1 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -16,7 +16,7 @@ import { TimelineId } from '../../../../common/types/timeline'; import { SecurityPageName } from '../../../app/types'; import { inputsSelectors, State } from '../../store'; import { UrlInputsModel } from '../../store/inputs/model'; -import { TimelineUrl } from '../../../timelines/store/timeline/model'; +import { TimelineTabs, TimelineUrl } from '../../../timelines/store/timeline/model'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { formatDate } from '../super_date_picker'; import { NavTab } from '../navigation/types'; @@ -130,9 +130,10 @@ export const makeMapStateToProps = () => { ? { id: flyoutTimeline.savedObjectId != null ? flyoutTimeline.savedObjectId : '', isOpen: flyoutTimeline.show, + activeTab: flyoutTimeline.activeTab, graphEventId: flyoutTimeline.graphEventId ?? '', } - : { id: '', isOpen: false, graphEventId: '' }; + : { id: '', isOpen: false, activeTab: TimelineTabs.query, graphEventId: '' }; let searchAttr: { [CONSTANTS.appQuery]?: Query; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index.tsx index 7d081f357e1b6..47b0b360f4b5d 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index.tsx @@ -52,4 +52,9 @@ const UseUrlStateComponent: React.FC = (props) => { return ; }; -export const UseUrlState = React.memo(UseUrlStateComponent); +export const UseUrlState = React.memo( + UseUrlStateComponent, + (prevProps, nextProps) => + deepEqual(prevProps.indexPattern, nextProps.indexPattern) && + deepEqual(prevProps.navTabs, nextProps.navTabs) +); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx index 1e77ae7766630..fb1c6197e9708 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx @@ -97,6 +97,7 @@ export const dispatchSetInitialStateFromUrl = ( const timeline = decodeRisonUrlState(newUrlStateString); if (timeline != null && timeline.id !== '') { queryTimelineById({ + activeTimelineTab: timeline.activeTab, apolloClient, duplicate: false, graphEventId: timeline.graphEventId, diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/test_dependencies.ts b/x-pack/plugins/security_solution/public/common/components/url_state/test_dependencies.ts index 272d40a8cea2b..bf5b6b1719605 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/test_dependencies.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/test_dependencies.ts @@ -17,6 +17,7 @@ import { Query } from '../../../../../../../src/plugins/data/public'; import { networkModel } from '../../../network/store'; import { hostsModel } from '../../../hosts/store'; import { HostsTableType } from '../../../hosts/store/model'; +import { TimelineTabs } from '../../../timelines/store/timeline/model'; type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; @@ -114,6 +115,7 @@ export const defaultProps: UrlStateContainerPropTypes = { [CONSTANTS.appQuery]: { query: '', language: 'kuery' }, [CONSTANTS.filters]: [], [CONSTANTS.timeline]: { + activeTab: TimelineTabs.query, id: '', isOpen: false, }, diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx index 0efd27c6ecbc6..ef1e36bd79e40 100644 --- a/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx +++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx @@ -115,7 +115,7 @@ export const BarText = styled.p.attrs({ className: 'siemUtilityBar__text', })` ${({ theme }) => css` - color: ${theme.eui.textColors.subdued}; + color: ${theme.eui.euiTextSubduedColor}; font-size: ${theme.eui.euiFontSizeXS}; line-height: ${theme.eui.euiLineHeight}; white-space: nowrap; diff --git a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx index 8eff52dae89f3..23f9a8a6bce01 100644 --- a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx @@ -23,16 +23,16 @@ const Wrapper = styled.div` flex: 1 1 auto; } - &.siemWrapperPage--withTimeline { - padding-right: ${gutterTimeline}; - } - &.siemWrapperPage--noPadding { padding: 0; display: flex; flex-direction: column; flex: 1 1 auto; } + + &.siemWrapperPage--withTimeline { + padding-bottom: ${gutterTimeline}; + } `; Wrapper.displayName = 'Wrapper'; diff --git a/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.test.ts new file mode 100644 index 0000000000000..9e1894e84bc49 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { noop } from 'lodash/fp'; +import { useTimelineLastEventTime, UseTimelineLastEventTimeArgs } from '.'; +import { LastEventIndexKey } from '../../../../../common/search_strategy'; +import { useKibana } from '../../../../common/lib/kibana'; + +const mockSearchStrategy = jest.fn(); +const mockUseKibana = { + services: { + data: { + search: { + search: mockSearchStrategy.mockReturnValue({ + unsubscribe: jest.fn(), + subscribe: jest.fn(({ next, error }) => { + const mockData = { + lastSeen: '1 minute ago', + }; + try { + next(mockData); + /* eslint-disable no-empty */ + } catch (e) {} + }), + }), + }, + }, + notifications: { + toasts: { + addWarning: jest.fn(), + }, + }, + }, +}; + +jest.mock('../../../../common/lib/kibana', () => ({ + useKibana: jest.fn(), +})); + +describe('useTimelineLastEventTime', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue(mockUseKibana); + }); + + it('should init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + string, + [boolean, UseTimelineLastEventTimeArgs] + >(() => + useTimelineLastEventTime({ + indexKey: LastEventIndexKey.hostDetails, + details: {}, + docValueFields: [], + indexNames: [], + }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual([ + false, + { errorMessage: undefined, lastSeen: null, refetch: noop }, + ]); + }); + }); + + it('should call search strategy', async () => { + await act(async () => { + const { waitForNextUpdate } = renderHook( + () => + useTimelineLastEventTime({ + indexKey: LastEventIndexKey.hostDetails, + details: {}, + docValueFields: [], + indexNames: [], + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(mockSearchStrategy.mock.calls[0][0]).toEqual({ + defaultIndex: [], + details: {}, + docValueFields: [], + factoryQueryType: 'eventsLastEventTime', + indexKey: 'hostDetails', + }); + }); + }); + + it('should set response', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + string, + [boolean, UseTimelineLastEventTimeArgs] + >(() => + useTimelineLastEventTime({ + indexKey: LastEventIndexKey.hostDetails, + details: {}, + docValueFields: [], + indexNames: [], + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current[1].lastSeen).toEqual('1 minute ago'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/global_time/index.tsx b/x-pack/plugins/security_solution/public/common/containers/global_time/index.tsx deleted file mode 100644 index f2545c1642d49..0000000000000 --- a/x-pack/plugins/security_solution/public/common/containers/global_time/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useCallback, useState, useEffect } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; - -import { inputsModel, inputsSelectors, State } from '../../store'; -import { inputsActions } from '../../store/actions'; - -interface SetQuery { - id: string; - inspect: inputsModel.InspectQuery | null; - loading: boolean; - refetch: inputsModel.Refetch | inputsModel.RefetchKql; -} - -export interface GlobalTimeArgs { - from: string; - to: string; - setQuery: ({ id, inspect, loading, refetch }: SetQuery) => void; - deleteQuery?: ({ id }: { id: string }) => void; - isInitializing: boolean; -} - -interface OwnProps { - children: (args: GlobalTimeArgs) => React.ReactNode; -} - -type GlobalTimeProps = OwnProps & PropsFromRedux; - -export const GlobalTimeComponent: React.FC = ({ - children, - deleteAllQuery, - deleteOneQuery, - from, - to, - setGlobalQuery, -}) => { - const [isInitializing, setIsInitializing] = useState(true); - - const setQuery = useCallback( - ({ id, inspect, loading, refetch }: SetQuery) => - setGlobalQuery({ inputId: 'global', id, inspect, loading, refetch }), - [setGlobalQuery] - ); - - const deleteQuery = useCallback( - ({ id }: { id: string }) => deleteOneQuery({ inputId: 'global', id }), - [deleteOneQuery] - ); - - useEffect(() => { - if (isInitializing) { - setIsInitializing(false); - } - return () => { - deleteAllQuery({ id: 'global' }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - <> - {children({ - isInitializing, - from, - to, - setQuery, - deleteQuery, - })} - - ); -}; - -const mapStateToProps = (state: State) => { - const timerange: inputsModel.TimeRange = inputsSelectors.globalTimeRangeSelector(state); - return { - from: timerange.from, - to: timerange.to, - }; -}; - -const mapDispatchToProps = { - deleteAllQuery: inputsActions.deleteAllQuery, - deleteOneQuery: inputsActions.deleteOneQuery, - setGlobalQuery: inputsActions.setQuery, -}; - -export const connector = connect(mapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const GlobalTime = connector(React.memo(GlobalTimeComponent)); - -GlobalTime.displayName = 'GlobalTime'; 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 f245857f3d0db..9bd375b897daf 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 @@ -19,7 +19,7 @@ import { BrowserFields, } from '../../../../common/search_strategy/index_fields'; import { AbortError } from '../../../../../../../src/plugins/kibana_utils/common'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import * as i18n from './translations'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { sourcererActions, sourcererSelectors } from '../../store/sourcerer'; @@ -213,7 +213,7 @@ export const useIndexFields = (sourcererScopeName: SourcererScopeName) => { () => sourcererSelectors.getIndexNamesSelectedSelector(), [] ); - const { indexNames, previousIndexNames } = useShallowEqualSelector<{ + const { indexNames, previousIndexNames } = useDeepEqualSelector<{ indexNames: string[]; previousIndexNames: string; }>((state) => indexNamesSelectedSelector(state, sourcererScopeName)); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index d9f2abeb3832e..b7938a5f3d755 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -4,21 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import deepEqual from 'fast-deep-equal'; -// Prefer importing entire lodash library, e.g. import { get } from "lodash" -// eslint-disable-next-line no-restricted-imports -import isEqual from 'lodash/isEqual'; import { useEffect, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { sourcererActions, sourcererSelectors } from '../../store/sourcerer'; -import { ManageScope, SourcererScopeName } from '../../store/sourcerer/model'; +import { SourcererScopeName } from '../../store/sourcerer/model'; import { useIndexFields } from '../source'; -import { State } from '../../store'; import { useUserInfo } from '../../../detections/components/user_info'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { TimelineId } from '../../../../common/types/timeline'; -import { TimelineModel } from '../../../timelines/store/timeline/model'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; export const useInitSourcerer = ( scopeId: SourcererScopeName.default | SourcererScopeName.detections = SourcererScopeName.default @@ -30,12 +25,11 @@ export const useInitSourcerer = ( () => sourcererSelectors.configIndexPatternsSelector(), [] ); - const ConfigIndexPatterns = useSelector(getConfigIndexPatternsSelector, isEqual); + const ConfigIndexPatterns = useDeepEqualSelector(getConfigIndexPatternsSelector); const getTimelineSelector = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const activeTimeline = useSelector( - (state) => getTimelineSelector(state, TimelineId.active), - isEqual + const activeTimeline = useDeepEqualSelector((state) => + getTimelineSelector(state, TimelineId.active) ); useIndexFields(scopeId); @@ -82,9 +76,6 @@ export const useInitSourcerer = ( export const useSourcererScope = (scope: SourcererScopeName = SourcererScopeName.default) => { const sourcererScopeSelector = useMemo(() => sourcererSelectors.getSourcererScopeSelector(), []); - const SourcererScope = useSelector( - (state) => sourcererScopeSelector(state, scope), - deepEqual - ); + const SourcererScope = useDeepEqualSelector((state) => sourcererScopeSelector(state, scope)); return SourcererScope; }; diff --git a/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.tsx b/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.tsx index e6c47c697c0b2..cd08f8b256a1c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.tsx @@ -4,17 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { pick } from 'lodash/fp'; import { useCallback, useState, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { useShallowEqualSelector } from '../../hooks/use_selector'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; import { inputsSelectors } from '../../store'; import { inputsActions } from '../../store/actions'; import { SetQuery, DeleteQuery } from './types'; export const useGlobalTime = (clearAllQuery: boolean = true) => { const dispatch = useDispatch(); - const { from, to } = useShallowEqualSelector(inputsSelectors.globalTimeRangeSelector); + const { from, to } = useDeepEqualSelector((state) => + pick(['from', 'to'], inputsSelectors.globalTimeRangeSelector(state)) + ); const [isInitializing, setIsInitializing] = useState(true); const setQuery = useCallback( diff --git a/x-pack/plugins/security_solution/public/common/lib/keury/index.ts b/x-pack/plugins/security_solution/public/common/lib/keury/index.ts index b06a6ec10f48e..cae05a61266bb 100644 --- a/x-pack/plugins/security_solution/public/common/lib/keury/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/keury/index.ts @@ -5,6 +5,7 @@ */ import { isEmpty, isString, flow } from 'lodash/fp'; + import { EsQueryConfig, Query, @@ -13,11 +14,8 @@ import { esKuery, IIndexPattern, } from '../../../../../../../src/plugins/data/public'; - import { JsonObject } from '../../../../../../../src/plugins/kibana_utils/public'; -import { KueryFilterQuery } from '../../store'; - export const convertKueryToElasticSearchQuery = ( kueryExpression: string, indexPattern?: IIndexPattern @@ -57,17 +55,6 @@ export const escapeQueryValue = (val: number | string = ''): string | number => return val; }; -export const isFromKueryExpressionValid = (kqlFilterQuery: KueryFilterQuery | null): boolean => { - if (kqlFilterQuery && kqlFilterQuery.kind === 'kuery') { - try { - esKuery.fromKueryExpression(kqlFilterQuery.expression); - } catch (err) { - return false; - } - } - return true; -}; - const escapeWhitespace = (val: string) => val.replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n'); 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 ba375612b22a7..db414dfab5c09 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 @@ -30,6 +30,7 @@ import { ManagementState } from '../../management/types'; import { initialSourcererState, SourcererScopeName } from '../store/sourcerer/model'; import { mockBrowserFields, mockDocValueFields } from '../containers/source/mock'; import { mockIndexPattern } from './index_pattern'; +import { TimelineTabs } from '../../timelines/store/timeline/model'; export const mockGlobalState: State = { app: { @@ -202,6 +203,7 @@ export const mockGlobalState: State = { }, timelineById: { test: { + activeTab: TimelineTabs.query, deletedEventIds: [], id: 'test', savedObjectId: null, @@ -220,7 +222,7 @@ export const mockGlobalState: State = { isSelectAllChecked: false, isLoading: false, kqlMode: 'filter', - kqlQuery: { filterQuery: null, filterQueryDraft: null }, + kqlQuery: { filterQuery: null }, loadingEventIds: [], title: '', timelineType: TimelineType.default, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index 0118004b48eb8..d927fcb27e099 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -12,8 +12,9 @@ import { GetAllTimeline, SortFieldTimeline, TimelineResult, Direction } from '.. import { TimelineEventsDetailsItem } from '../../../common/search_strategy'; import { allTimelinesQuery } from '../../timelines/containers/all/index.gql_query'; import { CreateTimelineProps } from '../../detections/components/alerts_table/types'; -import { TimelineModel } from '../../timelines/store/timeline/model'; +import { TimelineModel, TimelineTabs } from '../../timelines/store/timeline/model'; import { timelineDefaults } from '../../timelines/store/timeline/defaults'; + export interface MockedProvidedQuery { request: { query: GetAllTimeline.Query; @@ -2053,6 +2054,7 @@ export const mockTimelineResults: OpenTimelineResult[] = [ ]; export const mockTimelineModel: TimelineModel = { + activeTab: TimelineTabs.query, columns: [ { columnHeaderType: 'not-filtered', @@ -2129,7 +2131,6 @@ export const mockTimelineModel: TimelineModel = { kqlMode: 'filter', kqlQuery: { filterQuery: null, - filterQueryDraft: null, }, itemsPerPage: 25, itemsPerPageOptions: [10, 25, 50, 100], @@ -2192,6 +2193,7 @@ export const mockTimelineApolloResult = { export const defaultTimelineProps: CreateTimelineProps = { from: '2018-11-05T18:58:25.937Z', timeline: { + activeTab: TimelineTabs.query, columns: [ { columnHeaderType: 'not-filtered', id: '@timestamp', width: 190 }, { columnHeaderType: 'not-filtered', id: 'message', width: 180 }, @@ -2236,7 +2238,6 @@ export const defaultTimelineProps: CreateTimelineProps = { kqlMode: 'filter', kqlQuery: { filterQuery: { kuery: { expression: '', kind: 'kuery' }, serializedQuery: '' }, - filterQueryDraft: { expression: '', kind: 'kuery' }, }, loadingEventIds: [], noteIds: [], diff --git a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts index d18cb73dbcfb9..59d783107e587 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts @@ -33,4 +33,4 @@ export const selectNotesByIdSelector = createSelector( export const notesByIdsSelector = () => createSelector(selectNotesById, (notesById: NotesById) => notesById); -export const errorsSelector = () => createSelector(getErrors, (errors) => ({ errors })); +export const errorsSelector = () => createSelector(getErrors, (errors) => errors); diff --git a/x-pack/plugins/security_solution/public/common/store/drag_and_drop/selectors.ts b/x-pack/plugins/security_solution/public/common/store/drag_and_drop/selectors.ts index 5d6534f96bc7a..b8bfa9ca554ff 100644 --- a/x-pack/plugins/security_solution/public/common/store/drag_and_drop/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/drag_and_drop/selectors.ts @@ -10,7 +10,5 @@ import { State } from '../types'; const selectDataProviders = (state: State): IdToDataProvider => state.dragAndDrop.dataProviders; -export const dataProvidersSelector = createSelector( - selectDataProviders, - (dataProviders) => dataProviders -); +export const getDataProvidersSelector = () => + createSelector(selectDataProviders, (dataProviders) => dataProviders); diff --git a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.test.ts b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.test.ts index e6577f2461a9e..c9b42931c5dce 100644 --- a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.test.ts @@ -61,9 +61,9 @@ describe('Sourcerer selectors', () => { 'auditbeat-*', 'endgame-*', 'filebeat-*', + 'logs-endpoint.event-*', 'packetbeat-*', 'winlogbeat-*', - 'logs-endpoint.event-*', ]); }); }); diff --git a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts index 6ebc00133c0cd..599cddb605148 100644 --- a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import memoizeOne from 'memoize-one'; import { createSelector } from 'reselect'; import { State } from '../types'; -import { SourcererScopeById, KibanaIndexPatterns, SourcererScopeName, ManageScope } from './model'; +import { SourcererScopeById, ManageScope, KibanaIndexPatterns, SourcererScopeName } from './model'; export const sourcererKibanaIndexPatternsSelector = ({ sourcerer }: State): KibanaIndexPatterns => sourcerer.kibanaIndexPatterns; @@ -17,6 +18,13 @@ export const sourcererSignalIndexNameSelector = ({ sourcerer }: State): string | export const sourcererConfigIndexPatternsSelector = ({ sourcerer }: State): string[] => sourcerer.configIndexPatterns; +export const sourcererScopeIdSelector = ( + { sourcerer }: State, + scopeId: SourcererScopeName +): ManageScope => sourcerer.sourcererScopes[scopeId]; + +export const scopeIdSelector = () => createSelector(sourcererScopeIdSelector, (scope) => scope); + export const sourcererScopesSelector = ({ sourcerer }: State): SourcererScopeById => sourcerer.sourcererScopes; @@ -38,14 +46,14 @@ export const configIndexPatternsSelector = () => ); export const getIndexNamesSelectedSelector = () => { - const getScopesSelector = scopesSelector(); + const getScopeSelector = scopeIdSelector(); const getConfigIndexPatternsSelector = configIndexPatternsSelector(); const mapStateToProps = ( state: State, scopeId: SourcererScopeName ): { indexNames: string[]; previousIndexNames: string } => { - const scope = getScopesSelector(state)[scopeId]; + const scope = getScopeSelector(state, scopeId); const configIndexPatterns = getConfigIndexPatternsSelector(state); return { indexNames: @@ -72,39 +80,28 @@ export const getAllExistingIndexNamesSelector = () => { return mapStateToProps; }; -export const defaultIndexNamesSelector = () => { - const getScopesSelector = scopesSelector(); - const getConfigIndexPatternsSelector = configIndexPatternsSelector(); - - const mapStateToProps = (state: State, scopeId: SourcererScopeName): string[] => { - const scope = getScopesSelector(state)[scopeId]; - const configIndexPatterns = getConfigIndexPatternsSelector(state); - - return scope.selectedPatterns.length === 0 ? configIndexPatterns : scope.selectedPatterns; - }; - - return mapStateToProps; -}; - const EXCLUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*'; + export const getSourcererScopeSelector = () => { - const getScopesSelector = scopesSelector(); + const getScopeIdSelector = scopeIdSelector(); + const getSelectedPatterns = memoizeOne((selectedPatternsStr: string): string[] => { + const selectedPatterns = selectedPatternsStr.length > 0 ? selectedPatternsStr.split(',') : []; + return selectedPatterns.some((index) => index === 'logs-*') + ? [...selectedPatterns, EXCLUDE_ELASTIC_CLOUD_INDEX] + : selectedPatterns; + }); const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => { - const selectedPatterns = getScopesSelector(state)[scopeId].selectedPatterns.some( - (index) => index === 'logs-*' - ) - ? [...getScopesSelector(state)[scopeId].selectedPatterns, EXCLUDE_ELASTIC_CLOUD_INDEX] - : getScopesSelector(state)[scopeId].selectedPatterns; + const scope = getScopeIdSelector(state, scopeId); + const selectedPatterns = getSelectedPatterns(scope.selectedPatterns.sort().join()); return { - ...getScopesSelector(state)[scopeId], + ...scope, selectedPatterns, indexPattern: { - ...getScopesSelector(state)[scopeId].indexPattern, + ...scope.indexPattern, title: selectedPatterns.join(), }, }; }; - return mapStateToProps; }; diff --git a/x-pack/plugins/security_solution/public/common/utils/kql/use_update_kql.test.tsx b/x-pack/plugins/security_solution/public/common/utils/kql/use_update_kql.test.tsx deleted file mode 100644 index 1a31e08fc3dbc..0000000000000 --- a/x-pack/plugins/security_solution/public/common/utils/kql/use_update_kql.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { applyKqlFilterQuery as dispatchApplyTimelineFilterQuery } from '../../../timelines/store/timeline/actions'; - -import { mockIndexPattern } from '../../mock/index_pattern'; -import { useUpdateKql } from './use_update_kql'; - -const mockDispatch = jest.fn(); -mockDispatch.mockImplementation((fn) => fn); - -const applyTimelineKqlMock: jest.Mock = (dispatchApplyTimelineFilterQuery as unknown) as jest.Mock; - -jest.mock('../../../timelines/store/timeline/actions', () => ({ - applyKqlFilterQuery: jest.fn(), -})); - -describe('#useUpdateKql', () => { - beforeEach(() => { - mockDispatch.mockClear(); - applyTimelineKqlMock.mockClear(); - }); - - test('We should apply timeline kql', () => { - useUpdateKql({ - indexPattern: mockIndexPattern, - kueryFilterQuery: { expression: '', kind: 'kuery' }, - kueryFilterQueryDraft: { expression: 'host.name: "myLove"', kind: 'kuery' }, - storeType: 'timelineType', - timelineId: 'myTimelineId', - })(mockDispatch); - expect(applyTimelineKqlMock).toHaveBeenCalledWith({ - filterQuery: { - kuery: { - expression: 'host.name: "myLove"', - kind: 'kuery', - }, - serializedQuery: - '{"bool":{"should":[{"match_phrase":{"host.name":"myLove"}}],"minimum_should_match":1}}', - }, - id: 'myTimelineId', - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/utils/kql/use_update_kql.tsx b/x-pack/plugins/security_solution/public/common/utils/kql/use_update_kql.tsx deleted file mode 100644 index d1f5b40086cea..0000000000000 --- a/x-pack/plugins/security_solution/public/common/utils/kql/use_update_kql.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Dispatch } from 'redux'; -import { IIndexPattern } from 'src/plugins/data/public'; -import deepEqual from 'fast-deep-equal'; - -import { KueryFilterQuery } from '../../store'; -import { applyKqlFilterQuery as dispatchApplyTimelineFilterQuery } from '../../../timelines/store/timeline/actions'; -import { convertKueryToElasticSearchQuery } from '../../lib/keury'; -import { RefetchKql } from '../../store/inputs/model'; - -interface UseUpdateKqlProps { - indexPattern: IIndexPattern; - kueryFilterQuery: KueryFilterQuery | null; - kueryFilterQueryDraft: KueryFilterQuery | null; - storeType: 'timelineType'; - timelineId?: string; -} - -export const useUpdateKql = ({ - indexPattern, - kueryFilterQuery, - kueryFilterQueryDraft, - storeType, - timelineId, -}: UseUpdateKqlProps): RefetchKql => { - const updateKql: RefetchKql = (dispatch: Dispatch) => { - if (kueryFilterQueryDraft != null && !deepEqual(kueryFilterQuery, kueryFilterQueryDraft)) { - if (storeType === 'timelineType' && timelineId != null) { - dispatch( - dispatchApplyTimelineFilterQuery({ - id: timelineId, - filterQuery: { - kuery: kueryFilterQueryDraft, - serializedQuery: convertKueryToElasticSearchQuery( - kueryFilterQueryDraft.expression, - indexPattern - ), - }, - }) - ); - } - return true; - } - return false; - }; - return updateKql; -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 92657df7f9bb5..55258af7332e1 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -22,6 +22,7 @@ import { Ecs } from '../../../../common/ecs'; import { TimelineId, TimelineType, TimelineStatus } from '../../../../common/types/timeline'; import { ISearchStart } from '../../../../../../../src/plugins/data/public'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { TimelineTabs } from '../../../timelines/store/timeline/model'; jest.mock('apollo-client'); @@ -101,6 +102,7 @@ describe('alert actions', () => { from: '2018-11-05T18:58:25.937Z', notes: null, timeline: { + activeTab: TimelineTabs.query, columns: [ { aggregatable: undefined, @@ -231,10 +233,6 @@ describe('alert actions', () => { }, serializedQuery: '', }, - filterQueryDraft: { - expression: '', - kind: 'kuery', - }, }, loadingEventIds: [], noteIds: [], @@ -271,9 +269,6 @@ describe('alert actions', () => { expression: [''], }, }, - filterQueryDraft: { - expression: [''], - }, }, }; jest.spyOn(apolloClient, 'query').mockResolvedValue(mockTimelineApolloResultModified); @@ -292,36 +287,6 @@ describe('alert actions', () => { expect(createTimelineArg.timeline.kqlQuery.filterQuery.kuery.kind).toEqual('kuery'); }); - test('it invokes createTimeline with kqlQuery.filterQueryDraft.kuery.kind as "kuery" if not specified in returned timeline template', async () => { - const mockTimelineApolloResultModified = { - ...mockTimelineApolloResult, - kqlQuery: { - filterQuery: { - kuery: { - expression: [''], - }, - }, - filterQueryDraft: { - expression: [''], - }, - }, - }; - jest.spyOn(apolloClient, 'query').mockResolvedValue(mockTimelineApolloResultModified); - - await sendAlertToTimelineAction({ - apolloClient, - createTimeline, - ecsData: mockEcsDataWithAlert, - nonEcsData: [], - updateTimelineIsLoading, - searchStrategyClient, - }); - const createTimelineArg = (createTimeline as jest.Mock).mock.calls[0][0]; - - expect(createTimeline).toHaveBeenCalledTimes(1); - expect(createTimelineArg.timeline.kqlQuery.filterQueryDraft.kind).toEqual('kuery'); - }); - test('it invokes createTimeline with default timeline if apolloClient throws', async () => { jest.spyOn(apolloClient, 'query').mockImplementation(() => { throw new Error('Test error'); 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 e3defaea2ec67..54cdd636f7a33 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 @@ -242,10 +242,6 @@ export const sendAlertToTimelineAction = async ({ }, serializedQuery: convertKueryToElasticSearchQuery(query), }, - filterQueryDraft: { - kind: timeline.kqlQuery?.filterQuery?.kuery?.kind ?? 'kuery', - expression: query, - }, }, noteIds: notes?.map((n) => n.noteId) ?? [], show: true, @@ -301,12 +297,6 @@ export const sendAlertToTimelineAction = async ({ ? ecsData.signal?.rule?.query[0] : '', }, - filterQueryDraft: { - kind: ecsData.signal?.rule?.language?.length - ? (ecsData.signal?.rule?.language[0] as KueryFilterQueryKind) - : 'kuery', - expression: ecsData.signal?.rule?.query?.length ? ecsData.signal?.rule?.query[0] : '', - }, }, }, to, @@ -366,10 +356,6 @@ export const sendAlertToTimelineAction = async ({ }, serializedQuery: '', }, - filterQueryDraft: { - kind: 'kuery', - expression: '', - }, }, }, to, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx index 662f37b999fab..fc7385f807cbe 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx @@ -161,6 +161,14 @@ const AlertsUtilityBarComponent: React.FC = ({ ); + const handleSelectAllAlertsClick = useCallback(() => { + if (!showClearSelection) { + selectAll(); + } else { + clearSelection(); + } + }, [clearSelection, selectAll, showClearSelection]); + return ( <> @@ -198,13 +206,7 @@ const AlertsUtilityBarComponent: React.FC = ({ aria-label="selectAllAlerts" dataTestSubj="selectAllAlertsButton" iconType={showClearSelection ? 'cross' : 'pagesSelect'} - onClick={() => { - if (!showClearSelection) { - selectAll(); - } else { - clearSelection(); - } - }} + onClick={handleSelectAllAlertsClick} > {showClearSelection ? i18n.CLEAR_SELECTION diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx index 4cb2abe756cf3..8242b44acc2c9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx @@ -77,14 +77,14 @@ export const QueryBarDefineRule = ({ resizeParentContainer, onValidityChange, }: QueryBarDefineRuleProps) => { + const { value: fieldValue, setValue: setFieldValue } = field as FieldHook; const [originalHeight, setOriginalHeight] = useState(-1); const [loadingTimeline, setLoadingTimeline] = useState(false); - const [savedQuery, setSavedQuery] = useState(null); - const [queryDraft, setQueryDraft] = useState({ query: '', language: 'kuery' }); + const [savedQuery, setSavedQuery] = useState(undefined); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - const kibana = useKibana(); - const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); + const { uiSettings } = useKibana().services; + const [filterManager] = useState(new FilterManager(uiSettings)); const savedQueryServices = useSavedQueryServices(); @@ -107,10 +107,10 @@ export const QueryBarDefineRule = ({ next: () => { if (isSubscribed) { const newFilters = filterManager.getFilters(); - const { filters } = field.value as FieldValueQueryBar; + const { filters } = fieldValue; if (!deepEqual(filters, newFilters)) { - field.setValue({ ...(field.value as FieldValueQueryBar), filters: newFilters }); + setFieldValue({ ...fieldValue, filters: newFilters }); } } }, @@ -121,16 +121,12 @@ export const QueryBarDefineRule = ({ isSubscribed = false; subscriptions.unsubscribe(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field.value]); + }, [fieldValue, filterManager, setFieldValue]); useEffect(() => { let isSubscribed = true; async function updateFilterQueryFromValue() { - const { filters, query, saved_id: savedId } = field.value as FieldValueQueryBar; - if (!deepEqual(query, queryDraft)) { - setQueryDraft(query); - } + const { filters, saved_id: savedId } = fieldValue; if (!deepEqual(filters, filterManager.getFilters())) { filterManager.setFilters(filters); } @@ -144,55 +140,63 @@ export const QueryBarDefineRule = ({ setSavedQuery(mySavedQuery); } } catch { - setSavedQuery(null); + setSavedQuery(undefined); } } else if (savedId == null && savedQuery != null) { - setSavedQuery(null); + setSavedQuery(undefined); } } updateFilterQueryFromValue(); return () => { isSubscribed = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field.value]); + }, [fieldValue, filterManager, savedQuery, savedQueryServices]); const onSubmitQuery = useCallback( (newQuery: Query) => { - const { query } = field.value as FieldValueQueryBar; + const { query } = fieldValue; if (!deepEqual(query, newQuery)) { - field.setValue({ ...(field.value as FieldValueQueryBar), query: newQuery }); + setFieldValue({ ...fieldValue, query: newQuery }); } }, - [field] + [fieldValue, setFieldValue] ); const onChangedQuery = useCallback( (newQuery: Query) => { - const { query } = field.value as FieldValueQueryBar; + const { query } = fieldValue; if (!deepEqual(query, newQuery)) { - field.setValue({ ...(field.value as FieldValueQueryBar), query: newQuery }); + setFieldValue({ ...fieldValue, query: newQuery }); } }, - [field] + [fieldValue, setFieldValue] ); const onSavedQuery = useCallback( - (newSavedQuery: SavedQuery | null) => { + (newSavedQuery: SavedQuery | undefined) => { if (newSavedQuery != null) { - const { saved_id: savedId } = field.value as FieldValueQueryBar; + const { saved_id: savedId } = fieldValue; if (newSavedQuery.id !== savedId) { setSavedQuery(newSavedQuery); - field.setValue({ - filters: newSavedQuery.attributes.filters, + setFieldValue({ + filters: newSavedQuery.attributes.filters ?? [], query: newSavedQuery.attributes.query, saved_id: newSavedQuery.id, }); + } else { + setSavedQuery(newSavedQuery); + setFieldValue({ + filters: [], + query: { + query: '', + language: 'kuery', + }, + saved_id: undefined, + }); } } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [field.value] + [fieldValue, setFieldValue] ); const onCloseTimelineModal = useCallback(() => { @@ -215,7 +219,7 @@ export const QueryBarDefineRule = ({ ) : ''; const newFilters = timeline.filters ?? []; - field.setValue({ + setFieldValue({ filters: dataProvidersDsl !== '' ? [...newFilters, getDataProviderFilter(dataProvidersDsl)] @@ -224,7 +228,7 @@ export const QueryBarDefineRule = ({ saved_id: undefined, }); }, - [browserFields, field, indexPattern] + [browserFields, indexPattern, setFieldValue] ); const onMutation = () => { @@ -272,7 +276,7 @@ export const QueryBarDefineRule = ({ indexPattern={indexPattern} isLoading={isLoading || loadingTimeline} isRefreshPaused={false} - filterQuery={queryDraft} + filterQuery={fieldValue.query} filterManager={filterManager} filters={filterManager.getFilters() || []} onChangedQuery={onChangedQuery} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index 0982b5740b893..9e629936db1e2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -17,8 +17,7 @@ import { TestProviders, SUB_PLUGINS_REDUCER, } from '../../../common/mock'; -import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; -import { DetectionEnginePageComponent } from './detection_engine'; +import { DetectionEnginePage } from './detection_engine'; import { useUserData } from '../../components/user_info'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { createStore, State } from '../../../common/store'; @@ -84,12 +83,7 @@ describe('DetectionEnginePageComponent', () => { const wrapper = mount( - + ); 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 b39cd37521602..13be87846df80 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 @@ -7,9 +7,10 @@ import { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React, { useCallback, useMemo, useState } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; +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'; @@ -18,11 +19,9 @@ import { FiltersGlobal } from '../../../common/components/filters_global'; import { getRulesUrl } from '../../../common/components/link_to/redirect_to_detection_engine'; import { SiemSearchBar } from '../../../common/components/search_bar'; import { WrapperPage } from '../../../common/components/wrapper_page'; -import { State } from '../../../common/store'; import { inputsSelectors } from '../../../common/store/inputs'; -import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; +import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; -import { InputsRange } from '../../../common/store/inputs/model'; import { useAlertInfo } from '../../components/alerts_info'; import { AlertsTable } from '../../components/alerts_table'; import { NoApiIntegrationKeyCallOut } from '../../components/no_api_integration_callout'; @@ -43,17 +42,24 @@ import { Display } from '../../../hosts/pages/display'; import { showGlobalFilters } from '../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; -import { TimelineModel } from '../../../timelines/store/timeline/model'; import { buildShowBuildingBlockFilter } from '../../components/alerts_table/default_config'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; -export const DetectionEnginePageComponent: React.FC = ({ - filters, - graphEventId, - query, - setAbsoluteRangeDatePicker, -}) => { +const DetectionEnginePageComponent = () => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => (getTimeline(state, TimelineId.detectionsPage) ?? timelineDefaults).graphEventId + ); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + const { to, from, deleteQuery, setQuery } = useGlobalTime(); const { globalFullScreen } = useFullScreen(); const [ @@ -83,13 +89,15 @@ export const DetectionEnginePageComponent: React.FC = ({ return; } const [min, max] = x; - setAbsoluteRangeDatePicker({ - id: 'global', - from: new Date(min).toISOString(), - to: new Date(max).toISOString(), - }); + dispatch( + setAbsoluteRangeDatePicker({ + id: 'global', + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }) + ); }, - [setAbsoluteRangeDatePicker] + [dispatch] ); const goToRules = useCallback( @@ -215,31 +223,4 @@ export const DetectionEnginePageComponent: React.FC = ({ ); }; -const makeMapStateToProps = () => { - const getGlobalInputs = inputsSelectors.globalSelector(); - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - return (state: State) => { - const globalInputs: InputsRange = getGlobalInputs(state); - const { query, filters } = globalInputs; - - const timeline: TimelineModel = - getTimeline(state, TimelineId.detectionsPage) ?? timelineDefaults; - const { graphEventId } = timeline; - - return { - query, - filters, - graphEventId, - }; - }; -}; - -const mapDispatchToProps = { - setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, -}; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const DetectionEnginePage = connector(React.memo(DetectionEnginePageComponent)); +export const DetectionEnginePage = React.memo(DetectionEnginePageComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx index afa4777e74856..88aff1455ab0e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx @@ -17,9 +17,8 @@ import { TestProviders, SUB_PLUGINS_REDUCER, } from '../../../../../common/mock'; -import { RuleDetailsPageComponent } from './index'; +import { RuleDetailsPage } from './index'; import { createStore, State } from '../../../../../common/store'; -import { setAbsoluteRangeDatePicker } from '../../../../../common/store/inputs/actions'; import { useUserData } from '../../../../components/user_info'; import { useSourcererScope } from '../../../../../common/containers/sourcerer'; import { useParams } from 'react-router-dom'; @@ -82,17 +81,9 @@ describe('RuleDetailsPageComponent', () => { const wrapper = mount( - + - , - { - wrappingComponent: TestProviders, - } + ); await waitFor(() => { expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index d04980d764831..62f0d12fd67b1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -19,10 +19,14 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { noop } from 'lodash/fp'; -import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams, useHistory } from 'react-router-dom'; -import { connect, ConnectedProps } from 'react-redux'; +import { useDispatch } from 'react-redux'; +import { + useDeepEqualSelector, + useShallowEqualSelector, +} from '../../../../../common/hooks/use_selector'; import { useKibana } from '../../../../../common/lib/kibana'; import { TimelineId } from '../../../../../../common/types/timeline'; import { UpdateDateRange } from '../../../../../common/components/charts/common'; @@ -62,9 +66,7 @@ import * as i18n from './translations'; import { useGlobalTime } from '../../../../../common/containers/use_global_time'; import { alertsHistogramOptions } from '../../../../components/alerts_histogram_panel/config'; import { inputsSelectors } from '../../../../../common/store/inputs'; -import { State } from '../../../../../common/store'; -import { InputsRange } from '../../../../../common/store/inputs/model'; -import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../../common/store/inputs/actions'; +import { setAbsoluteRangeDatePicker } from '../../../../../common/store/inputs/actions'; import { RuleActionsOverflow } from '../../../../components/rules/rule_actions_overflow'; import { RuleStatusFailedCallOut } from './status_failed_callout'; import { FailureHistory } from './failure_history'; @@ -85,7 +87,6 @@ import { useRuleAsync } from '../../../../containers/detection_engine/rules/use_ import { showGlobalFilters } from '../../../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../../../timelines/store/timeline'; import { timelineDefaults } from '../../../../../timelines/store/timeline/defaults'; -import { TimelineModel } from '../../../../../timelines/store/timeline/model'; import { useSourcererScope } from '../../../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../../../common/store/sourcerer/model'; import { @@ -126,12 +127,21 @@ const getRuleDetailsTabs = (rule: Rule | null) => { ]; }; -export const RuleDetailsPageComponent: FC = ({ - filters, - graphEventId, - query, - setAbsoluteRangeDatePicker, -}) => { +const RuleDetailsPageComponent = () => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => + (getTimeline(state, TimelineId.detectionsRulesDetailsPage) ?? timelineDefaults).graphEventId + ); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + const { to, from, deleteQuery, setQuery } = useGlobalTime(); const [ { @@ -308,13 +318,15 @@ export const RuleDetailsPageComponent: FC = ({ return; } const [min, max] = x; - setAbsoluteRangeDatePicker({ - id: 'global', - from: new Date(min).toISOString(), - to: new Date(max).toISOString(), - }); + dispatch( + setAbsoluteRangeDatePicker({ + id: 'global', + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }) + ); }, - [setAbsoluteRangeDatePicker] + [dispatch] ); const handleOnChangeEnabledRule = useCallback( @@ -594,33 +606,6 @@ export const RuleDetailsPageComponent: FC = ({ RuleDetailsPageComponent.displayName = 'RuleDetailsPageComponent'; -const makeMapStateToProps = () => { - const getGlobalInputs = inputsSelectors.globalSelector(); - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - return (state: State) => { - const globalInputs: InputsRange = getGlobalInputs(state); - const { query, filters } = globalInputs; - - const timeline: TimelineModel = - getTimeline(state, TimelineId.detectionsRulesDetailsPage) ?? timelineDefaults; - const { graphEventId } = timeline; - - return { - query, - filters, - graphEventId, - }; - }; -}; - -const mapDispatchToProps = { - setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, -}; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const RuleDetailsPage = connector(memo(RuleDetailsPageComponent)); +export const RuleDetailsPage = React.memo(RuleDetailsPageComponent); RuleDetailsPage.displayName = 'RuleDetailsPage'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap index 242affbed2979..ed119568cdcb3 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Authentication Table Component rendering it renders the authentication table 1`] = ` - { ); - expect(wrapper.find('Connect(AuthenticationTableComponent)')).toMatchSnapshot(); + expect(wrapper.find('Memo(AuthenticationTableComponent)')).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx index 88fd1ad5f98b0..7d8a1a1eebdd0 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx @@ -8,11 +8,10 @@ import { has } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { AuthenticationsEdges } from '../../../../common/search_strategy/security_solution/hosts/authentications'; -import { State } from '../../../common/store'; import { DragEffects, DraggableWrapper, @@ -25,6 +24,7 @@ import { Columns, ItemsPerRow, PaginatedTable } from '../../../common/components import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; import { getRowItemDraggables } from '../../../common/components/tables/helpers'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { hostsActions, hostsModel, hostsSelectors } from '../../store'; @@ -32,7 +32,7 @@ import * as i18n from './translations'; const tableType = hostsModel.HostsTableType.authentications; -interface OwnProps { +interface AuthenticationTableProps { data: AuthenticationsEdges[]; fakeTotalCount: number; loading: boolean; @@ -56,8 +56,6 @@ export type AuthTableColumns = [ Columns ]; -type AuthenticationTableProps = OwnProps & PropsFromRedux; - const rowItems: ItemsPerRow[] = [ { text: i18n.ROWS_5, @@ -69,87 +67,75 @@ const rowItems: ItemsPerRow[] = [ }, ]; -const AuthenticationTableComponent = React.memo( - ({ - activePage, - data, - fakeTotalCount, - id, - isInspect, - limit, - loading, - loadPage, - showMorePagesIndicator, - totalCount, - type, - updateTableActivePage, - updateTableLimit, - }) => { - const updateLimitPagination = useCallback( - (newLimit) => - updateTableLimit({ +const AuthenticationTableComponent: React.FC = ({ + data, + fakeTotalCount, + id, + isInspect, + loading, + loadPage, + showMorePagesIndicator, + totalCount, + type, +}) => { + const dispatch = useDispatch(); + const getAuthenticationsSelector = useMemo(() => hostsSelectors.authenticationsSelector(), []); + const { activePage, limit } = useDeepEqualSelector((state) => + getAuthenticationsSelector(state, type) + ); + + const updateLimitPagination = useCallback( + (newLimit) => + dispatch( + hostsActions.updateTableLimit({ hostsType: type, limit: newLimit, tableType, - }), - [type, updateTableLimit] - ); + }) + ), + [type, dispatch] + ); - const updateActivePage = useCallback( - (newPage) => - updateTableActivePage({ + const updateActivePage = useCallback( + (newPage) => + dispatch( + hostsActions.updateTableActivePage({ activePage: newPage, hostsType: type, tableType, - }), - [type, updateTableActivePage] - ); - - const columns = useMemo(() => getAuthenticationColumnsCurated(type), [type]); - - return ( - - ); - } -); - -AuthenticationTableComponent.displayName = 'AuthenticationTableComponent'; + }) + ), + [type, dispatch] + ); -const makeMapStateToProps = () => { - const getAuthenticationsSelector = hostsSelectors.authenticationsSelector(); - return (state: State, { type }: OwnProps) => { - return getAuthenticationsSelector(state, type); - }; -}; + const columns = useMemo(() => getAuthenticationColumnsCurated(type), [type]); -const mapDispatchToProps = { - updateTableActivePage: hostsActions.updateTableActivePage, - updateTableLimit: hostsActions.updateTableLimit, + return ( + + ); }; -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; +AuthenticationTableComponent.displayName = 'AuthenticationTableComponent'; -export const AuthenticationTable = connector(AuthenticationTableComponent); +export const AuthenticationTable = React.memo(AuthenticationTableComponent); const getAuthenticationColumns = (): AuthTableColumns => [ { diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx index b78d1a1f493be..b8cf1bb3fbef6 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx @@ -5,7 +5,7 @@ */ import React, { useMemo, useCallback } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { assertUnreachable } from '../../../../common/utility_types'; import { @@ -17,7 +17,6 @@ import { HostsSortField, OsFields, } from '../../../graphql/types'; -import { State } from '../../../common/store'; import { Columns, Criteria, @@ -25,13 +24,14 @@ import { PaginatedTable, SortingBasicTable, } from '../../../common/components/paginated_table'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { hostsActions, hostsModel, hostsSelectors } from '../../store'; import { getHostsColumns } from './columns'; import * as i18n from './translations'; const tableType = hostsModel.HostsTableType.hosts; -interface OwnProps { +interface HostsTableProps { data: HostsEdges[]; fakeTotalCount: number; id: string; @@ -50,8 +50,6 @@ export type HostsTableColumns = [ Columns ]; -type HostsTableProps = OwnProps & PropsFromRedux; - const rowItems: ItemsPerRow[] = [ { text: i18n.ROWS_5, @@ -62,101 +60,100 @@ const rowItems: ItemsPerRow[] = [ numberOfRow: 10, }, ]; -const getSorting = ( - trigger: string, - sortField: HostsFields, - direction: Direction -): SortingBasicTable => ({ field: getNodeField(sortField), direction }); - -const HostsTableComponent = React.memo( - ({ - activePage, - data, - direction, - fakeTotalCount, - id, - isInspect, - limit, - loading, - loadPage, - showMorePagesIndicator, - sortField, - totalCount, - type, - updateHostsSort, - updateTableActivePage, - updateTableLimit, - }) => { - const updateLimitPagination = useCallback( - (newLimit) => - updateTableLimit({ +const getSorting = (sortField: HostsFields, direction: Direction): SortingBasicTable => ({ + field: getNodeField(sortField), + direction, +}); + +const HostsTableComponent: React.FC = ({ + data, + fakeTotalCount, + id, + isInspect, + loading, + loadPage, + showMorePagesIndicator, + totalCount, + type, +}) => { + const dispatch = useDispatch(); + const getHostsSelector = useMemo(() => hostsSelectors.hostsSelector(), []); + const { activePage, direction, limit, sortField } = useDeepEqualSelector((state) => + getHostsSelector(state, type) + ); + + const updateLimitPagination = useCallback( + (newLimit) => + dispatch( + hostsActions.updateTableLimit({ hostsType: type, limit: newLimit, tableType, - }), - [type, updateTableLimit] - ); - - const updateActivePage = useCallback( - (newPage) => - updateTableActivePage({ + }) + ), + [type, dispatch] + ); + + const updateActivePage = useCallback( + (newPage) => + dispatch( + hostsActions.updateTableActivePage({ activePage: newPage, hostsType: type, tableType, - }), - [type, updateTableActivePage] - ); - - const onChange = useCallback( - (criteria: Criteria) => { - if (criteria.sort != null) { - const sort: HostsSortField = { - field: getSortField(criteria.sort.field), - direction: criteria.sort.direction as Direction, - }; - if (sort.direction !== direction || sort.field !== sortField) { - updateHostsSort({ + }) + ), + [type, dispatch] + ); + + const onChange = useCallback( + (criteria: Criteria) => { + if (criteria.sort != null) { + const sort: HostsSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction as Direction, + }; + if (sort.direction !== direction || sort.field !== sortField) { + dispatch( + hostsActions.updateHostsSort({ sort, hostsType: type, - }); - } + }) + ); } - }, - [direction, sortField, type, updateHostsSort] - ); - - const hostsColumns = useMemo(() => getHostsColumns(), []); - - const sorting = useMemo(() => getSorting(`${sortField}-${direction}`, sortField, direction), [ - sortField, - direction, - ]); - - return ( - - ); - } -); + } + }, + [direction, sortField, type, dispatch] + ); + + const hostsColumns = useMemo(() => getHostsColumns(), []); + + const sorting = useMemo(() => getSorting(sortField, direction), [sortField, direction]); + + return ( + + ); +}; HostsTableComponent.displayName = 'HostsTableComponent'; @@ -180,25 +177,6 @@ const getNodeField = (field: HostsFields): string => { } assertUnreachable(field); }; - -const makeMapStateToProps = () => { - const getHostsSelector = hostsSelectors.hostsSelector(); - const mapStateToProps = (state: State, { type }: OwnProps) => { - return getHostsSelector(state, type); - }; - return mapStateToProps; -}; - -const mapDispatchToProps = { - updateHostsSort: hostsActions.updateHostsSort, - updateTableActivePage: hostsActions.updateTableActivePage, - updateTableLimit: hostsActions.updateTableLimit, -}; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const HostsTable = connector(HostsTableComponent); +export const HostsTable = React.memo(HostsTableComponent); HostsTable.displayName = 'HostsTable'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx index 84003e5dea5e9..17794323cc4da 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx @@ -72,4 +72,6 @@ const HostsKpiAuthenticationsComponent: React.FC = ({ ); }; +HostsKpiAuthenticationsComponent.displayName = 'HostsKpiAuthenticationsComponent'; + export const HostsKpiAuthentications = React.memo(HostsKpiAuthenticationsComponent); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx index 7c51a503092af..ead96f52a087f 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; - import { EuiFlexItem, EuiLoadingSpinner, EuiFlexGroup } from '@elastic/eui'; import styled from 'styled-components'; +import deepEqual from 'fast-deep-equal'; import { manageQuery } from '../../../../common/components/page/manage_query'; import { HostsKpiStrategyResponse } from '../../../../../common/search_strategy'; @@ -27,7 +27,7 @@ export const FlexGroup = styled(EuiFlexGroup)` FlexGroup.displayName = 'FlexGroup'; -export const HostsKpiBaseComponent = React.memo<{ +interface HostsKpiBaseComponentProps { fieldsMapping: Readonly; data: HostsKpiStrategyResponse; loading?: boolean; @@ -35,34 +35,46 @@ export const HostsKpiBaseComponent = React.memo<{ from: string; to: string; narrowDateRange: UpdateDateRange; -}>(({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { - const statItemsProps: StatItemsProps[] = useKpiMatrixStatus( - fieldsMapping, - data, - id, - from, - to, - narrowDateRange - ); +} - if (loading) { - return ( - - - - - +export const HostsKpiBaseComponent = React.memo( + ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { + const statItemsProps: StatItemsProps[] = useKpiMatrixStatus( + fieldsMapping, + data, + id, + from, + to, + narrowDateRange ); - } - return ( - - {statItemsProps.map((mappedStatItemProps) => ( - - ))} - - ); -}); + if (loading) { + return ( + + + + + + ); + } + + return ( + + {statItemsProps.map((mappedStatItemProps) => ( + + ))} + + ); + }, + (prevProps, nextProps) => + prevProps.fieldsMapping === nextProps.fieldsMapping && + prevProps.id === nextProps.id && + prevProps.loading === nextProps.loading && + prevProps.from === nextProps.from && + prevProps.to === nextProps.to && + prevProps.narrowDateRange === nextProps.narrowDateRange && + deepEqual(prevProps.data, nextProps.data) +); HostsKpiBaseComponent.displayName = 'HostsKpiBaseComponent'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx index c7025bb489ae4..f16ed8ceddf6f 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx @@ -7,13 +7,12 @@ /* eslint-disable react/display-name */ import React, { useCallback, useMemo } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { HostsUncommonProcessesEdges, HostsUncommonProcessItem, } from '../../../../common/search_strategy'; -import { State } from '../../../common/store'; import { hostsActions, hostsModel, hostsSelectors } from '../../store'; import { defaultToEmptyTag, getEmptyValue } from '../../../common/components/empty_value'; import { HostDetailsLink } from '../../../common/components/links'; @@ -22,8 +21,10 @@ import { Columns, ItemsPerRow, PaginatedTable } from '../../../common/components import * as i18n from './translations'; import { getRowItemDraggables } from '../../../common/components/tables/helpers'; import { HostsType } from '../../store/model'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; + const tableType = hostsModel.HostsTableType.uncommonProcesses; -interface OwnProps { +interface UncommonProcessTableProps { data: HostsUncommonProcessesEdges[]; fakeTotalCount: number; id: string; @@ -44,8 +45,6 @@ export type UncommonProcessTableColumns = [ Columns ]; -type UncommonProcessTableProps = OwnProps & PropsFromRedux; - const rowItems: ItemsPerRow[] = [ { text: i18n.ROWS_5, @@ -67,38 +66,47 @@ export const getArgs = (args: string[] | null | undefined): string | null => { const UncommonProcessTableComponent = React.memo( ({ - activePage, data, fakeTotalCount, id, isInspect, - limit, loading, loadPage, totalCount, showMorePagesIndicator, - updateTableActivePage, - updateTableLimit, type, }) => { + const dispatch = useDispatch(); + const getUncommonProcessesSelector = useMemo( + () => hostsSelectors.uncommonProcessesSelector(), + [] + ); + const { activePage, limit } = useDeepEqualSelector((state) => + getUncommonProcessesSelector(state, type) + ); + const updateLimitPagination = useCallback( (newLimit) => - updateTableLimit({ - hostsType: type, - limit: newLimit, - tableType, - }), - [type, updateTableLimit] + dispatch( + hostsActions.updateTableLimit({ + hostsType: type, + limit: newLimit, + tableType, + }) + ), + [type, dispatch] ); const updateActivePage = useCallback( (newPage) => - updateTableActivePage({ - activePage: newPage, - hostsType: type, - tableType, - }), - [type, updateTableActivePage] + dispatch( + hostsActions.updateTableActivePage({ + activePage: newPage, + hostsType: type, + tableType, + }) + ), + [type, dispatch] ); const columns = useMemo(() => getUncommonColumnsCurated(type), [type]); @@ -129,21 +137,7 @@ const UncommonProcessTableComponent = React.memo( UncommonProcessTableComponent.displayName = 'UncommonProcessTableComponent'; -const makeMapStateToProps = () => { - const getUncommonProcessesSelector = hostsSelectors.uncommonProcessesSelector(); - return (state: State, { type }: OwnProps) => getUncommonProcessesSelector(state, type); -}; - -const mapDispatchToProps = { - updateTableActivePage: hostsActions.updateTableActivePage, - updateTableLimit: hostsActions.updateTableLimit, -}; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const UncommonProcessTable = connector(UncommonProcessTableComponent); +export const UncommonProcessTable = React.memo(UncommonProcessTableComponent); UncommonProcessTable.displayName = 'UncommonProcessTable'; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx index d964366dc5f3d..87c0e6fd613f9 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { noop } from 'lodash/fp'; +import { noop, pick } from 'lodash/fp'; import { useCallback, useEffect, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; @@ -22,7 +22,7 @@ import { } from '../../../../common/search_strategy'; import { ESTermQuery } from '../../../../common/typed_json'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { inputsModel } from '../../../common/store'; import { createFilter } from '../../../common/containers/helpers'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; @@ -68,8 +68,8 @@ export const useAuthentications = ({ skip, }: UseAuthentications): [boolean, AuthenticationArgs] => { const getAuthenticationsSelector = hostsSelectors.authenticationsSelector(); - const { activePage, limit } = useShallowEqualSelector((state) => - getAuthenticationsSelector(state, type) + const { activePage, limit } = useDeepEqualSelector((state) => + pick(['activePage', 'limit'], getAuthenticationsSelector(state, type)) ); const { data, notifications } = useKibana().services; const refetch = useRef(noop); @@ -78,23 +78,7 @@ export const useAuthentications = ({ const [ authenticationsRequest, setAuthenticationsRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: HostsQueries.authentications, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - sort: {} as SortField, - } - : null - ); + ] = useState(null); const wrappedLoadMore = useCallback( (newActivePage: number) => { @@ -133,7 +117,7 @@ export const useAuthentications = ({ const authenticationsSearch = useCallback( (request: HostAuthenticationsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -188,7 +172,7 @@ export const useAuthentications = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -207,12 +191,12 @@ export const useAuthentications = ({ }, sort: {} as SortField, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [activePage, docValueFields, endDate, filterQuery, indexNames, limit, skip, startDate]); + }, [activePage, docValueFields, endDate, filterQuery, indexNames, limit, startDate]); useEffect(() => { authenticationsSearch(authenticationsRequest); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx index 54381d1ffd836..3f32d597b45f7 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx @@ -61,18 +61,7 @@ export const useHostDetails = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); const [hostDetailsRequest, setHostDetailsRequest] = useState( - !skip - ? { - defaultIndex: indexNames, - hostName, - factoryQueryType: HostsQueries.details, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null + null ); const [hostDetailsResponse, setHostDetailsResponse] = useState({ @@ -89,7 +78,7 @@ export const useHostDetails = ({ const hostDetailsSearch = useCallback( (request: HostDetailsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -143,7 +132,7 @@ export const useHostDetails = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -159,12 +148,12 @@ export const useHostDetails = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [endDate, hostName, indexNames, startDate, skip]); + }, [endDate, hostName, indexNames, startDate]); useEffect(() => { hostDetailsSearch(hostDetailsRequest); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx index c1081d22e12a4..f7899fe016571 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx @@ -6,12 +6,12 @@ import deepEqual from 'fast-deep-equal'; import { noop } from 'lodash/fp'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { inputsModel, State } from '../../../common/store'; import { createFilter } from '../../../common/containers/helpers'; import { useKibana } from '../../../common/lib/kibana'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { hostsModel, hostsSelectors } from '../../store'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; import { @@ -65,34 +65,15 @@ export const useAllHost = ({ startDate, type, }: UseAllHost): [boolean, HostsArgs] => { - const getHostsSelector = hostsSelectors.hostsSelector(); - const { activePage, direction, limit, sortField } = useShallowEqualSelector((state: State) => + const getHostsSelector = useMemo(() => hostsSelectors.hostsSelector(), []); + const { activePage, direction, limit, sortField } = useDeepEqualSelector((state: State) => getHostsSelector(state, type) ); const { data, notifications } = useKibana().services; const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostsRequest, setHostRequest] = useState( - !skip - ? { - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: HostsQueries.hosts, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - sort: { - direction, - field: sortField, - }, - } - : null - ); + const [hostsRequest, setHostRequest] = useState(null); const wrappedLoadMore = useCallback( (newActivePage: number) => { @@ -132,7 +113,7 @@ export const useAllHost = ({ const hostsSearch = useCallback( (request: HostsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -185,7 +166,7 @@ export const useAllHost = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -207,7 +188,7 @@ export const useAllHost = ({ field: sortField, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; @@ -220,7 +201,6 @@ export const useAllHost = ({ filterQuery, indexNames, limit, - skip, startDate, sortField, ]); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx index 3564b9f4516d9..f0395a5064e2d 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx @@ -55,20 +55,7 @@ export const useHostsKpiAuthentications = ({ const [ hostsKpiAuthenticationsRequest, setHostsKpiAuthenticationsRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: HostsKpiQueries.kpiAuthentications, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + ] = useState(null); const [ hostsKpiAuthenticationsResponse, @@ -89,7 +76,7 @@ export const useHostsKpiAuthentications = ({ const hostsKpiAuthenticationsSearch = useCallback( (request: HostsKpiAuthenticationsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -149,7 +136,7 @@ export const useHostsKpiAuthentications = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -165,12 +152,12 @@ export const useHostsKpiAuthentications = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, skip, startDate]); + }, [indexNames, endDate, filterQuery, startDate]); useEffect(() => { hostsKpiAuthenticationsSearch(hostsKpiAuthenticationsRequest); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx index ff4539fd379ed..b810d4e724eec 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx @@ -54,20 +54,7 @@ export const useHostsKpiHosts = ({ const [ hostsKpiHostsRequest, setHostsKpiHostsRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: HostsKpiQueries.kpiHosts, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + ] = useState(null); const [hostsKpiHostsResponse, setHostsKpiHostsResponse] = useState({ hosts: 0, @@ -83,7 +70,7 @@ export const useHostsKpiHosts = ({ const hostsKpiHostsSearch = useCallback( (request: HostsKpiHostsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -138,7 +125,7 @@ export const useHostsKpiHosts = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -154,12 +141,12 @@ export const useHostsKpiHosts = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, skip, startDate]); + }, [indexNames, endDate, filterQuery, startDate]); useEffect(() => { hostsKpiHostsSearch(hostsKpiHostsRequest); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx index 906a1d2716513..70cfd5fa957e7 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx @@ -55,20 +55,7 @@ export const useHostsKpiUniqueIps = ({ const [ hostsKpiUniqueIpsRequest, setHostsKpiUniqueIpsRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: HostsKpiQueries.kpiUniqueIps, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + ] = useState(null); const [hostsKpiUniqueIpsResponse, setHostsKpiUniqueIpsResponse] = useState( { @@ -88,7 +75,7 @@ export const useHostsKpiUniqueIps = ({ const hostsKpiUniqueIpsSearch = useCallback( (request: HostsKpiUniqueIpsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -145,7 +132,7 @@ export const useHostsKpiUniqueIps = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -161,7 +148,7 @@ export const useHostsKpiUniqueIps = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx index 821b2895ac3f9..12dc5ed3a267d 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx @@ -6,8 +6,7 @@ import deepEqual from 'fast-deep-equal'; import { noop } from 'lodash/fp'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; import { AbortError } from '../../../../../../../src/plugins/kibana_utils/common'; @@ -31,6 +30,7 @@ import * as i18n from './translations'; import { ESTermQuery } from '../../../../common/typed_json'; import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; const ID = 'hostsUncommonProcessesQuery'; @@ -64,8 +64,11 @@ export const useUncommonProcesses = ({ startDate, type, }: UseUncommonProcesses): [boolean, UncommonProcessesArgs] => { - const getUncommonProcessesSelector = hostsSelectors.uncommonProcessesSelector(); - const { activePage, limit } = useSelector((state: State) => + const getUncommonProcessesSelector = useMemo( + () => hostsSelectors.uncommonProcessesSelector(), + [] + ); + const { activePage, limit } = useDeepEqualSelector((state: State) => getUncommonProcessesSelector(state, type) ); const { data, notifications } = useKibana().services; @@ -75,23 +78,7 @@ export const useUncommonProcesses = ({ const [ uncommonProcessesRequest, setUncommonProcessesRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: HostsQueries.uncommonProcesses, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - sort: {} as SortField, - } - : null - ); + ] = useState(null); const wrappedLoadMore = useCallback( (newActivePage: number) => { @@ -131,7 +118,7 @@ export const useUncommonProcesses = ({ const uncommonProcessesSearch = useCallback( (request: HostsUncommonProcessesRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -189,7 +176,7 @@ export const useUncommonProcesses = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -208,12 +195,12 @@ export const useUncommonProcesses = ({ }, sort: {} as SortField, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [activePage, indexNames, docValueFields, endDate, filterQuery, limit, skip, startDate]); + }, [activePage, indexNames, docValueFields, endDate, filterQuery, limit, startDate]); useEffect(() => { uncommonProcessesSearch(uncommonProcessesRequest); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index a8b46769b7363..58474f05bb2b9 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -7,7 +7,7 @@ import { EuiHorizontalRule, EuiSpacer, EuiWindowEvent } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React, { useEffect, useCallback, useMemo } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { HostItem, LastEventIndexKey } from '../../../../common/search_strategy'; import { SecurityPageName } from '../../../app/types'; @@ -30,9 +30,9 @@ import { HostOverviewByNameQuery } from '../../containers/hosts/details'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { useKibana } from '../../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../../common/lib/keury'; -import { inputsSelectors, State } from '../../../common/store'; -import { setHostDetailsTablesActivePageToZero as dispatchHostDetailsTablesActivePageToZero } from '../../store/actions'; -import { setAbsoluteRangeDatePicker as dispatchAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; +import { inputsSelectors } from '../../../common/store'; +import { setHostDetailsTablesActivePageToZero } from '../../store/actions'; +import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; import { esQuery, Filter } from '../../../../../../../src/plugins/data/public'; @@ -46,201 +46,185 @@ import { showGlobalFilters } from '../../../timelines/components/timeline/helper import { useFullScreen } from '../../../common/containers/use_full_screen'; import { Display } from '../display'; import { timelineSelectors } from '../../../timelines/store/timeline'; -import { TimelineModel } from '../../../timelines/store/timeline/model'; import { TimelineId } from '../../../../common/types/timeline'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { useSourcererScope } from '../../../common/containers/sourcerer'; +import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; const HostOverviewManage = manageQuery(HostOverview); -const HostDetailsComponent = React.memo( - ({ - filters, - graphEventId, - query, - setAbsoluteRangeDatePicker, - setHostDetailsTablesActivePageToZero, +const HostDetailsComponent: React.FC = ({ detailName, hostDetailsPagePath }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => (getTimeline(state, TimelineId.hostsPageEvents) ?? timelineDefaults).graphEventId + ); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { globalFullScreen } = useFullScreen(); + + const capabilities = useMlCapabilities(); + const kibana = useKibana(); + const hostDetailsPageFilters: Filter[] = useMemo(() => getHostDetailsPageFilters(detailName), [ detailName, - hostDetailsPagePath, - }) => { - const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); - useEffect(() => { - setHostDetailsTablesActivePageToZero(); - }, [setHostDetailsTablesActivePageToZero, detailName]); - const capabilities = useMlCapabilities(); - const kibana = useKibana(); - const hostDetailsPageFilters: Filter[] = useMemo(() => getHostDetailsPageFilters(detailName), [ - detailName, - ]); - const getFilters = () => [...hostDetailsPageFilters, ...filters]; - const narrowDateRange = useCallback( - ({ x }) => { - if (!x) { - return; - } - const [min, max] = x; + ]); + const getFilters = () => [...hostDetailsPageFilters, ...filters]; + + const narrowDateRange = useCallback( + ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + dispatch( setAbsoluteRangeDatePicker({ id: 'global', from: new Date(min).toISOString(), to: new Date(max).toISOString(), - }); - }, - [setAbsoluteRangeDatePicker] - ); - const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); - const filterQuery = convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters: getFilters(), - }); - - return ( - <> - {indicesExist ? ( - <> - - - - - - - - - } - title={detailName} - /> - - - {({ hostOverview, loading, id, inspect, refetch }) => ( - - {({ isLoadingAnomaliesData, anomaliesData }) => ( - { - const fromTo = scoreIntervalToDateTime(score, interval); - setAbsoluteRangeDatePicker({ - id: 'global', - from: fromTo.from, - to: fromTo.to, - }); - }} - /> - )} - - )} - - - - - - - - - - - - - - { + dispatch(setHostDetailsTablesActivePageToZero()); + }, [dispatch, detailName]); + + return ( + <> + {indicesExist ? ( + <> + + + + + + + + + } + title={detailName} + /> + + + {({ hostOverview, loading, id, inspect, refetch }) => ( + + {({ isLoadingAnomaliesData, anomaliesData }) => ( + { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }} + /> + )} + + )} + + + + + - - - ) : ( - - - - - )} + - - - ); - } -); -HostDetailsComponent.displayName = 'HostDetailsComponent'; - -export const makeMapStateToProps = () => { - const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); - const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - return (state: State) => { - const timeline: TimelineModel = - getTimeline(state, TimelineId.hostsPageEvents) ?? timelineDefaults; - const { graphEventId } = timeline; - - return { - query: getGlobalQuerySelector(state), - filters: getGlobalFiltersQuerySelector(state), - graphEventId, - }; - }; -}; + -const mapDispatchToProps = { - setAbsoluteRangeDatePicker: dispatchAbsoluteRangeDatePicker, - setHostDetailsTablesActivePageToZero: dispatchHostDetailsTablesActivePageToZero, + + + + + + + ) : ( + + + + + + )} + + + + ); }; -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; +HostDetailsComponent.displayName = 'HostDetailsComponent'; -export const HostDetails = connector(HostDetailsComponent); +export const HostDetails = React.memo(HostDetailsComponent); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx index b341647afdfbc..4a614cd0d1de5 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx @@ -21,7 +21,6 @@ import { import { SiemNavigation } from '../../common/components/navigation'; import { inputsActions } from '../../common/store/inputs'; import { State, createStore } from '../../common/store'; -import { HostsComponentProps } from './types'; import { Hosts } from './hosts'; import { HostsTabs } from './hosts_tabs'; import { useSourcererScope } from '../../common/containers/sourcerer'; @@ -60,10 +59,6 @@ const mockHistory = { }; const mockUseSourcererScope = useSourcererScope as jest.Mock; describe('Hosts - rendering', () => { - const hostProps: HostsComponentProps = { - hostsPagePath: '', - }; - test('it renders the Setup Instructions text when no index is available', async () => { mockUseSourcererScope.mockReturnValue({ indicesExist: false, @@ -72,7 +67,7 @@ describe('Hosts - rendering', () => { const wrapper = mount( - + ); @@ -87,7 +82,7 @@ describe('Hosts - rendering', () => { const wrapper = mount( - + ); @@ -103,7 +98,7 @@ describe('Hosts - rendering', () => { const wrapper = mount( - + ); @@ -158,7 +153,7 @@ describe('Hosts - rendering', () => { const wrapper = mount( - + ); 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 4835f7eff5b6f..d54891ba573fd 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -6,8 +6,8 @@ import { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { useCallback } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; import { SecurityPageName } from '../../app/types'; @@ -26,8 +26,8 @@ import { TimelineId } from '../../../common/types/timeline'; import { LastEventIndexKey } from '../../../common/search_strategy'; import { useKibana } from '../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../common/lib/keury'; -import { inputsSelectors, State } from '../../common/store'; -import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../common/store/inputs/actions'; +import { inputsSelectors } from '../../common/store'; +import { setAbsoluteRangeDatePicker } from '../../common/store/inputs/actions'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { esQuery } from '../../../../../../src/plugins/data/public'; @@ -37,156 +37,149 @@ import { Display } from './display'; import { HostsTabs } from './hosts_tabs'; import { navTabsHosts } from './nav_tabs'; import * as i18n from './translations'; -import { HostsComponentProps } from './types'; import { filterHostData } from './navigation'; import { hostsModel } from '../store'; import { HostsTableType } from '../store/model'; import { showGlobalFilters } from '../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../timelines/store/timeline'; import { timelineDefaults } from '../../timelines/store/timeline/defaults'; -import { TimelineModel } from '../../timelines/store/timeline/model'; import { useSourcererScope } from '../../common/containers/sourcerer'; - -export const HostsComponent = React.memo( - ({ filters, graphEventId, query, setAbsoluteRangeDatePicker, hostsPagePath }) => { - const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); - const capabilities = useMlCapabilities(); - const kibana = useKibana(); - const { tabName } = useParams<{ tabName: string }>(); - const tabsFilters = React.useMemo(() => { - if (tabName === HostsTableType.alerts) { - return filters.length > 0 ? [...filters, ...filterHostData] : filterHostData; +import { useDeepEqualSelector, useShallowEqualSelector } from '../../common/hooks/use_selector'; + +const HostsComponent = () => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => + ( + getTimeline(state, TimelineId.hostsPageEvents) ?? + getTimeline(state, TimelineId.hostsPageExternalAlerts) ?? + timelineDefaults + ).graphEventId + ); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { globalFullScreen } = useFullScreen(); + const capabilities = useMlCapabilities(); + const { uiSettings } = useKibana().services; + const { tabName } = useParams<{ tabName: string }>(); + const tabsFilters = React.useMemo(() => { + if (tabName === HostsTableType.alerts) { + return filters.length > 0 ? [...filters, ...filterHostData] : filterHostData; + } + return filters; + }, [tabName, filters]); + const narrowDateRange = useCallback( + ({ x }) => { + if (!x) { + return; } - return filters; - }, [tabName, filters]); - const narrowDateRange = useCallback( - ({ x }) => { - if (!x) { - return; - } - const [min, max] = x; + const [min, max] = x; + dispatch( setAbsoluteRangeDatePicker({ id: 'global', from: new Date(min).toISOString(), to: new Date(max).toISOString(), - }); - }, - [setAbsoluteRangeDatePicker] - ); - const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); - const filterQuery = convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters, - }); - const tabsFilterQuery = convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters: tabsFilters, - }); - - return ( - <> - {indicesExist ? ( - <> - - - - - - - - - } - title={i18n.PAGE_TITLE} - /> - - - - - - - - - + }) + ); + }, + [dispatch] + ); + const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); + const filterQuery = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters, + }), + [filters, indexPattern, uiSettings, query] + ); + const tabsFilterQuery = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters: tabsFilters, + }), + [indexPattern, query, tabsFilters, uiSettings] + ); + + return ( + <> + {indicesExist ? ( + <> + + + + + + + + + } + title={i18n.PAGE_TITLE} + /> - - - - ) : ( - - - + + + + + + + + - )} - - - - ); - } -); -HostsComponent.displayName = 'HostsComponent'; - -const makeMapStateToProps = () => { - const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); - const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const mapStateToProps = (state: State) => { - const hostsPageEventsTimeline: TimelineModel = - getTimeline(state, TimelineId.hostsPageEvents) ?? timelineDefaults; - const { graphEventId: hostsPageEventsGraphEventId } = hostsPageEventsTimeline; - - const hostsPageExternalAlertsTimeline: TimelineModel = - getTimeline(state, TimelineId.hostsPageExternalAlerts) ?? timelineDefaults; - const { graphEventId: hostsPageExternalAlertsGraphEventId } = hostsPageExternalAlertsTimeline; - - return { - query: getGlobalQuerySelector(state), - filters: getGlobalFiltersQuerySelector(state), - graphEventId: hostsPageEventsGraphEventId ?? hostsPageExternalAlertsGraphEventId, - }; - }; - - return mapStateToProps; + + ) : ( + + + + + + )} + + + + ); }; +HostsComponent.displayName = 'HostsComponent'; -const mapDispatchToProps = { - setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, -}; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const Hosts = connector(HostsComponent); +export const Hosts = React.memo(HostsComponent); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx index 17dd20bac2d0d..0a2513828a68a 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx @@ -31,12 +31,38 @@ export const HostsTabs = memo( from, indexNames, isInitializing, - hostsPagePath, setAbsoluteRangeDatePicker, setQuery, to, type, }) => { + const narrowDateRange = useCallback( + (score: Anomaly, interval: string) => { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const updateDateRange = useCallback( + ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + setAbsoluteRangeDatePicker({ + id: 'global', + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }); + }, + [setAbsoluteRangeDatePicker] + ); + const tabProps = { deleteQuery, endDate: to, @@ -46,31 +72,8 @@ export const HostsTabs = memo( setQuery, startDate: from, type, - narrowDateRange: useCallback( - (score: Anomaly, interval: string) => { - const fromTo = scoreIntervalToDateTime(score, interval); - setAbsoluteRangeDatePicker({ - id: 'global', - from: fromTo.from, - to: fromTo.to, - }); - }, - [setAbsoluteRangeDatePicker] - ), - updateDateRange: useCallback( - ({ x }) => { - if (!x) { - return; - } - const [min, max] = x; - setAbsoluteRangeDatePicker({ - id: 'global', - from: new Date(min).toISOString(), - to: new Date(max).toISOString(), - }); - }, - [setAbsoluteRangeDatePicker] - ), + narrowDateRange, + updateDateRange, }; return ( diff --git a/x-pack/plugins/security_solution/public/hosts/pages/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/index.tsx index 75cd36924dbba..d0746bf78b249 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/index.tsx @@ -45,7 +45,7 @@ export const HostsContainer = React.memo(({ url }) => { )} /> - + ; - }; +export type HostsTabsProps = GlobalTimeArgs & { + docValueFields: DocValueFields[]; + filterQuery: string; + indexNames: string[]; + type: hostsModel.HostsType; + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: string; + to: string; + }>; +}; export type HostsQueryProps = GlobalTimeArgs; - -export interface HostsComponentProps { - hostsPagePath: string; -} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index a3d6cbea3ddc7..09c1fc1915d02 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -67,7 +67,7 @@ const NO_WRAP_TRUNCATE_STYLE: CSSProperties = Object.freeze({ }); const DangerEuiContextMenuItem = styled(EuiContextMenuItem)` - color: ${(props) => props.theme.eui.textColors.danger}; + color: ${(props) => props.theme.eui.euiColorDangerText}; `; // eslint-disable-next-line react/display-name diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx index ac7c5078e4ba0..f2f6a01482ee0 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx @@ -10,7 +10,6 @@ import React, { useEffect, useState, useMemo } from 'react'; import { createPortalNode, InPortal } from 'react-reverse-portal'; import styled, { css } from 'styled-components'; -import { useSelector } from 'react-redux'; import { ErrorEmbeddable, isErrorEmbeddable, @@ -30,6 +29,7 @@ import { Query, Filter } from '../../../../../../../src/plugins/data/public'; import { useKibana } from '../../../common/lib/kibana'; import { getDefaultSourcererSelector } from './selector'; import { getLayerList } from './map_config'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; interface EmbeddableMapProps { maintainRatio?: boolean; @@ -95,9 +95,8 @@ export const EmbeddedMapComponent = ({ const [, dispatchToaster] = useStateToaster(); const defaultSourcererScopeSelector = useMemo(getDefaultSourcererSelector, []); - const { kibanaIndexPatterns, sourcererScope } = useSelector( - defaultSourcererScopeSelector, - deepEqual + const { kibanaIndexPatterns, sourcererScope } = useDeepEqualSelector( + defaultSourcererScopeSelector ); const [mapIndexPatterns, setMapIndexPatterns] = useState( diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx index bf7cefd41463c..c3147df4d989e 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; - import { EuiFlexItem, EuiLoadingSpinner, EuiFlexGroup } from '@elastic/eui'; import styled from 'styled-components'; +import deepEqual from 'fast-deep-equal'; import { manageQuery } from '../../../../common/components/page/manage_query'; import { NetworkKpiStrategyResponse } from '../../../../../common/search_strategy'; @@ -35,34 +35,44 @@ export const NetworkKpiBaseComponent = React.memo<{ from: string; to: string; narrowDateRange: UpdateDateRange; -}>(({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { - const statItemsProps: StatItemsProps[] = useKpiMatrixStatus( - fieldsMapping, - data, - id, - from, - to, - narrowDateRange - ); +}>( + ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { + const statItemsProps: StatItemsProps[] = useKpiMatrixStatus( + fieldsMapping, + data, + id, + from, + to, + narrowDateRange + ); + + if (loading) { + return ( + + + + + + ); + } - if (loading) { return ( - - - - - + + {statItemsProps.map((mappedStatItemProps) => ( + + ))} + ); - } - - return ( - - {statItemsProps.map((mappedStatItemProps) => ( - - ))} - - ); -}); + }, + (prevProps, nextProps) => + prevProps.fieldsMapping === nextProps.fieldsMapping && + prevProps.loading === nextProps.loading && + prevProps.id === nextProps.id && + prevProps.from === nextProps.from && + prevProps.to === nextProps.to && + prevProps.narrowDateRange === nextProps.narrowDateRange && + deepEqual(prevProps.data, nextProps.data) +); NetworkKpiBaseComponent.displayName = 'NetworkKpiBaseComponent'; diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx index 0d5b379a62d38..1223926f35bbe 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx @@ -16,7 +16,7 @@ import { NetworkDnsFields, } from '../../../../common/search_strategy'; import { Criteria, ItemsPerRow, PaginatedTable } from '../../../common/components/paginated_table'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { getNetworkDnsColumns } from './columns'; import { IsPtrIncluded } from './is_ptr_included'; @@ -59,8 +59,9 @@ const NetworkDnsTableComponent: React.FC = ({ type, }) => { const dispatch = useDispatch(); - const getNetworkDnsSelector = networkSelectors.dnsSelector(); - const { activePage, isPtrIncluded, limit, sort } = useShallowEqualSelector(getNetworkDnsSelector); + const getNetworkDnsSelector = useMemo(() => networkSelectors.dnsSelector(), []); + const { activePage, isPtrIncluded, limit, sort } = useDeepEqualSelector(getNetworkDnsSelector); + const updateLimitPagination = useCallback( (newLimit) => dispatch( diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx index 6982388cafd9c..2700ca711a4e6 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx @@ -9,7 +9,7 @@ import { useDispatch } from 'react-redux'; import { networkActions, networkModel, networkSelectors } from '../../store'; import { NetworkHttpEdges, NetworkHttpFields } from '../../../../common/search_strategy'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { Criteria, ItemsPerRow, PaginatedTable } from '../../../common/components/paginated_table'; import { getNetworkHttpColumns } from './columns'; @@ -50,8 +50,8 @@ const NetworkHttpTableComponent: React.FC = ({ type, }) => { const dispatch = useDispatch(); - const getNetworkHttpSelector = networkSelectors.httpSelector(); - const { activePage, limit, sort } = useShallowEqualSelector((state) => + const getNetworkHttpSelector = useMemo(() => networkSelectors.httpSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state) => getNetworkHttpSelector(state, type) ); const tableType = diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx index 9b265aa002ccc..682d653db64cb 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx @@ -18,7 +18,7 @@ import { NetworkTopTablesFields, SortField, } from '../../../../common/search_strategy'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { Criteria, ItemsPerRow, PaginatedTable } from '../../../common/components/paginated_table'; @@ -66,8 +66,8 @@ const NetworkTopCountriesTableComponent: React.FC type, }) => { const dispatch = useDispatch(); - const getTopCountriesSelector = networkSelectors.topCountriesSelector(); - const { activePage, limit, sort } = useShallowEqualSelector((state) => + const getTopCountriesSelector = useMemo(() => networkSelectors.topCountriesSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state) => getTopCountriesSelector(state, type, flowTargeted) ); diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx index b1789569bed75..e068540efff2f 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx @@ -15,7 +15,7 @@ import { NetworkTopNFlowEdges, NetworkTopTablesFields, } from '../../../../common/search_strategy'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { Criteria, ItemsPerRow, PaginatedTable } from '../../../common/components/paginated_table'; import { networkActions, networkModel, networkSelectors } from '../../store'; import { getNFlowColumnsCurated } from './columns'; @@ -60,8 +60,8 @@ const NetworkTopNFlowTableComponent: React.FC = ({ type, }) => { const dispatch = useDispatch(); - const getTopNFlowSelector = networkSelectors.topNFlowSelector(); - const { activePage, limit, sort } = useShallowEqualSelector((state) => + const getTopNFlowSelector = useMemo(() => networkSelectors.topNFlowSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state) => getTopNFlowSelector(state, type, flowTargeted) ); @@ -112,11 +112,17 @@ const NetworkTopNFlowTableComponent: React.FC = ({ [sort, dispatch, type, tableType] ); - const field = - sort.field === NetworkTopTablesFields.bytes_out || - sort.field === NetworkTopTablesFields.bytes_in - ? `node.network.${sort.field}` - : `node.${flowTargeted}.${sort.field}`; + const sorting = useMemo( + () => ({ + field: + sort.field === NetworkTopTablesFields.bytes_out || + sort.field === NetworkTopTablesFields.bytes_in + ? `node.network.${sort.field}` + : `node.${flowTargeted}.${sort.field}`, + direction: sort.direction, + }), + [flowTargeted, sort] + ); const updateActivePage = useCallback( (newPage) => @@ -159,7 +165,7 @@ const NetworkTopNFlowTableComponent: React.FC = ({ onChange={onChange} pageOfItems={data} showMorePagesIndicator={showMorePagesIndicator} - sorting={{ field, direction: sort.direction }} + sorting={sorting} totalCount={fakeTotalCount} updateActivePage={updateActivePage} updateLimitPagination={updateLimitPagination} diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx index 79590bdfa0870..0ae0259d24c37 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx @@ -15,7 +15,7 @@ import { NetworkTlsFields, SortField, } from '../../../../common/search_strategy'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { Criteria, ItemsPerRow, @@ -62,10 +62,8 @@ const TlsTableComponent: React.FC = ({ type, }) => { const dispatch = useDispatch(); - const getTlsSelector = networkSelectors.tlsSelector(); - const { activePage, limit, sort } = useShallowEqualSelector((state) => - getTlsSelector(state, type) - ); + const getTlsSelector = useMemo(() => networkSelectors.tlsSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state) => getTlsSelector(state, type)); const tableType: networkModel.TopTlsTableType = type === networkModel.NetworkType.page ? networkModel.NetworkTableType.tls diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx index 7829449530829..1df3cb3145653 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { assertUnreachable } from '../../../../common/utility_types'; import { networkActions, networkModel, networkSelectors } from '../../store'; import { @@ -68,8 +68,9 @@ const UsersTableComponent: React.FC = ({ type, }) => { const dispatch = useDispatch(); - const getUsersSelector = networkSelectors.usersSelector(); - const { activePage, sort, limit } = useShallowEqualSelector(getUsersSelector); + const getUsersSelector = useMemo(() => networkSelectors.usersSelector(), []); + const { activePage, sort, limit } = useDeepEqualSelector(getUsersSelector); + const updateLimitPagination = useCallback( (newLimit) => dispatch( diff --git a/x-pack/plugins/security_solution/public/network/containers/details/index.tsx b/x-pack/plugins/security_solution/public/network/containers/details/index.tsx index 8a80d073d4beb..82a2c0257e550 100644 --- a/x-pack/plugins/security_solution/public/network/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/details/index.tsx @@ -59,17 +59,7 @@ export const useNetworkDetails = ({ const [ networkDetailsRequest, setNetworkDetailsRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: NetworkQueries.details, - filterQuery: createFilter(filterQuery), - ip, - } - : null - ); + ] = useState(null); const [networkDetailsResponse, setNetworkDetailsResponse] = useState({ networkDetails: {}, @@ -84,7 +74,7 @@ export const useNetworkDetails = ({ const networkDetailsSearch = useCallback( (request: NetworkDetailsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -138,7 +128,7 @@ export const useNetworkDetails = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -151,12 +141,12 @@ export const useNetworkDetails = ({ filterQuery: createFilter(filterQuery), ip, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, filterQuery, skip, ip, docValueFields, id]); + }, [indexNames, filterQuery, ip, docValueFields, id]); useEffect(() => { networkDetailsSearch(networkDetailsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx index 39868af2ae14d..84aa128fd8e04 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx @@ -59,20 +59,7 @@ export const useNetworkKpiDns = ({ const [ networkKpiDnsRequest, setNetworkKpiDnsRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.dns, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + ] = useState(null); const [networkKpiDnsResponse, setNetworkKpiDnsResponse] = useState({ dnsQueries: 0, @@ -87,7 +74,7 @@ export const useNetworkKpiDns = ({ const networkKpiDnsSearch = useCallback( (request: NetworkKpiDnsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -141,7 +128,7 @@ export const useNetworkKpiDns = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -157,12 +144,12 @@ export const useNetworkKpiDns = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, skip, startDate]); + }, [indexNames, endDate, filterQuery, startDate]); useEffect(() => { networkKpiDnsSearch(networkKpiDnsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx index 0cce484280906..32abd5710c6b1 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx @@ -59,20 +59,7 @@ export const useNetworkKpiNetworkEvents = ({ const [ networkKpiNetworkEventsRequest, setNetworkKpiNetworkEventsRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.networkEvents, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + ] = useState(null); const [ networkKpiNetworkEventsResponse, @@ -90,7 +77,7 @@ export const useNetworkKpiNetworkEvents = ({ const networkKpiNetworkEventsSearch = useCallback( (request: NetworkKpiNetworkEventsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -147,7 +134,7 @@ export const useNetworkKpiNetworkEvents = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -163,12 +150,12 @@ export const useNetworkKpiNetworkEvents = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, skip, startDate]); + }, [indexNames, endDate, filterQuery, startDate]); useEffect(() => { networkKpiNetworkEventsSearch(networkKpiNetworkEventsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx index 565504ca3ef09..22120a56d2150 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx @@ -59,20 +59,7 @@ export const useNetworkKpiTlsHandshakes = ({ const [ networkKpiTlsHandshakesRequest, setNetworkKpiTlsHandshakesRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.tlsHandshakes, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + ] = useState(null); const [ networkKpiTlsHandshakesResponse, @@ -90,7 +77,7 @@ export const useNetworkKpiTlsHandshakes = ({ const networkKpiTlsHandshakesSearch = useCallback( (request: NetworkKpiTlsHandshakesRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } let didCancel = false; @@ -146,7 +133,7 @@ export const useNetworkKpiTlsHandshakes = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -162,12 +149,12 @@ export const useNetworkKpiTlsHandshakes = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, skip, startDate]); + }, [indexNames, endDate, filterQuery, startDate]); useEffect(() => { networkKpiTlsHandshakesSearch(networkKpiTlsHandshakesRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx index 6924f3202076b..78ba96a140ac1 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx @@ -59,20 +59,7 @@ export const useNetworkKpiUniqueFlows = ({ const [ networkKpiUniqueFlowsRequest, setNetworkKpiUniqueFlowsRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.uniqueFlows, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + ] = useState(null); const [ networkKpiUniqueFlowsResponse, @@ -90,7 +77,7 @@ export const useNetworkKpiUniqueFlows = ({ const networkKpiUniqueFlowsSearch = useCallback( (request: NetworkKpiUniqueFlowsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -147,7 +134,7 @@ export const useNetworkKpiUniqueFlows = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -163,12 +150,12 @@ export const useNetworkKpiUniqueFlows = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, skip, startDate]); + }, [indexNames, endDate, filterQuery, startDate]); useEffect(() => { networkKpiUniqueFlowsSearch(networkKpiUniqueFlowsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx index 0b14945bba9ff..d2eae61a8212c 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx @@ -63,20 +63,7 @@ export const useNetworkKpiUniquePrivateIps = ({ const [ networkKpiUniquePrivateIpsRequest, setNetworkKpiUniquePrivateIpsRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.uniquePrivateIps, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + ] = useState(null); const [ networkKpiUniquePrivateIpsResponse, @@ -97,7 +84,7 @@ export const useNetworkKpiUniquePrivateIps = ({ const networkKpiUniquePrivateIpsSearch = useCallback( (request: NetworkKpiUniquePrivateIpsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -158,7 +145,7 @@ export const useNetworkKpiUniquePrivateIps = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -174,12 +161,12 @@ export const useNetworkKpiUniquePrivateIps = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, skip, startDate]); + }, [indexNames, endDate, filterQuery, startDate]); useEffect(() => { networkKpiUniquePrivateIpsSearch(networkKpiUniquePrivateIpsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx index aab90702de337..6245b22d188b3 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx @@ -5,12 +5,12 @@ */ import { noop } from 'lodash/fp'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; import { ESTermQuery } from '../../../../common/typed_json'; import { inputsModel } from '../../../common/store'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useKibana } from '../../../common/lib/kibana'; import { createFilter } from '../../../common/containers/helpers'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; @@ -65,31 +65,14 @@ export const useNetworkDns = ({ startDate, type, }: UseNetworkDns): [boolean, NetworkDnsArgs] => { - const getNetworkDnsSelector = networkSelectors.dnsSelector(); - const { activePage, sort, isPtrIncluded, limit } = useShallowEqualSelector(getNetworkDnsSelector); + const getNetworkDnsSelector = useMemo(() => networkSelectors.dnsSelector(), []); + const { activePage, sort, isPtrIncluded, limit } = useDeepEqualSelector(getNetworkDnsSelector); const { data, notifications } = useKibana().services; const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkDnsRequest, setNetworkDnsRequest] = useState( - !skip - ? { - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: NetworkQueries.dns, - filterQuery: createFilter(filterQuery), - isPtrIncluded, - pagination: generateTablePaginationOptions(activePage, limit, true), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - } - : null - ); + const [networkDnsRequest, setNetworkDnsRequest] = useState(null); const wrappedLoadMore = useCallback( (newActivePage: number) => { @@ -128,7 +111,7 @@ export const useNetworkDns = ({ const networkDnsSearch = useCallback( (request: NetworkDnsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -185,7 +168,7 @@ export const useNetworkDns = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -205,7 +188,7 @@ export const useNetworkDns = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; @@ -218,7 +201,6 @@ export const useNetworkDns = ({ limit, startDate, sort, - skip, isPtrIncluded, docValueFields, ]); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx index 8edb760429a7c..a6ae4d73f6608 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx @@ -5,12 +5,12 @@ */ import { noop } from 'lodash/fp'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; import { ESTermQuery } from '../../../../common/typed_json'; import { inputsModel } from '../../../common/store'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useKibana } from '../../../common/lib/kibana'; import { createFilter } from '../../../common/containers/helpers'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; @@ -64,32 +64,14 @@ export const useNetworkHttp = ({ startDate, type, }: UseNetworkHttp): [boolean, NetworkHttpArgs] => { - const getHttpSelector = networkSelectors.httpSelector(); - const { activePage, limit, sort } = useShallowEqualSelector((state) => - getHttpSelector(state, type) - ); + const getHttpSelector = useMemo(() => networkSelectors.httpSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state) => getHttpSelector(state, type)); const { data, notifications } = useKibana().services; const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkHttpRequest, setHostRequest] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.http, - filterQuery: createFilter(filterQuery), - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort: sort as SortField, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - } - : null - ); + const [networkHttpRequest, setHostRequest] = useState(null); const wrappedLoadMore = useCallback( (newActivePage: number) => { @@ -127,7 +109,7 @@ export const useNetworkHttp = ({ const networkHttpSearch = useCallback( (request: NetworkHttpRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -183,7 +165,7 @@ export const useNetworkHttp = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -202,12 +184,12 @@ export const useNetworkHttp = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, ip, limit, startDate, sort, skip]); + }, [activePage, indexNames, endDate, filterQuery, ip, limit, startDate, sort]); useEffect(() => { networkHttpSearch(networkHttpRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx index fa9a6ac08e812..d9ad4763177aa 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx @@ -10,7 +10,7 @@ import deepEqual from 'fast-deep-equal'; import { ESTermQuery } from '../../../../common/typed_json'; import { inputsModel } from '../../../common/store'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useKibana } from '../../../common/lib/kibana'; import { createFilter } from '../../../common/containers/helpers'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; @@ -63,8 +63,8 @@ export const useNetworkTopCountries = ({ startDate, type, }: UseNetworkTopCountries): [boolean, NetworkTopCountriesArgs] => { - const getTopCountriesSelector = networkSelectors.topCountriesSelector(); - const { activePage, limit, sort } = useShallowEqualSelector((state) => + const getTopCountriesSelector = useMemo(() => networkSelectors.topCountriesSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state) => getTopCountriesSelector(state, type, flowTarget) ); const { data, notifications } = useKibana().services; @@ -76,24 +76,7 @@ export const useNetworkTopCountries = ({ const [ networkTopCountriesRequest, setHostRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.topCountries, - filterQuery: createFilter(filterQuery), - flowTarget, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - } - : null - ); + ] = useState(null); const wrappedLoadMore = useCallback( (newActivePage: number) => { @@ -134,7 +117,7 @@ export const useNetworkTopCountries = ({ const networkTopCountriesSearch = useCallback( (request: NetworkTopCountriesRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -190,7 +173,7 @@ export const useNetworkTopCountries = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -210,12 +193,12 @@ export const useNetworkTopCountries = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, ip, limit, startDate, sort, skip, flowTarget]); + }, [activePage, indexNames, endDate, filterQuery, ip, limit, startDate, sort, flowTarget]); useEffect(() => { networkTopCountriesSearch(networkTopCountriesRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx index 49ff6016900a5..d62fc7ce545c4 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx @@ -5,12 +5,12 @@ */ import { noop } from 'lodash/fp'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; import { ESTermQuery } from '../../../../common/typed_json'; import { inputsModel } from '../../../common/store'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useKibana } from '../../../common/lib/kibana'; import { createFilter } from '../../../common/containers/helpers'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; @@ -63,8 +63,8 @@ export const useNetworkTopNFlow = ({ startDate, type, }: UseNetworkTopNFlow): [boolean, NetworkTopNFlowArgs] => { - const getTopNFlowSelector = networkSelectors.topNFlowSelector(); - const { activePage, limit, sort } = useShallowEqualSelector((state) => + const getTopNFlowSelector = useMemo(() => networkSelectors.topNFlowSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state) => getTopNFlowSelector(state, type, flowTarget) ); const { data, notifications } = useKibana().services; @@ -75,24 +75,7 @@ export const useNetworkTopNFlow = ({ const [ networkTopNFlowRequest, setTopNFlowRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.topNFlow, - filterQuery: createFilter(filterQuery), - flowTarget, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - } - : null - ); + ] = useState(null); const wrappedLoadMore = useCallback( (newActivePage: number) => { @@ -130,7 +113,7 @@ export const useNetworkTopNFlow = ({ const networkTopNFlowSearch = useCallback( (request: NetworkTopNFlowRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -186,7 +169,7 @@ export const useNetworkTopNFlow = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -206,12 +189,12 @@ export const useNetworkTopNFlow = ({ }, sort, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [activePage, endDate, filterQuery, indexNames, ip, limit, startDate, sort, skip, flowTarget]); + }, [activePage, endDate, filterQuery, indexNames, ip, limit, startDate, sort, flowTarget]); useEffect(() => { networkTopNFlowSearch(networkTopNFlowRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx index 8abd91186465a..ed7b3232809c6 100644 --- a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx @@ -5,12 +5,12 @@ */ import { noop } from 'lodash/fp'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; import { ESTermQuery } from '../../../../common/typed_json'; import { inputsModel } from '../../../common/store'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useKibana } from '../../../common/lib/kibana'; import { createFilter } from '../../../common/containers/helpers'; import { PageInfoPaginated, FlowTargetSourceDest } from '../../../graphql/types'; @@ -63,8 +63,8 @@ export const useNetworkTls = ({ startDate, type, }: UseNetworkTls): [boolean, NetworkTlsArgs] => { - const getTlsSelector = networkSelectors.tlsSelector(); - const { activePage, limit, sort } = useShallowEqualSelector((state) => + const getTlsSelector = useMemo(() => networkSelectors.tlsSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state) => getTlsSelector(state, type, flowTarget) ); const { data, notifications } = useKibana().services; @@ -72,24 +72,7 @@ export const useNetworkTls = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkTlsRequest, setHostRequest] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.tls, - filterQuery: createFilter(filterQuery), - flowTarget, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - } - : null - ); + const [networkTlsRequest, setHostRequest] = useState(null); const wrappedLoadMore = useCallback( (newActivePage: number) => { @@ -127,7 +110,7 @@ export const useNetworkTls = ({ const networkTlsSearch = useCallback( (request: NetworkTlsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -180,7 +163,7 @@ export const useNetworkTls = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -200,24 +183,12 @@ export const useNetworkTls = ({ }, sort, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [ - activePage, - indexNames, - endDate, - filterQuery, - limit, - startDate, - sort, - skip, - flowTarget, - ip, - id, - ]); + }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, flowTarget, ip, id]); useEffect(() => { networkTlsSearch(networkTlsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx index 75f28773b89f6..b4d671c406334 100644 --- a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx @@ -5,10 +5,10 @@ */ import { noop } from 'lodash/fp'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { ESTermQuery } from '../../../../common/typed_json'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; import { inputsModel } from '../../../common/store'; @@ -62,8 +62,8 @@ export const useNetworkUsers = ({ skip, startDate, }: UseNetworkUsers): [boolean, NetworkUsersArgs] => { - const getNetworkUsersSelector = networkSelectors.usersSelector(); - const { activePage, sort, limit } = useShallowEqualSelector(getNetworkUsersSelector); + const getNetworkUsersSelector = useMemo(() => networkSelectors.usersSelector(), []); + const { activePage, sort, limit } = useDeepEqualSelector(getNetworkUsersSelector); const { data, notifications, uiSettings } = useKibana().services; const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); @@ -71,22 +71,7 @@ export const useNetworkUsers = ({ const [loading, setLoading] = useState(false); const [networkUsersRequest, setNetworkUsersRequest] = useState( - !skip - ? { - defaultIndex, - factoryQueryType: NetworkQueries.users, - filterQuery: createFilter(filterQuery), - flowTarget, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - } - : null + null ); const wrappedLoadMore = useCallback( @@ -125,7 +110,7 @@ export const useNetworkUsers = ({ const networkUsersSearch = useCallback( (request: NetworkUsersRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -181,7 +166,7 @@ export const useNetworkUsers = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -201,23 +186,12 @@ export const useNetworkUsers = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [ - activePage, - defaultIndex, - endDate, - filterQuery, - limit, - startDate, - sort, - skip, - ip, - flowTarget, - ]); + }, [activePage, defaultIndex, endDate, filterQuery, limit, startDate, sort, ip, flowTarget]); useEffect(() => { networkUsersSearch(networkUsersRequest); diff --git a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx index bd563c2bd7617..4a97492312aba 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx @@ -9,7 +9,7 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { FlowTarget, LastEventIndexKey } from '../../../../common/search_strategy'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { FiltersGlobal } from '../../../common/components/filters_global'; @@ -56,11 +56,14 @@ const NetworkDetailsComponent: React.FC = () => { detailName: string; flowTarget: FlowTarget; }>(); - const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); - const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); - const query = useShallowEqualSelector(getGlobalQuerySelector); - const filters = useShallowEqualSelector(getGlobalFiltersQuerySelector); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const type = networkModel.NetworkType.details; const narrowDateRange = useCallback( diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx index 0a88519390486..47aeed99cde59 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx @@ -16,6 +16,7 @@ const NetworkHttpTableManage = manageQuery(NetworkHttpTable); export const NetworkHttpQueryTable = ({ endDate, filterQuery, + indexNames, ip, setQuery, skip, @@ -28,7 +29,7 @@ export const NetworkHttpQueryTable = ({ ] = useNetworkHttp({ endDate, filterQuery, - indexNames: [], + indexNames, ip, skip, startDate, diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx index 8a7d499a8ef5f..65924e6b4be0f 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx @@ -17,6 +17,7 @@ export const NetworkTopCountriesQueryTable = ({ endDate, filterQuery, flowTarget, + indexNames, ip, setQuery, skip, @@ -31,7 +32,7 @@ export const NetworkTopCountriesQueryTable = ({ endDate, flowTarget, filterQuery, - indexNames: [], + indexNames, ip, skip, startDate, diff --git a/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx index b8c53cdf10fee..28a9aaf50dcff 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx @@ -17,6 +17,7 @@ export const TlsQueryTable = ({ endDate, filterQuery, flowTarget, + indexNames, ip, setQuery, skip, @@ -30,7 +31,7 @@ export const TlsQueryTable = ({ endDate, filterQuery, flowTarget, - indexNames: [], + indexNames, ip, skip, startDate, diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx index 8d850a926f093..4fc3b7bd01b2e 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx @@ -57,7 +57,9 @@ const DnsQueryTabBodyComponent: React.FC = ({ type, }) => { const getNetworkDnsSelector = networkSelectors.dnsSelector(); - const { isPtrIncluded } = useShallowEqualSelector(getNetworkDnsSelector); + const isPtrIncluded = useShallowEqualSelector( + (state) => getNetworkDnsSelector(state).isPtrIncluded + ); useEffect(() => { return () => { 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 01e5b6ae6cf12..f9e30e30472d9 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -7,7 +7,7 @@ import { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; import { esQuery } from '../../../../../../src/plugins/data/public'; @@ -27,8 +27,8 @@ import { useGlobalTime } from '../../common/containers/use_global_time'; import { LastEventIndexKey } from '../../../common/search_strategy'; import { useKibana } from '../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../common/lib/keury'; -import { State, inputsSelectors } from '../../common/store'; -import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../common/store/inputs/actions'; +import { inputsSelectors } from '../../common/store'; +import { setAbsoluteRangeDatePicker } from '../../common/store/inputs/actions'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { Display } from '../../hosts/pages/display'; import { networkModel } from '../store'; @@ -42,19 +42,25 @@ import { showGlobalFilters } from '../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../timelines/store/timeline'; import { TimelineId } from '../../../common/types/timeline'; import { timelineDefaults } from '../../timelines/store/timeline/defaults'; -import { TimelineModel } from '../../timelines/store/timeline/model'; import { useSourcererScope } from '../../common/containers/sourcerer'; +import { useDeepEqualSelector, useShallowEqualSelector } from '../../common/hooks/use_selector'; + +const NetworkComponent = React.memo( + ({ networkPagePath, hasMlUserPermissions, capabilitiesFetched }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => + (getTimeline(state, TimelineId.networkPageExternalAlerts) ?? timelineDefaults).graphEventId + ); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); -const NetworkComponent = React.memo( - ({ - filters, - graphEventId, - query, - setAbsoluteRangeDatePicker, - networkPagePath, - hasMlUserPermissions, - capabilitiesFetched, - }) => { const { to, from, setQuery, isInitializing } = useGlobalTime(); const { globalFullScreen } = useFullScreen(); const kibana = useKibana(); @@ -73,13 +79,15 @@ const NetworkComponent = React.memo( return; } const [min, max] = x; - setAbsoluteRangeDatePicker({ - id: 'global', - from: new Date(min).toISOString(), - to: new Date(max).toISOString(), - }); + dispatch( + setAbsoluteRangeDatePicker({ + id: 'global', + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }) + ); }, - [setAbsoluteRangeDatePicker] + [dispatch] ); const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); @@ -183,30 +191,4 @@ const NetworkComponent = React.memo( ); NetworkComponent.displayName = 'NetworkComponent'; -const makeMapStateToProps = () => { - const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); - const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const mapStateToProps = (state: State) => { - const timeline: TimelineModel = - getTimeline(state, TimelineId.networkPageExternalAlerts) ?? timelineDefaults; - const { graphEventId } = timeline; - - return { - query: getGlobalQuerySelector(state), - filters: getGlobalFiltersQuerySelector(state), - graphEventId, - }; - }; - return mapStateToProps; -}; - -const mapDispatchToProps = { - setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, -}; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const Network = connector(NetworkComponent); +export const Network = React.memo(NetworkComponent); diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx index 4d3b2dbf3f11f..4ab72afc3fb45 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx @@ -58,18 +58,11 @@ const AlertsByCategoryComponent: React.FC = ({ setQuery, to, }) => { - useEffect(() => { - return () => { - if (deleteQuery) { - deleteQuery({ id: ID }); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const kibana = useKibana(); + const { + uiSettings, + application: { navigateToApp }, + } = useKibana().services; const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.hosts); - const { navigateToApp } = kibana.services.application; const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); const goToHostAlerts = useCallback( @@ -108,15 +101,29 @@ const AlertsByCategoryComponent: React.FC = ({ [] ); - return ( - + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), indexPattern, queries: [query], filters, - })} + }), + [filters, indexPattern, uiSettings, query] + ); + + useEffect(() => { + return () => { + if (deleteQuery) { + deleteQuery({ id: ID }); + } + }; + }, [deleteQuery]); + + return ( + ; filterBy: FilterMode; } -export type Props = OwnProps & PropsFromRedux; - const PAGE_SIZE = 3; -const StatefulRecentTimelinesComponent = React.memo( - ({ apolloClient, filterBy, updateIsLoading, updateTimeline }) => { - const { formatUrl } = useFormatUrl(SecurityPageName.timelines); - const { navigateToApp } = useKibana().services.application; - const onOpenTimeline: OnOpenTimeline = useCallback( - ({ duplicate, timelineId }) => { - queryTimelineById({ - apolloClient, - duplicate, - timelineId, - updateIsLoading, - updateTimeline, - }); +const StatefulRecentTimelinesComponent: React.FC = ({ apolloClient, filterBy }) => { + const dispatch = useDispatch(); + const updateIsLoading = useCallback((payload) => dispatch(dispatchUpdateIsLoading(payload)), [ + dispatch, + ]); + const updateTimeline = useMemo(() => dispatchUpdateTimeline(dispatch), [dispatch]); + + const { formatUrl } = useFormatUrl(SecurityPageName.timelines); + const { navigateToApp } = useKibana().services.application; + const onOpenTimeline: OnOpenTimeline = useCallback( + ({ duplicate, timelineId }) => { + queryTimelineById({ + apolloClient, + duplicate, + timelineId, + updateIsLoading, + updateTimeline, + }); + }, + [apolloClient, updateIsLoading, updateTimeline] + ); + + const goToTimelines = useCallback( + (ev) => { + ev.preventDefault(); + navigateToApp(`${APP_ID}:${SecurityPageName.timelines}`); + }, + [navigateToApp] + ); + + const noTimelinesMessage = + filterBy === 'favorites' ? i18n.NO_FAVORITE_TIMELINES : i18n.NO_TIMELINES; + + const linkAllTimelines = useMemo( + () => ( + + {i18n.VIEW_ALL_TIMELINES} + + ), + [goToTimelines, formatUrl] + ); + const loadingPlaceholders = useMemo( + () => , + [filterBy] + ); + + const { fetchAllTimeline, timelines, loading } = useGetAllTimeline(); + const timelineType = TimelineType.default; + const { timelineStatus } = useTimelineStatus({ timelineType }); + + useEffect(() => { + fetchAllTimeline({ + pageInfo: { + pageIndex: 1, + pageSize: PAGE_SIZE, }, - [apolloClient, updateIsLoading, updateTimeline] - ); - - const goToTimelines = useCallback( - (ev) => { - ev.preventDefault(); - navigateToApp(`${APP_ID}:${SecurityPageName.timelines}`); + search: '', + sort: { + sortField: SortFieldTimeline.updated, + sortOrder: Direction.desc, }, - [navigateToApp] - ); - - const noTimelinesMessage = - filterBy === 'favorites' ? i18n.NO_FAVORITE_TIMELINES : i18n.NO_TIMELINES; - - const linkAllTimelines = useMemo( - () => ( - - {i18n.VIEW_ALL_TIMELINES} - - ), - [goToTimelines, formatUrl] - ); - const loadingPlaceholders = useMemo( - () => ( - - ), - [filterBy] - ); - - const { fetchAllTimeline, timelines, loading } = useGetAllTimeline(); - const timelineType = TimelineType.default; - const { timelineStatus } = useTimelineStatus({ timelineType }); - useEffect(() => { - fetchAllTimeline({ - pageInfo: { - pageIndex: 1, - pageSize: PAGE_SIZE, - }, - search: '', - sort: { - sortField: SortFieldTimeline.updated, - sortOrder: Direction.desc, - }, - onlyUserFavorite: filterBy === 'favorites', - status: timelineStatus, - timelineType, - }); - }, [fetchAllTimeline, filterBy, timelineStatus, timelineType]); - - return ( - <> - {loading ? ( - loadingPlaceholders - ) : ( - - )} - - {linkAllTimelines} - - ); - } -); + onlyUserFavorite: filterBy === 'favorites', + status: timelineStatus, + timelineType, + }); + }, [fetchAllTimeline, filterBy, timelineStatus, timelineType]); + + return ( + <> + {loading ? ( + loadingPlaceholders + ) : ( + + )} + + {linkAllTimelines} + + ); +}; StatefulRecentTimelinesComponent.displayName = 'StatefulRecentTimelinesComponent'; -const mapDispatchToProps = (dispatch: Dispatch) => ({ - updateIsLoading: ({ id, isLoading }: { id: string; isLoading: boolean }) => - dispatch(dispatchUpdateIsLoading({ id, isLoading })), - updateTimeline: dispatchUpdateTimeline(dispatch), -}); - -const connector = connect(null, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const StatefulRecentTimelines = connector(StatefulRecentTimelinesComponent); +export const StatefulRecentTimelines = React.memo(StatefulRecentTimelinesComponent); diff --git a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx index 0ac136044c06d..34722fd147a99 100644 --- a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx @@ -5,11 +5,12 @@ */ import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; import { AlertsHistogramPanel } from '../../../detections/components/alerts_histogram_panel'; import { alertsHistogramOptions } from '../../../detections/components/alerts_histogram_panel/config'; import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { SetAbsoluteRangeDatePicker } from '../../../network/pages/types'; +import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; import { Filter, IIndexPattern, Query } from '../../../../../../../src/plugins/data/public'; import { InputsModelId } from '../../../common/store/inputs/constants'; import * as i18n from '../../pages/translations'; @@ -26,7 +27,6 @@ interface Props extends Pick = ({ headerChildren, onlyField, query = DEFAULT_QUERY, - setAbsoluteRangeDatePicker, setAbsoluteRangeDatePickerTarget = 'global', setQuery, timelineId, to, }) => { + const dispatch = useDispatch(); const { signalIndexName } = useSignalIndex(); const updateDateRangeCallback = useCallback( ({ x }) => { @@ -51,14 +51,15 @@ const SignalsByCategoryComponent: React.FC = ({ return; } const [min, max] = x; - setAbsoluteRangeDatePicker({ - id: setAbsoluteRangeDatePickerTarget, - from: new Date(min).toISOString(), - to: new Date(max).toISOString(), - }); + dispatch( + setAbsoluteRangeDatePicker({ + id: setAbsoluteRangeDatePickerTarget, + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }) + ); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [setAbsoluteRangeDatePicker] + [dispatch, setAbsoluteRangeDatePickerTarget] ); return ( diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx index edf68750e2fdd..dfa391e49913b 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx @@ -52,20 +52,7 @@ export const useHostOverview = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [overviewHostRequest, setHostRequest] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: HostsQueries.overview, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + const [overviewHostRequest, setHostRequest] = useState(null); const [overviewHostResponse, setHostOverviewResponse] = useState({ overviewHost: {}, @@ -80,7 +67,7 @@ export const useHostOverview = ({ const overviewHostSearch = useCallback( (request: HostOverviewRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -134,7 +121,7 @@ export const useHostOverview = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -150,12 +137,12 @@ export const useHostOverview = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, skip, startDate]); + }, [indexNames, endDate, filterQuery, startDate]); useEffect(() => { overviewHostSearch(overviewHostRequest); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx index c414276c1a615..325d9a7965066 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx @@ -55,20 +55,7 @@ export const useNetworkOverview = ({ const [ overviewNetworkRequest, setNetworkRequest, - ] = useState( - !skip - ? { - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.overview, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - } - : null - ); + ] = useState(null); const [overviewNetworkResponse, setNetworkOverviewResponse] = useState({ overviewNetwork: {}, @@ -153,12 +140,12 @@ export const useNetworkOverview = ({ to: endDate, }, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, skip, startDate]); + }, [indexNames, endDate, filterQuery, startDate]); useEffect(() => { overviewNetworkSearch(overviewNetworkRequest); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index a292ec3e1a119..0f34734ebf861 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -6,7 +6,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useState, useMemo } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; import { Query, Filter } from 'src/plugins/data/public'; import styled from 'styled-components'; @@ -22,8 +21,7 @@ import { EventCounts } from '../components/event_counts'; import { OverviewEmpty } from '../components/overview_empty'; import { StatefulSidebar } from '../components/sidebar'; import { SignalsByCategory } from '../components/signals_by_category'; -import { inputsSelectors, State } from '../../common/store'; -import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../common/store/inputs/actions'; +import { inputsSelectors } from '../../common/store'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { SecurityPageName } from '../../app/types'; import { EndpointNotice } from '../components/endpoint_notice'; @@ -33,6 +31,7 @@ import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enable import { useSourcererScope } from '../../common/containers/sourcerer'; import { Sourcerer } from '../../common/components/sourcerer'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; +import { useDeepEqualSelector } from '../../common/hooks/use_selector'; const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; const NO_FILTERS: Filter[] = []; @@ -41,11 +40,17 @@ const SidebarFlexItem = styled(EuiFlexItem)` margin-right: 24px; `; -const OverviewComponent: React.FC = ({ - filters = NO_FILTERS, - query = DEFAULT_QUERY, - setAbsoluteRangeDatePicker, -}) => { +const OverviewComponent = () => { + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector((state) => getGlobalQuerySelector(state) ?? DEFAULT_QUERY); + const filters = useDeepEqualSelector( + (state) => getGlobalFiltersQuerySelector(state) ?? NO_FILTERS + ); + const { from, deleteQuery, setQuery, to } = useGlobalTime(); const { indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); @@ -94,7 +99,6 @@ const OverviewComponent: React.FC = ({ from={from} indexPattern={indexPattern} query={query} - setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setQuery={setQuery} to={to} /> @@ -152,22 +156,4 @@ const OverviewComponent: React.FC = ({ ); }; -const makeMapStateToProps = () => { - const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); - - const mapStateToProps = (state: State) => ({ - query: getGlobalQuerySelector(state), - filters: getGlobalFiltersQuerySelector(state), - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = { setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker }; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const StatefulOverview = connector(React.memo(OverviewComponent)); +export const StatefulOverview = React.memo(OverviewComponent); 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 7addfaaf7c5fc..4a98630e31a73 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 @@ -10,6 +10,7 @@ import styled from 'styled-components'; import { BrowserFields } from '../../../common/containers/source'; +import { OnUpdateColumns } from '../timeline/events'; import { FieldBrowserProps } from './types'; import { getCategoryColumns } from './category_columns'; import { TABLE_HEIGHT } from './helpers'; @@ -38,7 +39,7 @@ const H5 = styled.h5` Title.displayName = 'Title'; -type Props = Pick & { +type Props = Pick & { /** * A map of categoryId -> metadata about the fields in that category, * filtered such that the name of every field in the category includes @@ -51,6 +52,8 @@ type Props = Pick void; /** The category selected on the left-hand side of the field browser */ + /** Invoked when a user chooses to view a new set of columns in the timeline */ + onUpdateColumns: OnUpdateColumns; selectedCategoryId: string; /** The width of the categories pane */ width: number; 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 14c17b7262724..9b8207a5060bc 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 @@ -7,7 +7,7 @@ /* eslint-disable react/display-name */ import { EuiIcon, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiToolTip } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../../common/containers/source'; @@ -54,20 +54,23 @@ const ToolTip = React.memo( const { isLoading } = useMemo(() => getManageTimelineById(timelineId) ?? { isLoading: false }, [ timelineId, ]); + + const handleClick = useCallback(() => { + onUpdateColumns( + getColumnsWithTimestamp({ + browserFields, + category: categoryId, + }) + ); + }, [browserFields, categoryId, onUpdateColumns]); + return ( {!isLoading ? ( { - onUpdateColumns( - getColumnsWithTimestamp({ - browserFields, - category: categoryId, - }) - ); - }} + onClick={handleClick} type="visTable" /> ) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.test.tsx index 9340ee8cf0c7f..f65a884d95405 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.test.tsx @@ -50,11 +50,9 @@ describe('FieldsBrowser', () => { onCategorySelected={jest.fn()} onHideFieldBrowser={jest.fn()} onOutsideClick={onOutsideClick} - onUpdateColumns={jest.fn()} onSearchInputChange={jest.fn()} selectedCategoryId={''} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} />
@@ -88,11 +86,9 @@ describe('FieldsBrowser', () => { onFieldSelected={jest.fn()} onHideFieldBrowser={jest.fn()} onOutsideClick={onOutsideClick} - onUpdateColumns={jest.fn()} onSearchInputChange={jest.fn()} selectedCategoryId={''} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} />
@@ -118,11 +114,9 @@ describe('FieldsBrowser', () => { onCategorySelected={jest.fn()} onHideFieldBrowser={jest.fn()} onOutsideClick={jest.fn()} - onUpdateColumns={jest.fn()} onSearchInputChange={jest.fn()} selectedCategoryId={''} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -144,11 +138,9 @@ describe('FieldsBrowser', () => { onCategorySelected={jest.fn()} onHideFieldBrowser={jest.fn()} onOutsideClick={jest.fn()} - onUpdateColumns={jest.fn()} onSearchInputChange={jest.fn()} selectedCategoryId={''} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -170,11 +162,9 @@ describe('FieldsBrowser', () => { onCategorySelected={jest.fn()} onHideFieldBrowser={jest.fn()} onOutsideClick={jest.fn()} - onUpdateColumns={jest.fn()} onSearchInputChange={jest.fn()} selectedCategoryId={''} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -196,11 +186,9 @@ describe('FieldsBrowser', () => { onCategorySelected={jest.fn()} onHideFieldBrowser={jest.fn()} onOutsideClick={jest.fn()} - onUpdateColumns={jest.fn()} onSearchInputChange={jest.fn()} selectedCategoryId={''} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -228,11 +216,9 @@ describe('FieldsBrowser', () => { onCategorySelected={jest.fn()} onHideFieldBrowser={jest.fn()} onOutsideClick={jest.fn()} - onUpdateColumns={jest.fn()} onSearchInputChange={onSearchInputChange} selectedCategoryId={''} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> 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 3c9101878be8d..563857e5a829f 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 @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiOutsideClickDetector } from '@elastic/eui import React, { useEffect, useCallback } from 'react'; import { noop } from 'lodash/fp'; import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; import { BrowserFields } from '../../../common/containers/source'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; @@ -23,6 +24,7 @@ import { PANES_FLEX_GROUP_WIDTH, } from './helpers'; import { FieldBrowserProps, OnHideFieldBrowser } from './types'; +import { timelineActions } from '../../store/timeline'; const FieldsBrowserContainer = styled.div<{ width: number }>` background-color: ${({ theme }) => theme.eui.euiColorLightestShade}; @@ -46,7 +48,7 @@ PanesFlexGroup.displayName = 'PanesFlexGroup'; type Props = Pick< FieldBrowserProps, - 'browserFields' | 'height' | 'onFieldSelected' | 'onUpdateColumns' | 'timelineId' | 'width' + 'browserFields' | 'height' | 'onFieldSelected' | 'timelineId' | 'width' > & { /** * The current timeline column headers @@ -86,10 +88,6 @@ type Props = Pick< * Invoked when the user types in the search input */ onSearchInputChange: (newSearchInput: string) => void; - /** - * Invoked to add or remove a column from the timeline - */ - toggleColumn: (column: ColumnHeaderOptions) => void; }; /** @@ -106,13 +104,18 @@ const FieldsBrowserComponent: React.FC = ({ onHideFieldBrowser, onSearchInputChange, onOutsideClick, - onUpdateColumns, searchInput, selectedCategoryId, timelineId, - toggleColumn, width, }) => { + const dispatch = useDispatch(); + + const onUpdateColumns = useCallback( + (columns) => dispatch(timelineActions.updateColumns({ id: timelineId, columns })), + [dispatch, timelineId] + ); + /** Focuses the input that filters the field browser */ const focusInput = () => { const elements = document.getElementsByClassName( @@ -219,7 +222,6 @@ const FieldsBrowserComponent: React.FC = ({ searchInput={searchInput} selectedCategoryId={selectedCategoryId} timelineId={timelineId} - toggleColumn={toggleColumn} width={FIELDS_PANE_WIDTH} /> 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 c2ddba6bd88c3..29debc52adb95 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 @@ -33,7 +33,6 @@ describe('FieldsPane', () => { searchInput="" selectedCategoryId={selectedCategory} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELDS_PANE_WIDTH} /> @@ -58,7 +57,6 @@ describe('FieldsPane', () => { searchInput="" selectedCategoryId={selectedCategory} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELDS_PANE_WIDTH} /> @@ -83,7 +81,6 @@ describe('FieldsPane', () => { searchInput={searchInput} selectedCategoryId="" timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELDS_PANE_WIDTH} /> @@ -108,7 +105,6 @@ describe('FieldsPane', () => { searchInput={searchInput} selectedCategoryId="" timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELDS_PANE_WIDTH} /> 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 73ea739216857..d47f1705b1722 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 @@ -5,12 +5,14 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; 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'; import { FieldBrowserProps } from './types'; import { getFieldItems } from './field_items'; @@ -32,7 +34,7 @@ const NoFieldsFlexGroup = styled(EuiFlexGroup)` NoFieldsFlexGroup.displayName = 'NoFieldsFlexGroup'; -type Props = Pick & { +type Props = Pick & { columnHeaders: ColumnHeaderOptions[]; /** * A map of categoryId -> metadata about the fields in that category, @@ -46,6 +48,8 @@ type Props = Pick void; /** The text displayed in the search input */ + /** Invoked when a user chooses to view a new set of columns in the timeline */ + onUpdateColumns: OnUpdateColumns; searchInput: string; /** * The category selected on the left-hand side of the field browser @@ -53,10 +57,6 @@ type Props = Pick void; }; export const FieldsPane = React.memo( ({ @@ -67,11 +67,39 @@ export const FieldsPane = React.memo( searchInput, selectedCategoryId, timelineId, - toggleColumn, width, - }) => ( - <> - {Object.keys(filteredBrowserFields).length > 0 ? ( + }) => { + const dispatch = useDispatch(); + + const toggleColumn = useCallback( + (column: ColumnHeaderOptions) => { + if (columnHeaders.some((c) => c.id === column.id)) { + dispatch( + timelineActions.removeColumn({ + columnId: column.id, + id: timelineId, + }) + ); + } else { + dispatch( + timelineActions.upsertColumn({ + column, + id: timelineId, + index: 1, + }) + ); + } + }, + [columnHeaders, dispatch, timelineId] + ); + + const filteredBrowserFieldsExists = useMemo( + () => Object.keys(filteredBrowserFields).length > 0, + [filteredBrowserFields] + ); + + if (filteredBrowserFieldsExists) { + return ( ( onCategorySelected={onCategorySelected} timelineId={timelineId} /> - ) : ( - - - -

{i18n.NO_FIELDS_MATCH_INPUT(searchInput)}

-
-
-
- )} - - ) + ); + } + + return ( + + + +

{i18n.NO_FIELDS_MATCH_INPUT(searchInput)}

+
+
+
+ ); + } ); FieldsPane.displayName = 'FieldsPane'; 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 916240ac411e5..0bbf13aa07457 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 @@ -96,6 +96,7 @@ const TitleRow = React.memo<{ onUpdateColumns: OnUpdateColumns; }>(({ id, onOutsideClick, onUpdateColumns }) => { const { getManageTimelineById } = useManageTimeline(); + const handleResetColumns = useCallback(() => { const timeline = getManageTimelineById(id); onUpdateColumns(timeline.defaultModel.columns); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx index 3bfeabc614ea9..381681898e27c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx @@ -27,9 +27,7 @@ describe('StatefulFieldsBrowser', () => { browserFields={mockBrowserFields} columnHeaders={[]} height={FIELD_BROWSER_HEIGHT} - onUpdateColumns={jest.fn()} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -46,9 +44,7 @@ describe('StatefulFieldsBrowser', () => { browserFields={mockBrowserFields} columnHeaders={[]} height={FIELD_BROWSER_HEIGHT} - onUpdateColumns={jest.fn()} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -64,9 +60,7 @@ describe('StatefulFieldsBrowser', () => { browserFields={mockBrowserFields} columnHeaders={[]} height={FIELD_BROWSER_HEIGHT} - onUpdateColumns={jest.fn()} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -89,9 +83,7 @@ describe('StatefulFieldsBrowser', () => { browserFields={mockBrowserFields} columnHeaders={[]} height={FIELD_BROWSER_HEIGHT} - onUpdateColumns={jest.fn()} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -115,9 +107,7 @@ describe('StatefulFieldsBrowser', () => { browserFields={mockBrowserFields} columnHeaders={[]} height={FIELD_BROWSER_HEIGHT} - onUpdateColumns={jest.fn()} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -152,9 +142,7 @@ describe('StatefulFieldsBrowser', () => { columnHeaders={[]} height={FIELD_BROWSER_HEIGHT} isEventViewer={isEventViewer} - onUpdateColumns={jest.fn()} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> @@ -173,9 +161,7 @@ describe('StatefulFieldsBrowser', () => { columnHeaders={[]} height={FIELD_BROWSER_HEIGHT} isEventViewer={isEventViewer} - onUpdateColumns={jest.fn()} timelineId={timelineId} - toggleColumn={jest.fn()} width={FIELD_BROWSER_WIDTH} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx index f197d241cc422..eb69310cae157 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx @@ -10,7 +10,6 @@ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react' import styled from 'styled-components'; import { BrowserFields } from '../../../common/containers/source'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { DEFAULT_CATEGORY_NAME } from '../timeline/body/column_headers/default_headers'; import { FieldsBrowser } from './field_browser'; import { filterBrowserFieldsByFieldName, mergeBrowserFieldsWithDefaultCategory } from './helpers'; @@ -37,9 +36,7 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ browserFields, height, onFieldSelected, - onUpdateColumns, timelineId, - toggleColumn, width, }) => { /** tracks the latest timeout id from `setTimeout`*/ @@ -109,24 +106,6 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ [browserFields, filterInput, inputTimeoutId.current] ); - /** - * Invoked when the user clicks a category name in the left-hand side of - * the field browser - */ - const updateSelectedCategoryId = useCallback((categoryId: string) => { - setSelectedCategoryId(categoryId); - }, []); - - /** - * Invoked when the user clicks on the context menu to view a category's - * columns in the timeline, this function dispatches the action that - * causes the timeline display those columns. - */ - const updateColumnsAndSelectCategoryId = useCallback((columns: ColumnHeaderOptions[]) => { - onUpdateColumns(columns); // show the category columns in the timeline - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - /** Invoked when the field browser should be hidden */ const hideFieldBrowser = useCallback(() => { setFilterInput(''); @@ -136,6 +115,7 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ setSelectedCategoryId(DEFAULT_CATEGORY_NAME); setShow(false); }, []); + // only merge in the default category if the field browser is visible const browserFieldsWithDefaultCategory = useMemo(() => { return show ? mergeBrowserFieldsWithDefaultCategory(browserFields) : {}; @@ -164,16 +144,14 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ } height={height} isSearching={isSearching} - onCategorySelected={updateSelectedCategoryId} + onCategorySelected={setSelectedCategoryId} onFieldSelected={onFieldSelected} onHideFieldBrowser={hideFieldBrowser} onOutsideClick={show ? hideFieldBrowser : noop} onSearchInputChange={updateFilter} - onUpdateColumns={updateColumnsAndSelectCategoryId} searchInput={filterInput} selectedCategoryId={selectedCategoryId} timelineId={timelineId} - toggleColumn={toggleColumn} width={width} /> )} 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 2b9889ec13e79..345b0adfacd27 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 @@ -6,7 +6,6 @@ import { BrowserFields } from '../../../common/containers/source'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; -import { OnUpdateColumns } from '../timeline/events'; export type OnFieldSelected = (fieldId: string) => void; export type OnHideFieldBrowser = () => void; @@ -26,12 +25,8 @@ export interface FieldBrowserProps { * instead of dragging it to the timeline */ onFieldSelected?: OnFieldSelected; - /** Invoked when a user chooses to view a new set of columns in the timeline */ - onUpdateColumns: OnUpdateColumns; /** The timeline associated with this field browser */ timelineId: string; - /** Adds or removes a column to / from the timeline */ - toggleColumn: (column: ColumnHeaderOptions) => void; /** The width of the field browser */ width: number; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/flyout/__snapshots__/index.test.tsx.snap index 46c9fbb524066..bbf09856936ca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/__snapshots__/index.test.tsx.snap @@ -3,10 +3,5 @@ exports[`Flyout rendering it renders correctly against snapshot 1`] = ` `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx new file mode 100644 index 0000000000000..1bcae7f686333 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.test.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; +import { waitFor } from '@testing-library/react'; + +import { AddTimelineButton } from './'; +import { useKibana } from '../../../../common/lib/kibana'; +import { TimelineId } from '../../../../../common/types/timeline'; + +jest.mock('../../../../common/lib/kibana', () => ({ + useKibana: jest.fn(), + useUiSetting$: jest.fn().mockReturnValue([]), +})); + +jest.mock('../../timeline/properties/new_template_timeline', () => ({ + NewTemplateTimeline: jest.fn(() =>
), +})); + +jest.mock('../../timeline/properties/helpers', () => ({ + Description: jest.fn().mockReturnValue(
), + ExistingCase: jest.fn().mockReturnValue(
), + NewCase: jest.fn().mockReturnValue(
), + NewTimeline: jest.fn().mockReturnValue(
), + NotesButton: jest.fn().mockReturnValue(
), +})); + +jest.mock('../../../../common/components/inspect', () => ({ + InspectButton: jest.fn().mockReturnValue(
), + InspectButtonContainer: jest.fn(({ children }) =>
{children}
), +})); + +describe('AddTimelineButton', () => { + let wrapper: ReactWrapper; + const props = { + timelineId: TimelineId.active, + }; + + describe('with crud', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + siem: { + crud: true, + }, + }, + }, + }, + }); + wrapper = mount(); + }); + + afterEach(() => { + (useKibana as jest.Mock).mockReset(); + }); + + test('it renders settings-plus-in-circle', () => { + expect(wrapper.find('[data-test-subj="settings-plus-in-circle"]').exists()).toBeTruthy(); + }); + + test('it renders create timeline btn', async () => { + await waitFor(() => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); + }); + }); + + test('it renders create timeline template btn', async () => { + await waitFor(() => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy(); + }); + }); + + test('it renders Open timeline btn', async () => { + await waitFor(() => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy(); + }); + }); + }); + + describe('with no crud', () => { + beforeEach(async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + siem: { + crud: false, + }, + }, + }, + }, + }); + wrapper = mount(); + }); + + afterEach(() => { + (useKibana as jest.Mock).mockReset(); + }); + + test('it renders settings-plus-in-circle', () => { + expect(wrapper.find('[data-test-subj="settings-plus-in-circle"]').exists()).toBeTruthy(); + }); + + test('it renders create timeline btn', async () => { + await waitFor(() => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); + }); + }); + + test('it renders create timeline template btn', async () => { + await waitFor(() => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy(); + }); + }); + + test('it renders Open timeline btn', async () => { + await waitFor(() => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx new file mode 100644 index 0000000000000..3b807ae296ca5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { OpenTimelineModalButton } from '../../open_timeline/open_timeline_modal/open_timeline_modal_button'; +import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal'; +import * as i18n from '../../timeline/properties/translations'; +import { NewTimeline } from '../../timeline/properties/helpers'; +import { NewTemplateTimeline } from '../../timeline/properties/new_template_timeline'; + +interface AddTimelineButtonComponentProps { + timelineId: string; +} + +const AddTimelineButtonComponent: React.FC = ({ timelineId }) => { + const [showActions, setShowActions] = useState(false); + const [showTimelineModal, setShowTimelineModal] = useState(false); + + const onButtonClick = useCallback(() => setShowActions(!showActions), [showActions]); + const onClosePopover = useCallback(() => setShowActions(false), []); + const onCloseTimelineModal = useCallback(() => setShowTimelineModal(false), []); + const onOpenTimelineModal = useCallback(() => { + onClosePopover(); + setShowTimelineModal(true); + }, [onClosePopover]); + + const PopoverButtonIcon = useMemo( + () => ( + + ), + [onButtonClick] + ); + + return ( + <> + + + + + + + + + + + + + + + + + + + {showTimelineModal ? : null} + + ); +}; + +export const AddTimelineButton = React.memo(AddTimelineButtonComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx new file mode 100644 index 0000000000000..f26c34fb5c073 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick } from 'lodash/fp'; +import { EuiButton, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { APP_ID } from '../../../../../common/constants'; +import { timelineSelectors } from '../../../../timelines/store/timeline'; +import { useAllCasesModal } from '../../../../cases/components/use_all_cases_modal'; +import { setInsertTimeline, showTimeline } from '../../../store/timeline/actions'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { useKibana } from '../../../../common/lib/kibana'; +import { TimelineStatus, TimelineId, TimelineType } from '../../../../../common/types/timeline'; +import { getCreateCaseUrl } from '../../../../common/components/link_to'; +import { SecurityPageName } from '../../../../app/types'; +import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; +import * as i18n from '../../timeline/properties/translations'; + +interface Props { + timelineId: string; +} + +const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { navigateToApp } = useKibana().services.application; + const dispatch = useDispatch(); + const { + graphEventId, + savedObjectId, + status: timelineStatus, + title: timelineTitle, + timelineType, + } = useDeepEqualSelector((state) => + pick( + ['graphEventId', 'savedObjectId', 'status', 'title', 'timelineType'], + getTimeline(state, timelineId) ?? timelineDefaults + ) + ); + const [isPopoverOpen, setPopover] = useState(false); + const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId }); + + const handleButtonClick = useCallback(() => { + setPopover((currentIsOpen) => !currentIsOpen); + }, []); + + const handlePopoverClose = useCallback(() => setPopover(false), []); + + const handleNewCaseClick = useCallback(() => { + handlePopoverClose(); + + dispatch(showTimeline({ id: TimelineId.active, show: false })); + + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCreateCaseUrl(), + }).then(() => + dispatch( + setInsertTimeline({ + graphEventId, + timelineId, + timelineSavedObjectId: savedObjectId, + timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, + }) + ) + ); + }, [ + dispatch, + graphEventId, + navigateToApp, + handlePopoverClose, + savedObjectId, + timelineId, + timelineTitle, + ]); + + const handleExistingCaseClick = useCallback(() => { + handlePopoverClose(); + onOpenCaseModal(); + }, [onOpenCaseModal, handlePopoverClose]); + + const closePopover = useCallback(() => { + setPopover(false); + }, []); + + const button = useMemo( + () => ( + + {i18n.ATTACH_TO_CASE} + + ), + [handleButtonClick, timelineStatus, timelineType] + ); + + const items = useMemo( + () => [ + + {i18n.ATTACH_TO_NEW_CASE} + , + + {i18n.ATTACH_TO_EXISTING_CASE} + , + ], + [handleExistingCaseClick, handleNewCaseClick] + ); + + return ( + <> + + + + + + ); +}; + +AddToCaseButtonComponent.displayName = 'AddToCaseButtonComponent'; + +export const AddToCaseButton = React.memo(AddToCaseButtonComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx new file mode 100644 index 0000000000000..81fb42dd8d20b --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../../../common/mock/test_providers'; +import { FlyoutBottomBar } from '.'; + +describe('FlyoutBottomBar', () => { + test('it renders the expected bottom bar', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="flyoutBottomBar"]').exists()).toBeTruthy(); + }); + + test('it renders the data providers drop target area', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="dataProviders"]').exists()).toBe(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx new file mode 100644 index 0000000000000..1c0f2ba55de41 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiPanel } from '@elastic/eui'; +import { rgba } from 'polished'; +import React from 'react'; +import styled from 'styled-components'; + +import { IS_DRAGGING_CLASS_NAME } from '../../../../common/components/drag_and_drop/helpers'; +import { DataProvider } from '../../timeline/data_providers/data_provider'; +import { flattenIntoAndGroups } from '../../timeline/data_providers/helpers'; +import { DataProviders } from '../../timeline/data_providers'; +import { FlyoutHeaderPanel } from '../header'; + +export const FLYOUT_BUTTON_CLASS_NAME = 'timeline-flyout-button'; + +export const getBadgeCount = (dataProviders: DataProvider[]): number => + flattenIntoAndGroups(dataProviders).reduce((total, group) => total + group.length, 0); + +const SHOW_HIDE_TRANSLATE_X = 50; // px + +const Container = styled.div` + position: fixed; + left: 0; + bottom: 0; + transform: translateY(calc(100% - ${SHOW_HIDE_TRANSLATE_X}px)); + user-select: none; + width: 100%; + z-index: ${({ theme }) => theme.eui.euiZLevel6}; + + .${IS_DRAGGING_CLASS_NAME} & { + transform: none; + } + + .${FLYOUT_BUTTON_CLASS_NAME} { + background: ${({ theme }) => rgba(theme.eui.euiPageBackgroundColor, 1)}; + border-radius: 4px 4px 0 0; + box-shadow: none; + height: 46px; + } + + .${IS_DRAGGING_CLASS_NAME} & .${FLYOUT_BUTTON_CLASS_NAME} { + color: ${({ theme }) => theme.eui.euiColorSuccess}; + background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.2)} !important; + border: 1px solid ${({ theme }) => theme.eui.euiColorSuccess}; + border-bottom: none; + text-decoration: none; + } +`; + +Container.displayName = 'Container'; + +const DataProvidersPanel = styled(EuiPanel)` + border-radius: 0; + padding: 0 4px 0 4px; + user-select: none; + z-index: ${({ theme }) => theme.eui.euiZLevel9}; +`; + +interface FlyoutBottomBarProps { + timelineId: string; +} + +export const FlyoutBottomBar = React.memo(({ timelineId }) => ( + + + + + + +)); + +FlyoutBottomBar.displayName = 'FlyoutBottomBar'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/timelines/components/flyout/button/translations.ts rename to x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.test.tsx deleted file mode 100644 index 1a1ee061799d2..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../../../common/mock/test_providers'; -import { twoGroups } from '../../timeline/data_providers/mock/mock_and_providers'; - -import { FlyoutButton, getBadgeCount } from '.'; - -describe('FlyoutButton', () => { - describe('getBadgeCount', () => { - test('it returns 0 when dataProviders is empty', () => { - expect(getBadgeCount([])).toEqual(0); - }); - - test('it returns a count that includes every provider in every group of ANDs', () => { - expect(getBadgeCount(twoGroups)).toEqual(6); - }); - }); - - test('it renders the button when show is true', () => { - const onOpen = jest.fn(); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').exists()).toBe(true); - }); - - test('it renders the expected button text', () => { - const onOpen = jest.fn(); - - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').first().text() - ).toEqual('Timeline'); - }); - - test('it renders the data providers drop target area', () => { - const onOpen = jest.fn(); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="dataProviders"]').exists()).toBe(true); - }); - - test('it does NOT render the button when show is false', () => { - const onOpen = jest.fn(); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').exists()).toBe(false); - }); - - test('it invokes `onOpen` when clicked', () => { - const onOpen = jest.fn(); - - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').first().simulate('click'); - wrapper.update(); - - expect(onOpen).toBeCalled(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx deleted file mode 100644 index 72fa20c9f152d..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiButton, EuiNotificationBadge, EuiPanel } from '@elastic/eui'; -import { rgba } from 'polished'; -import React, { useMemo } from 'react'; -import styled from 'styled-components'; -import deepEqual from 'fast-deep-equal'; - -import { IS_DRAGGING_CLASS_NAME } from '../../../../common/components/drag_and_drop/helpers'; -import { DataProvider } from '../../timeline/data_providers/data_provider'; -import { flattenIntoAndGroups } from '../../timeline/data_providers/helpers'; -import { DataProviders } from '../../timeline/data_providers'; -import * as i18n from './translations'; -import { useSourcererScope } from '../../../../common/containers/sourcerer'; -import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; - -export const FLYOUT_BUTTON_CLASS_NAME = 'timeline-flyout-button'; - -export const getBadgeCount = (dataProviders: DataProvider[]): number => - flattenIntoAndGroups(dataProviders).reduce((total, group) => total + group.length, 0); - -const SHOW_HIDE_TRANSLATE_X = 501; // px - -const Container = styled.div` - padding-top: 8px; - position: fixed; - right: 0px; - top: 40%; - transform: translateX(${SHOW_HIDE_TRANSLATE_X}px); - user-select: none; - width: 500px; - z-index: ${({ theme }) => theme.eui.euiZLevel9}; - - .${IS_DRAGGING_CLASS_NAME} & { - transform: none; - } - - .${FLYOUT_BUTTON_CLASS_NAME} { - background: ${({ theme }) => rgba(theme.eui.euiPageBackgroundColor, 1)}; - border-radius: 4px 4px 0 0; - box-shadow: none; - height: 46px; - } - - .${IS_DRAGGING_CLASS_NAME} & .${FLYOUT_BUTTON_CLASS_NAME} { - color: ${({ theme }) => theme.eui.euiColorSuccess}; - background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.2)} !important; - border: 1px solid ${({ theme }) => theme.eui.euiColorSuccess}; - border-bottom: none; - text-decoration: none; - } -`; - -Container.displayName = 'Container'; - -const BadgeButtonContainer = styled.div` - align-items: flex-start; - display: flex; - flex-direction: row; - left: -87px; - position: absolute; - top: 34px; - transform: rotate(-90deg); -`; - -BadgeButtonContainer.displayName = 'BadgeButtonContainer'; - -const DataProvidersPanel = styled(EuiPanel)` - border-radius: 0; - padding: 0 4px 0 4px; - user-select: none; - z-index: ${({ theme }) => theme.eui.euiZLevel9}; -`; - -interface FlyoutButtonProps { - dataProviders: DataProvider[]; - onOpen: () => void; - show: boolean; - timelineId: string; -} - -export const FlyoutButton = React.memo( - ({ onOpen, show, dataProviders, timelineId }) => { - const badgeCount = useMemo(() => getBadgeCount(dataProviders), [dataProviders]); - const { browserFields } = useSourcererScope(SourcererScopeName.timeline); - - const badgeStyles: React.CSSProperties = useMemo( - () => ({ - left: '-9px', - position: 'relative', - top: '-6px', - transform: 'rotate(90deg)', - visibility: dataProviders.length !== 0 ? 'inherit' : 'hidden', - zIndex: 10, - }), - [dataProviders.length] - ); - - if (!show) { - return null; - } - - return ( - - - - {i18n.FLYOUT_BUTTON} - - - {badgeCount} - - - - - - - ); - }, - (prevProps, nextProps) => - prevProps.show === nextProps.show && - deepEqual(prevProps.dataProviders, nextProps.dataProviders) && - prevProps.timelineId === nextProps.timelineId -); - -FlyoutButton.displayName = 'FlyoutButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx new file mode 100644 index 0000000000000..0b086610da82a --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { isEmpty } from 'lodash/fp'; +import styled from 'styled-components'; + +import { TimelineType } from '../../../../../common/types/timeline'; +import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/translations'; +import { timelineActions } from '../../../store/timeline'; + +const ButtonWrapper = styled(EuiFlexItem)` + flex-direction: row; + align-items: center; +`; + +interface ActiveTimelinesProps { + timelineId: string; + timelineTitle: string; + timelineType: TimelineType; + isOpen: boolean; +} + +const ActiveTimelinesComponent: React.FC = ({ + timelineId, + timelineType, + timelineTitle, + isOpen, +}) => { + const dispatch = useDispatch(); + + const handleToggleOpen = useCallback( + () => dispatch(timelineActions.showTimeline({ id: timelineId, show: !isOpen })), + [dispatch, isOpen, timelineId] + ); + + const title = !isEmpty(timelineTitle) + ? timelineTitle + : timelineType === TimelineType.template + ? UNTITLED_TEMPLATE + : UNTITLED_TIMELINE; + + return ( + + + + {title} + + + + ); +}; + +export const ActiveTimelines = React.memo(ActiveTimelinesComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index 0737db7a00788..b22d071a97d12 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -4,154 +4,236 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; -import { Dispatch } from 'redux'; - -import { isEmpty, get } from 'lodash/fp'; -import { TimelineType } from '../../../../../common/types/timeline'; -import { History } from '../../../../common/lib/history'; -import { Note } from '../../../../common/lib/note'; -import { appSelectors, inputsModel, inputsSelectors, State } from '../../../../common/store'; -import { Properties } from '../../timeline/properties'; -import { appActions } from '../../../../common/store/app'; -import { inputsActions } from '../../../../common/store/inputs'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiToolTip, + EuiButtonIcon, + EuiText, + EuiTextColor, +} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { isEmpty, get, pick } from 'lodash/fp'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; +import { FormattedRelative } from '@kbn/i18n/react'; + +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; -import { TimelineModel } from '../../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; -import { InputsModelId } from '../../../../common/store/inputs/constants'; +import { AddToFavoritesButton } from '../../timeline/properties/helpers'; + +import { AddToCaseButton } from '../add_to_case_button'; +import { AddTimelineButton } from '../add_timeline_button'; +import { SaveTimelineButton } from '../../timeline/header/save_timeline_button'; +import { InspectButton } from '../../../../common/components/inspect'; +import { ActiveTimelines } from './active_timelines'; +import * as i18n from './translations'; +import * as commonI18n from '../../timeline/properties/translations'; + +// to hide side borders +const StyledPanel = styled(EuiPanel)` + margin: 0 -1px 0; +`; -interface OwnProps { +interface FlyoutHeaderProps { timelineId: string; - usersViewing: string[]; } -type Props = OwnProps & PropsFromRedux; +interface FlyoutHeaderPanelProps { + timelineId: string; +} + +const FlyoutHeaderPanelComponent: React.FC = ({ timelineId }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { dataProviders, kqlQuery, title, timelineType, show } = useDeepEqualSelector((state) => + pick( + ['dataProviders', 'kqlQuery', 'title', 'timelineType', 'show'], + getTimeline(state, timelineId) ?? timelineDefaults + ) + ); + const isDataInTimeline = useMemo( + () => !isEmpty(dataProviders) || !isEmpty(get('filterQuery.kuery.expression', kqlQuery)), + [dataProviders, kqlQuery] + ); + + const handleClose = useCallback( + () => dispatch(timelineActions.showTimeline({ id: timelineId, show: false })), + [dispatch, timelineId] + ); + + return ( + + + + + + + {show && ( + + + + + + + + + + + + + )} + + + ); +}; + +export const FlyoutHeaderPanel = React.memo(FlyoutHeaderPanelComponent); + +const StyledTimelineHeader = styled(EuiFlexGroup)` + margin: 0; + flex: 0; +`; + +const RowFlexItem = styled(EuiFlexItem)` + flex-direction: row; + align-items: center; +`; + +const TimelineNameComponent: React.FC = ({ timelineId }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { title, timelineType } = useDeepEqualSelector((state) => + pick(['title', 'timelineType'], getTimeline(state, timelineId) ?? timelineDefaults) + ); + const placeholder = useMemo( + () => + timelineType === TimelineType.template + ? commonI18n.UNTITLED_TEMPLATE + : commonI18n.UNTITLED_TIMELINE, + [timelineType] + ); + + const content = useMemo(() => (title.length ? title : placeholder), [title, placeholder]); + + return ( + <> + +

{content}

+
+ + + ); +}; + +const TimelineName = React.memo(TimelineNameComponent); -const StatefulFlyoutHeader = React.memo( - ({ - associateNote, +const TimelineDescriptionComponent: React.FC = ({ timelineId }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const description = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).description + ); + + const content = useMemo(() => (description.length ? description : commonI18n.DESCRIPTION), [ description, - graphEventId, - isDataInTimeline, - isDatepickerLocked, - isFavorite, - noteIds, - notesById, - status, - timelineId, - timelineType, - title, - toggleLock, - updateDescription, - updateIsFavorite, - updateNote, - updateTitle, - usersViewing, - }) => { - const getNotesByIds = useCallback( - (noteIdsVar: string[]): Note[] => appSelectors.getNotes(notesById, noteIdsVar), - [notesById] - ); + ]); + + return ( + <> + + {content} + + + + ); +}; + +const TimelineDescription = React.memo(TimelineDescriptionComponent); + +const TimelineStatusInfoComponent: React.FC = ({ timelineId }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { status: timelineStatus, updated } = useDeepEqualSelector((state) => + pick(['status', 'updated'], getTimeline(state, timelineId) ?? timelineDefaults) + ); + + const isUnsaved = useMemo(() => timelineStatus === TimelineStatus.draft, [timelineStatus]); + + if (isUnsaved) { return ( - + + + {'Unsaved'} + + ); } -); -StatefulFlyoutHeader.displayName = 'StatefulFlyoutHeader'; - -const emptyHistory: History[] = []; // stable reference - -const emptyNotesId: string[] = []; // stable reference - -const makeMapStateToProps = () => { - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const getNotesByIds = appSelectors.notesByIdsSelector(); - const getGlobalInput = inputsSelectors.globalSelector(); - const mapStateToProps = (state: State, { timelineId }: OwnProps) => { - const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; - const globalInput: inputsModel.InputsRange = getGlobalInput(state); - const { - dataProviders, - description = '', - graphEventId, - isFavorite = false, - kqlQuery, - title = '', - noteIds = emptyNotesId, - status, - timelineType = TimelineType.default, - } = timeline; - - const history = emptyHistory; // TODO: get history from store via selector - - return { - description, - graphEventId, - history, - isDataInTimeline: - !isEmpty(dataProviders) || !isEmpty(get('filterQuery.kuery.expression', kqlQuery)), - isFavorite, - isDatepickerLocked: globalInput.linkTo.includes('timeline'), - noteIds, - notesById: getNotesByIds(state), - status, - title, - timelineType, - }; - }; - return mapStateToProps; + return ( + + + {i18n.AUTOSAVED}{' '} + + + + ); }; -const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ - associateNote: (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), - updateDescription: ({ - id, - description, - disableAutoSave, - }: { - id: string; - description: string; - disableAutoSave?: boolean; - }) => dispatch(timelineActions.updateDescription({ id, description, disableAutoSave })), - updateIsFavorite: ({ id, isFavorite }: { id: string; isFavorite: boolean }) => - dispatch(timelineActions.updateIsFavorite({ id, isFavorite })), - updateNote: (note: Note) => dispatch(appActions.updateNote({ note })), - updateTitle: ({ - id, - title, - disableAutoSave, - }: { - id: string; - title: string; - disableAutoSave?: boolean; - }) => dispatch(timelineActions.updateTitle({ id, title, disableAutoSave })), - toggleLock: ({ linkToId }: { linkToId: InputsModelId }) => - dispatch(inputsActions.toggleTimelineLinkTo({ linkToId })), -}); - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const FlyoutHeader = connector(StatefulFlyoutHeader); +const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent); + +const FlyoutHeaderComponent: React.FC = ({ timelineId }) => ( + + + + + + + + + + + + + + + + {/* KPIs PLACEHOLDER */} + + + + + + + + + + + + +); + +FlyoutHeaderComponent.displayName = 'FlyoutHeaderComponent'; + +export const FlyoutHeader = React.memo(FlyoutHeaderComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts similarity index 58% rename from x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/translations.ts rename to x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts index f35193bfb8d6f..ef9b88d65c551 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts @@ -12,3 +12,17 @@ export const CLOSE_TIMELINE = i18n.translate( defaultMessage: 'Close timeline', } ); + +export const AUTOSAVED = i18n.translate( + 'xpack.securitySolution.timeline.properties.autosavedLabel', + { + defaultMessage: 'Autosaved', + } +); + +export const INSPECT_TIMELINE_TITLE = i18n.translate( + 'xpack.securitySolution.timeline.properties.inspectTimelineTitle', + { + defaultMessage: 'Timeline', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap deleted file mode 100644 index d0d7a1cd7f5d7..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FlyoutHeaderWithCloseButton renders correctly against snapshot 1`] = ` - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx deleted file mode 100644 index cfdca8950d314..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount, shallow } from 'enzyme'; -import React from 'react'; - -import { TimelineType } from '../../../../../common/types/timeline'; -import { TestProviders } from '../../../../common/mock'; -import '../../../../common/mock/match_media'; -import { FlyoutHeaderWithCloseButton } from '.'; - -jest.mock('react-router-dom', () => { - const original = jest.requireActual('react-router-dom'); - - return { - ...original, - useHistory: jest.fn(), - }; -}); -jest.mock('../../../../common/lib/kibana', () => { - const original = jest.requireActual('../../../../common/lib/kibana'); - - return { - ...original, - useKibana: jest.fn().mockReturnValue({ - services: { - application: { - capabilities: { - siem: { - crud: true, - }, - }, - }, - }, - }), - useUiSetting$: jest.fn().mockReturnValue([]), - useGetUserSavedObjectPermissions: jest.fn(), - }; -}); - -describe('FlyoutHeaderWithCloseButton', () => { - const props = { - onClose: jest.fn(), - timelineId: 'test', - timelineType: TimelineType.default, - usersViewing: ['elastic'], - }; - test('renders correctly against snapshot', () => { - const EmptyComponent = shallow( - - - - ); - expect(EmptyComponent.find('FlyoutHeaderWithCloseButton')).toMatchSnapshot(); - }); - - test('it should invoke onClose when the close button is clicked', () => { - const closeMock = jest.fn(); - const testProps = { - ...props, - onClose: closeMock, - }; - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="close-timeline"] button').first().simulate('click'); - - expect(closeMock).toBeCalled(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.tsx deleted file mode 100644 index a4d9f0e8293df..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; -import styled from 'styled-components'; - -import { FlyoutHeader } from '../header'; -import * as i18n from './translations'; - -const FlyoutHeaderContainer = styled.div` - align-items: center; - display: flex; - flex-direction: row; - justify-content: space-between; - width: 100%; -`; - -// manually wrap the close button because EuiButtonIcon can't be a wrapped `styled` -const WrappedCloseButton = styled.div` - margin-right: 5px; -`; - -const FlyoutHeaderWithCloseButtonComponent: React.FC<{ - onClose: () => void; - timelineId: string; - usersViewing: string[]; -}> = ({ onClose, timelineId, usersViewing }) => ( - - - - - - - - -); - -export const FlyoutHeaderWithCloseButton = React.memo(FlyoutHeaderWithCloseButtonComponent); - -FlyoutHeaderWithCloseButton.displayName = 'FlyoutHeaderWithCloseButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx index c163ab1ae448b..5d118b357c8ef 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx @@ -18,11 +18,9 @@ import { createSecuritySolutionStorageMock, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; -import { mockDataProviders } from '../timeline/data_providers/mock/mock_data_providers'; import * as timelineActions from '../../store/timeline/actions'; import { Flyout } from '.'; -import { FlyoutButton } from './button'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => { @@ -39,8 +37,6 @@ jest.mock('../timeline', () => ({ StatefulTimeline: () =>
, })); -const usersViewing = ['elastic']; - describe('Flyout', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); @@ -53,25 +49,25 @@ describe('Flyout', () => { test('it renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(wrapper.find('Flyout')).toMatchSnapshot(); }); - test('it renders the default flyout state as a button', () => { + test('it renders the default flyout state as a bottom bar', () => { const wrapper = mount( - + ); - expect( - wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').first().text() - ).toContain('Timeline'); + expect(wrapper.find('[data-test-subj="flyoutBottomBar"]').first().text()).toContain( + 'Untitled timeline' + ); }); - test('it does NOT render the fly out button when its state is set to flyout is true', () => { + test('it does NOT render the fly out bottom bar when its state is set to flyout is true', () => { const stateShowIsTrue = set('timeline.timelineById.test.show', true, state); const storeShowIsTrue = createStore( stateShowIsTrue, @@ -83,7 +79,7 @@ describe('Flyout', () => { const wrapper = mount( - + ); @@ -92,93 +88,10 @@ describe('Flyout', () => { ); }); - test('it does render the data providers badge when the number is greater than 0', () => { - const stateWithDataProviders = set( - 'timeline.timelineById.test.dataProviders', - mockDataProviders, - state - ); - const storeWithDataProviders = createStore( - stateWithDataProviders, - SUB_PLUGINS_REDUCER, - apolloClientObservable, - kibanaObservable, - storage - ); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="badge"]').exists()).toEqual(true); - }); - - test('it renders the correct number of data providers badge when the number is greater than 0', () => { - const stateWithDataProviders = set( - 'timeline.timelineById.test.dataProviders', - mockDataProviders, - state - ); - const storeWithDataProviders = createStore( - stateWithDataProviders, - SUB_PLUGINS_REDUCER, - apolloClientObservable, - kibanaObservable, - storage - ); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="badge"]').first().text()).toContain('10'); - }); - - test('it hides the data providers badge when the timeline does NOT have data providers', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="badge"]').first().props().style!.visibility).toEqual( - 'hidden' - ); - }); - - test('it does NOT hide the data providers badge when the timeline has data providers', () => { - const stateWithDataProviders = set( - 'timeline.timelineById.test.dataProviders', - mockDataProviders, - state - ); - const storeWithDataProviders = createStore( - stateWithDataProviders, - SUB_PLUGINS_REDUCER, - apolloClientObservable, - kibanaObservable, - storage - ); - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="badge"]').first().props().style!.visibility).toEqual( - 'inherit' - ); - }); - test('should call the onOpen when the mouse is clicked for rendering', () => { const wrapper = mount( - + ); @@ -187,74 +100,4 @@ describe('Flyout', () => { expect(mockDispatch).toBeCalledWith(timelineActions.showTimeline({ id: 'test', show: true })); }); }); - - describe('showFlyoutButton', () => { - test('should show the flyout button when show is true', () => { - const openMock = jest.fn(); - const wrapper = mount( - - - - ); - expect(wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').exists()).toEqual( - true - ); - }); - - test('should NOT show the flyout button when show is false', () => { - const openMock = jest.fn(); - const wrapper = mount( - - - - ); - expect(wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').exists()).toEqual( - false - ); - }); - - test('should return the flyout button with text', () => { - const openMock = jest.fn(); - const wrapper = mount( - - - - ); - expect( - wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').first().text() - ).toContain('Timeline'); - }); - - test('should call the onOpen when it is clicked', () => { - const openMock = jest.fn(); - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="flyoutOverlay"]').first().simulate('click'); - - expect(openMock).toBeCalled(); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index f5ad6264f95e2..a1e61b9fa4ae6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -4,27 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiBadge } from '@elastic/eui'; -import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { DataProvider } from '../timeline/data_providers/data_provider'; -import { FlyoutButton } from './button'; +import { FlyoutBottomBar } from './bottom_bar'; import { Pane } from './pane'; -import { timelineActions, timelineSelectors } from '../../store/timeline'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; - -export const Badge = (styled(EuiBadge)` - position: absolute; - padding-left: 4px; - padding-right: 4px; - right: 0%; - top: 0%; - border-bottom-left-radius: 5px; -` as unknown) as typeof EuiBadge; - -Badge.displayName = 'Badge'; +import { timelineSelectors } from '../../store/timeline'; +import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { timelineDefaults } from '../../store/timeline/defaults'; const Visible = styled.div<{ show?: boolean }>` visibility: ${({ show }) => (show ? 'visible' : 'hidden')}; @@ -34,38 +21,22 @@ Visible.displayName = 'Visible'; interface OwnProps { timelineId: string; - usersViewing: string[]; } -const DEFAULT_DATA_PROVIDERS: DataProvider[] = []; -const DEFAULT_TIMELINE_BY_ID = {}; - -const FlyoutComponent: React.FC = ({ timelineId, usersViewing }) => { - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const dispatch = useDispatch(); - const { dataProviders = DEFAULT_DATA_PROVIDERS, show = false } = useDeepEqualSelector( - (state) => getTimeline(state, timelineId) ?? DEFAULT_TIMELINE_BY_ID - ); - const handleClose = useCallback( - () => dispatch(timelineActions.showTimeline({ id: timelineId, show: false })), - [dispatch, timelineId] - ); - const handleOpen = useCallback( - () => dispatch(timelineActions.showTimeline({ id: timelineId, show: true })), - [dispatch, timelineId] +const FlyoutComponent: React.FC = ({ timelineId }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const show = useShallowEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).show ); return ( <> - + + + + - ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/__snapshots__/index.test.tsx.snap index 4a314d76a51bf..5c9123ed8810e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/__snapshots__/index.test.tsx.snap @@ -2,8 +2,6 @@ exports[`Pane renders correctly against snapshot 1`] = ` `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx index fed6a39ae2ed5..46f3fc4a86413 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx @@ -14,7 +14,7 @@ describe('Pane', () => { test('renders correctly against snapshot', () => { const EmptyComponent = shallow( - + ); expect(EmptyComponent.find('Pane')).toMatchSnapshot(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index 10eb140515826..c112b40f908c1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -5,48 +5,49 @@ */ import { EuiFlyout } from '@elastic/eui'; -import React from 'react'; +import React, { useCallback } from 'react'; import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; -import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; import { StatefulTimeline } from '../../timeline'; import * as i18n from './translations'; +import { timelineActions } from '../../../store/timeline'; interface FlyoutPaneComponentProps { - onClose: () => void; timelineId: string; - usersViewing: string[]; } const EuiFlyoutContainer = styled.div` .timeline-flyout { - z-index: 4001; + z-index: ${({ theme }) => theme.eui.euiZLevel8}; min-width: 150px; width: 100%; animation: none; } `; -const FlyoutPaneComponent: React.FC = ({ - onClose, - timelineId, - usersViewing, -}) => ( - - - - - - - -); +const FlyoutPaneComponent: React.FC = ({ timelineId }) => { + const dispatch = useDispatch(); + const handleClose = useCallback( + () => dispatch(timelineActions.showTimeline({ id: timelineId, show: false })), + [dispatch, timelineId] + ); + + return ( + + + + + + ); +}; export const Pane = React.memo(FlyoutPaneComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx index 65210ab2fd60a..f102193475027 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx @@ -6,6 +6,7 @@ import { isArray, isEmpty, isString, uniq } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; +import deepEqual from 'fast-deep-equal'; import { DragEffects, @@ -203,7 +204,15 @@ const AddressLinksComponent: React.FC = ({ return <>{content}; }; -const AddressLinks = React.memo(AddressLinksComponent); +const AddressLinks = React.memo( + AddressLinksComponent, + (prevProps, nextProps) => + prevProps.contextId === nextProps.contextId && + prevProps.eventId === nextProps.eventId && + prevProps.fieldName === nextProps.fieldName && + prevProps.truncate === nextProps.truncate && + deepEqual(prevProps.addresses, nextProps.addresses) +); const FormattedIpComponent: React.FC<{ contextId: string; @@ -253,4 +262,12 @@ const FormattedIpComponent: React.FC<{ } }; -export const FormattedIp = React.memo(FormattedIpComponent); +export const FormattedIp = React.memo( + FormattedIpComponent, + (prevProps, nextProps) => + prevProps.contextId === nextProps.contextId && + prevProps.eventId === nextProps.eventId && + prevProps.fieldName === nextProps.fieldName && + prevProps.truncate === nextProps.truncate && + deepEqual(prevProps.value, nextProps.value) +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx index bddbd05aae999..3d5e548e726e5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx @@ -10,12 +10,13 @@ import React from 'react'; import { useFullScreen } from '../../../common/containers/use_full_screen'; import { mockTimelineModel, TestProviders } from '../../../common/mock'; -import { TimelineId, TimelineType } from '../../../../common/types/timeline'; +import { TimelineId } from '../../../../common/types/timeline'; import { GraphOverlay } from '.'; jest.mock('../../../common/hooks/use_selector', () => ({ - useShallowEqualSelector: jest.fn().mockReturnValue(mockTimelineModel), + useShallowEqualSelector: jest.fn().mockReturnValue(mockTimelineModel.savedObjectId), + useDeepEqualSelector: jest.fn().mockReturnValue(mockTimelineModel), })); jest.mock('../../../common/containers/use_full_screen', () => ({ @@ -39,12 +40,7 @@ describe('GraphOverlay', () => { test('it has 100% width when isEventViewer is true and NOT in full screen mode', async () => { const wrapper = mount( - + ); @@ -64,12 +60,7 @@ describe('GraphOverlay', () => { const wrapper = mount( - + ); @@ -87,12 +78,7 @@ describe('GraphOverlay', () => { test('it has 100% width when isEventViewer is false and NOT in full screen mode', async () => { const wrapper = mount( - + ); @@ -112,12 +98,7 @@ describe('GraphOverlay', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index c3247c337ac3a..74185c9a803ac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -12,26 +12,21 @@ import { EuiHorizontalRule, EuiToolTip, } from '@elastic/eui'; -import { noop } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; -import { connect, ConnectedProps, useDispatch } from 'react-redux'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; import { DEFAULT_INDEX_KEY, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; import { useFullScreen } from '../../../common/containers/use_full_screen'; -import { State } from '../../../common/store'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; -import { TimelineId, TimelineType } from '../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; -import { TimelineModel } from '../../store/timeline/model'; import { isFullScreen } from '../timeline/body/column_headers'; -import { NewCase, ExistingCase } from '../timeline/properties/helpers'; import { updateTimelineGraphEventId } from '../../../timelines/store/timeline/actions'; import { Resolver } from '../../../resolver/view'; -import { useAllCasesModal } from '../../../cases/components/use_all_cases_modal'; import * as i18n from './translations'; import { useUiSetting$ } from '../../../common/lib/kibana'; @@ -42,7 +37,7 @@ const OverlayContainer = styled.div` ` display: flex; flex-direction: column; - height: 100%; + flex: 1; width: ${$restrictWidth ? 'calc(100% - 36px)' : '100%'}; `} `; @@ -56,26 +51,26 @@ const FullScreenButtonIcon = styled(EuiButtonIcon)` `; interface OwnProps { - graphEventId?: string; isEventViewer: boolean; timelineId: string; - timelineType: TimelineType; } -const Navigation = ({ - fullScreen, - globalFullScreen, - onCloseOverlay, - timelineId, - timelineFullScreen, - toggleFullScreen, -}: { +interface NavigationProps { fullScreen: boolean; globalFullScreen: boolean; onCloseOverlay: () => void; timelineId: string; timelineFullScreen: boolean; toggleFullScreen: () => void; +} + +const NavigationComponent: React.FC = ({ + fullScreen, + globalFullScreen, + onCloseOverlay, + timelineId, + timelineFullScreen, + toggleFullScreen, }) => ( @@ -83,54 +78,53 @@ const Navigation = ({ {i18n.CLOSE_ANALYZER} - - - - - + {timelineId !== TimelineId.active && ( + + + + + + )} ); -const GraphOverlayComponent = ({ - graphEventId, - isEventViewer, - status, - timelineId, - title, - timelineType, -}: OwnProps & PropsFromRedux) => { +NavigationComponent.displayName = 'NavigationComponent'; + +const Navigation = React.memo(NavigationComponent); + +const GraphOverlayComponent: React.FC = ({ isEventViewer, timelineId }) => { const dispatch = useDispatch(); const onCloseOverlay = useCallback(() => { dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: '' })); }, [dispatch, timelineId]); - - const currentTimeline = useShallowEqualSelector((state) => - timelineSelectors.selectTimeline(state, timelineId) + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).graphEventId ); - const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId }); - const { timelineFullScreen, setTimelineFullScreen, globalFullScreen, setGlobalFullScreen, } = useFullScreen(); + const fullScreen = useMemo( () => isFullScreen({ globalFullScreen, timelineId, timelineFullScreen }), [globalFullScreen, timelineId, timelineFullScreen] ); + const toggleFullScreen = useCallback(() => { if (timelineId === TimelineId.active) { setTimelineFullScreen(!timelineFullScreen); @@ -172,61 +166,19 @@ const GraphOverlayComponent = ({ toggleFullScreen={toggleFullScreen} /> - {timelineId === TimelineId.active && timelineType === TimelineType.default && ( - - - - - - - - - - - )} + {graphEventId !== undefined && indices !== null && ( )} - ); }; -const makeMapStateToProps = () => { - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const mapStateToProps = (state: State, { timelineId }: OwnProps) => { - const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; - const { status, title = '' } = timeline; - - return { - status, - title, - }; - }; - return mapStateToProps; -}; - -const connector = connect(makeMapStateToProps); - -type PropsFromRedux = ConnectedProps; - -export const GraphOverlay = connector(GraphOverlayComponent); +export const GraphOverlay = React.memo(GraphOverlayComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 53bc76bfeb8e8..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,38 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AddNote renders correctly 1`] = ` - - - - - - - - - Add Note - - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx index 01dfd72a22db1..98a10f2a1a0b5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx @@ -4,31 +4,46 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mount, shallow } from 'enzyme'; +import { mount } from 'enzyme'; import React from 'react'; +import { TestProviders } from '../../../../common/mock'; import { AddNote } from '.'; -import { TimelineStatus } from '../../../../../common/types/timeline'; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); describe('AddNote', () => { const note = 'The contents of a new note'; const props = { associateNote: jest.fn(), - getNewNoteId: jest.fn(), newNote: note, onCancelAddNote: jest.fn(), updateNewNote: jest.fn(), - updateNote: jest.fn(), - status: TimelineStatus.active, }; test('renders correctly', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const wrapper = mount( + + + + ); + expect(wrapper.find('AddNote').exists()).toBeTruthy(); }); test('it renders the Cancel button when onCancelAddNote is provided', () => { - const wrapper = mount(); + const wrapper = mount( + + + + ); expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(true); }); @@ -40,7 +55,11 @@ describe('AddNote', () => { onCancelAddNote, }; - const wrapper = mount(); + const wrapper = mount( + + + + ); wrapper.find('[data-test-subj="cancel"]').first().simulate('click'); @@ -54,7 +73,11 @@ describe('AddNote', () => { associateNote, }; - const wrapper = mount(); + const wrapper = mount( + + + + ); wrapper.find('[data-test-subj="cancel"]').first().simulate('click'); @@ -66,13 +89,21 @@ describe('AddNote', () => { ...props, onCancelAddNote: undefined, }; - const wrapper = mount(); + const wrapper = mount( + + + + ); expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(false); }); test('it renders the contents of the note', () => { - const wrapper = mount(); + const wrapper = mount( + + + + ); expect( wrapper.find('[data-test-subj="add-a-note"] .euiMarkdownEditorDropZone').first().text() @@ -86,26 +117,30 @@ describe('AddNote', () => { newNote: note, associateNote, }; - const wrapper = mount(); + const wrapper = mount( + + + + ); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); expect(associateNote).toBeCalled(); }); - test('it invokes getNewNoteId when the Add Note button is clicked', () => { - const getNewNoteId = jest.fn(); - const testProps = { - ...props, - getNewNoteId, - }; + // test('it invokes getNewNoteId when the Add Note button is clicked', () => { + // const getNewNoteId = jest.fn(); + // const testProps = { + // ...props, + // getNewNoteId, + // }; - const wrapper = mount(); + // const wrapper = mount(); - wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); + // wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); - expect(getNewNoteId).toBeCalled(); - }); + // expect(getNewNoteId).toBeCalled(); + // }); test('it invokes updateNewNote when the Add Note button is clicked', () => { const updateNewNote = jest.fn(); @@ -114,7 +149,11 @@ describe('AddNote', () => { updateNewNote, }; - const wrapper = mount(); + const wrapper = mount( + + + + ); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); @@ -122,15 +161,14 @@ describe('AddNote', () => { }); test('it invokes updateNote when the Add Note button is clicked', () => { - const updateNote = jest.fn(); - const testProps = { - ...props, - updateNote, - }; - const wrapper = mount(); + const wrapper = mount( + + + + ); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); - expect(updateNote).toBeCalled(); + expect(mockDispatch).toBeCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx index 6ba62a115917f..259cc2d0feb61 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx @@ -7,14 +7,11 @@ import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; -import { - AssociateNote, - GetNewNoteId, - updateAndAssociateNode, - UpdateInternalNewNote, - UpdateNote, -} from '../helpers'; +import { appActions } from '../../../../common/store/app'; +import { Note } from '../../../../common/lib/note'; +import { AssociateNote, updateAndAssociateNode, UpdateInternalNewNote } from '../helpers'; import * as i18n from '../translations'; import { NewNote } from './new_note'; @@ -43,23 +40,27 @@ CancelButton.displayName = 'CancelButton'; /** Displays an input for entering a new note, with an adjacent "Add" button */ export const AddNote = React.memo<{ associateNote: AssociateNote; - getNewNoteId: GetNewNoteId; newNote: string; onCancelAddNote?: () => void; updateNewNote: UpdateInternalNewNote; - updateNote: UpdateNote; -}>(({ associateNote, getNewNoteId, newNote, onCancelAddNote, updateNewNote, updateNote }) => { +}>(({ associateNote, newNote, onCancelAddNote, updateNewNote }) => { + const dispatch = useDispatch(); + + const updateNote = useCallback((note: Note) => dispatch(appActions.updateNote({ note })), [ + dispatch, + ]); + const handleClick = useCallback( () => updateAndAssociateNode({ associateNote, - getNewNoteId, newNote, updateNewNote, updateNote, }), - [associateNote, getNewNoteId, newNote, updateNewNote, updateNote] + [associateNote, newNote, updateNewNote, updateNote] ); + return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/helpers.tsx index 938bc0d222002..a4622f58d34b4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/helpers.tsx @@ -8,6 +8,7 @@ import { EuiIcon, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import moment from 'moment'; import React from 'react'; import styled from 'styled-components'; +import uuid from 'uuid'; import { Note } from '../../../common/lib/note'; @@ -24,8 +25,6 @@ export type GetNewNoteId = () => string; export type UpdateInternalNewNote = (newNote: string) => void; /** Closes the notes popover */ export type OnClosePopover = () => void; -/** Performs IO to associate a note with an event */ -export type AddNoteToEvent = ({ eventId, noteId }: { eventId: string; noteId: string }) => void; /** * Defines the behavior of the search input that appears above the table of data @@ -75,15 +74,9 @@ export const NotesCount = React.memo<{ NotesCount.displayName = 'NotesCount'; /** Creates a new instance of a `note` */ -export const createNote = ({ - newNote, - getNewNoteId, -}: { - newNote: string; - getNewNoteId: GetNewNoteId; -}): Note => ({ +export const createNote = ({ newNote }: { newNote: string }): Note => ({ created: moment.utc().toDate(), - id: getNewNoteId(), + id: uuid.v4(), lastEdit: null, note: newNote.trim(), saveObjectId: null, @@ -93,7 +86,6 @@ export const createNote = ({ interface UpdateAndAssociateNodeParams { associateNote: AssociateNote; - getNewNoteId: GetNewNoteId; newNote: string; updateNewNote: UpdateInternalNewNote; updateNote: UpdateNote; @@ -101,12 +93,11 @@ interface UpdateAndAssociateNodeParams { export const updateAndAssociateNode = ({ associateNote, - getNewNoteId, newNote, updateNewNote, updateNote, }: UpdateAndAssociateNodeParams) => { - const note = createNote({ newNote, getNewNoteId }); + const note = createNote({ newNote }); updateNote(note); // perform IO to store the newly-created note associateNote(note.id); // associate the note with the (opaque) thing updateNewNote(''); // clear the input diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx index 7d083735e6c71..1ba573c0ac6c3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx @@ -11,26 +11,27 @@ import { EuiModalHeader, EuiSpacer, } from '@elastic/eui'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; import { Note } from '../../../common/lib/note'; import { AddNote } from './add_note'; import { columns } from './columns'; -import { AssociateNote, GetNewNoteId, NotesCount, search, UpdateNote } from './helpers'; +import { AssociateNote, NotesCount, search } from './helpers'; import { TimelineStatusLiteral, TimelineStatus } from '../../../../common/types/timeline'; +import { timelineActions } from '../../store/timeline'; +import { appSelectors } from '../../../common/store/app'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; interface Props { associateNote: AssociateNote; - getNotesByIds: (noteIds: string[]) => Note[]; - getNewNoteId: GetNewNoteId; noteIds: string[]; status: TimelineStatusLiteral; - updateNote: UpdateNote; } -const InMemoryTable: typeof EuiInMemoryTable & { displayName: string } = styled( +export const InMemoryTable: typeof EuiInMemoryTable & { displayName: string } = styled( EuiInMemoryTable as React.ComponentType> )` & thead { @@ -41,39 +42,78 @@ const InMemoryTable: typeof EuiInMemoryTable & { displayName: string } = styled( InMemoryTable.displayName = 'InMemoryTable'; /** A view for entering and reviewing notes */ -export const Notes = React.memo( - ({ associateNote, getNotesByIds, getNewNoteId, noteIds, status, updateNote }) => { +export const Notes = React.memo(({ associateNote, noteIds, status }) => { + const getNotesByIds = appSelectors.notesByIdsSelector(); + const [newNote, setNewNote] = useState(''); + const isImmutable = status === TimelineStatus.immutable; + + const notesById = useDeepEqualSelector(getNotesByIds); + + const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); + + return ( + <> + + + + + + {!isImmutable && ( + + )} + + + + + ); +}); + +Notes.displayName = 'Notes'; + +interface NotesTabContentPros { + noteIds: string[]; + timelineId: string; + timelineStatus: TimelineStatusLiteral; +} + +/** A view for entering and reviewing notes */ +export const NotesTabContent = React.memo( + ({ noteIds, timelineStatus, timelineId }) => { + const dispatch = useDispatch(); + const getNotesByIds = appSelectors.notesByIdsSelector(); const [newNote, setNewNote] = useState(''); - const isImmutable = status === TimelineStatus.immutable; + const isImmutable = timelineStatus === TimelineStatus.immutable; + const notesById = useDeepEqualSelector(getNotesByIds); + + const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); + + const associateNote = useCallback( + (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), + [dispatch, timelineId] + ); return ( <> - - - - - - {!isImmutable && ( - - )} - - - + + + {!isImmutable && ( + + )} ); } ); -Notes.displayName = 'Notes'; +NotesTabContent.displayName = 'NotesTabContent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap index 03dc2afc625cd..58cf0ae1e9f8f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap @@ -36,6 +36,7 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "gutterExtraSmall": "4px", "gutterSmall": "8px", }, + "euiBodyLineHeight": 1, "euiBorderColor": "#343741", "euiBorderEditable": "2px dotted #343741", "euiBorderRadius": "4px", @@ -68,7 +69,7 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiButtonHeightSmall": "32px", "euiButtonIconTypes": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "disabled": "#4c4e51", "ghost": "#ffffff", "primary": "#1ba9f5", @@ -139,6 +140,8 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiCodeBlockTitleColor": "#da8b45", "euiCodeBlockTypeColor": "#6092c0", "euiCodeFontFamily": "'Roboto Mono', 'Consolas', 'Menlo', 'Courier', monospace", + "euiCodeFontWeightBold": 700, + "euiCodeFontWeightRegular": 400, "euiCollapsibleNavGroupDarkBackgroundColor": "#131317", "euiCollapsibleNavGroupDarkHighContrastColor": "#1ba9f5", "euiCollapsibleNavGroupLightBackgroundColor": "#1a1b20", @@ -148,9 +151,11 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiColorChartBand": "#2a2b33", "euiColorChartLines": "#343741", "euiColorDanger": "#ff6666", - "euiColorDangerText": "#ff7575", + "euiColorDangerText": "#ff6666", "euiColorDarkShade": "#98a2b3", "euiColorDarkestShade": "#d4dae5", + "euiColorDisabled": "#434548", + "euiColorDisabledText": "#4c4e51", "euiColorEmptyShade": "#1d1e24", "euiColorFullShade": "#ffffff", "euiColorGhost": "#ffffff", @@ -159,6 +164,11 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiColorLightShade": "#343741", "euiColorLightestShade": "#25262e", "euiColorMediumShade": "#535966", + "euiColorPaletteDisplaySizes": Object { + "sizeExtraSmall": "4px", + "sizeMedium": "16px", + "sizeSmall": "8px", + }, "euiColorPickerIndicatorSize": "12px", "euiColorPickerSaturationRange0": "#000000", "euiColorPickerSaturationRange1": "rgba(0, 0, 0, 0)", @@ -220,7 +230,7 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` }, "euiExpressionColors": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "primary": "#1ba9f5", "secondary": "#7de2d1", "subdued": "#81858f", @@ -234,13 +244,14 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` }, "euiFilePickerTallHeight": "128px", "euiFlyoutBorder": "1px solid #343741", - "euiFocusBackgroundColor": "#232635", + "euiFocusBackgroundColor": "#08334a", "euiFocusRingAnimStartColor": "rgba(27, 169, 245, 0)", "euiFocusRingAnimStartSize": "6px", "euiFocusRingAnimStartSizeLarge": "10px", "euiFocusRingColor": "rgba(27, 169, 245, 0.3)", "euiFocusRingSize": "3px", "euiFocusRingSizeLarge": "4px", + "euiFocusTransparency": 0.3, "euiFontFamily": "'Inter UI', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", "euiFontFeatureSettings": "calt 1 kern 1 liga 1", "euiFontSize": "16px", @@ -314,6 +325,16 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiKeyPadMenuSize": "96px", "euiLineHeight": 1.5, "euiLinkColor": "#1ba9f5", + "euiLinkColors": Object { + "accent": "#f990c0", + "danger": "#ff6666", + "ghost": "#ffffff", + "primary": "#1ba9f5", + "secondary": "#7de2d1", + "subdued": "#81858f", + "text": "#dfe5ef", + "warning": "#ffce7a", + }, "euiListGroupGutterTypes": Object { "gutterMedium": "16px", "gutterSmall": "8px", @@ -524,8 +545,8 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiTableFocusClickableColor": "rgba(27, 169, 245, 0.09999999999999998)", "euiTableHoverClickableColor": "rgba(27, 169, 245, 0.050000000000000044)", "euiTableHoverColor": "#1e1e25", - "euiTableHoverSelectedColor": "#202230", - "euiTableSelectedColor": "#232635", + "euiTableHoverSelectedColor": "#072e43", + "euiTableSelectedColor": "#08334a", "euiTextColor": "#dfe5ef", "euiTextColors": Object { "accent": "#f990c0", @@ -721,16 +742,6 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "xs": "4px", "xxl": "40px", }, - "textColors": Object { - "accent": "#f990c0", - "danger": "#ff7575", - "ghost": "#ffffff", - "primary": "#1ba9f5", - "secondary": "#7de2d1", - "subdued": "#81858f", - "text": "#dfe5ef", - "warning": "#ffce7a", - }, "textareaResizing": Object { "both": "resizeBoth", "horizontal": "resizeHorizontal", diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx index 731ff020457a2..8fd95feba6031 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx @@ -5,45 +5,43 @@ */ import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { mount } from 'enzyme'; import '../../../../common/mock/formatted_relative'; -import { Note } from '../../../../common/lib/note'; - import { NoteCards } from '.'; import { TimelineStatus } from '../../../../../common/types/timeline'; +import { TestProviders } from '../../../../common/mock'; + +const getNotesByIds = () => ({ + abc: { + created: new Date(), + id: 'abc', + lastEdit: null, + note: 'a fake note', + saveObjectId: null, + user: 'elastic', + version: null, + }, + def: { + created: new Date(), + id: 'def', + lastEdit: null, + note: 'another fake note', + saveObjectId: null, + user: 'elastic', + version: null, + }, +}); + +jest.mock('../../../../common/hooks/use_selector', () => ({ + useDeepEqualSelector: jest.fn().mockReturnValue(getNotesByIds()), +})); describe('NoteCards', () => { const noteIds = ['abc', 'def']; - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - const getNotesByIds = (_: string[]): Note[] => [ - { - created: new Date(), - id: 'abc', - lastEdit: null, - note: 'a fake note', - saveObjectId: null, - user: 'elastic', - version: null, - }, - { - created: new Date(), - id: 'def', - lastEdit: null, - note: 'another fake note', - saveObjectId: null, - user: 'elastic', - version: null, - }, - ]; const props = { associateNote: jest.fn(), - getNotesByIds, - getNewNoteId: jest.fn(), noteIds, showAddNote: true, status: TimelineStatus.active, @@ -52,10 +50,10 @@ describe('NoteCards', () => { }; test('it renders the notes column when noteIds are specified', () => { - const wrapper = mountWithIntl( - + const wrapper = mount( + - + ); expect(wrapper.find('[data-test-subj="notes"]').exists()).toEqual(true); @@ -63,20 +61,20 @@ describe('NoteCards', () => { test('it does NOT render the notes column when noteIds are NOT specified', () => { const testProps = { ...props, noteIds: [] }; - const wrapper = mountWithIntl( - + const wrapper = mount( + - + ); expect(wrapper.find('[data-test-subj="notes"]').exists()).toEqual(false); }); test('renders note cards', () => { - const wrapper = mountWithIntl( - + const wrapper = mount( + - + ); expect( @@ -86,14 +84,14 @@ describe('NoteCards', () => { .find('.euiMarkdownFormat') .first() .text() - ).toEqual(getNotesByIds(noteIds)[0].note); + ).toEqual(getNotesByIds().abc.note); }); test('it shows controls for adding notes when showAddNote is true', () => { - const wrapper = mountWithIntl( - + const wrapper = mount( + - + ); expect(wrapper.find('[data-test-subj="add-note"]').exists()).toEqual(true); @@ -102,10 +100,10 @@ describe('NoteCards', () => { test('it does NOT show controls for adding notes when showAddNote is false', () => { const testProps = { ...props, showAddNote: false }; - const wrapper = mountWithIntl( - + const wrapper = mount( + - + ); expect(wrapper.find('[data-test-subj="add-note"]').exists()).toEqual(false); 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 62d169b1169dd..4ce4de1851863 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 @@ -5,14 +5,14 @@ */ import { EuiFlexGroup, EuiPanel } from '@elastic/eui'; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import styled from 'styled-components'; -import { Note } from '../../../../common/lib/note'; +import { appSelectors } from '../../../../common/store'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { AddNote } from '../add_note'; -import { AssociateNote, GetNewNoteId, UpdateNote } from '../helpers'; +import { AssociateNote } from '../helpers'; import { NoteCard } from '../note_card'; -import { TimelineStatusLiteral } from '../../../../../common/types/timeline'; const AddNoteContainer = styled.div``; AddNoteContainer.displayName = 'AddNoteContainer'; @@ -46,27 +46,17 @@ NotesContainer.displayName = 'NotesContainer'; interface Props { associateNote: AssociateNote; - getNotesByIds: (noteIds: string[]) => Note[]; - getNewNoteId: GetNewNoteId; noteIds: string[]; showAddNote: boolean; - status: TimelineStatusLiteral; toggleShowAddNote: () => void; - updateNote: UpdateNote; } /** A view for entering and reviewing notes */ export const NoteCards = React.memo( - ({ - associateNote, - getNotesByIds, - getNewNoteId, - noteIds, - showAddNote, - status, - toggleShowAddNote, - updateNote, - }) => { + ({ associateNote, noteIds, showAddNote, toggleShowAddNote }) => { + const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); + const notesById = useDeepEqualSelector(getNotesByIds); + const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); const [newNote, setNewNote] = useState(''); const associateNoteAndToggleShow = useCallback( @@ -81,7 +71,7 @@ export const NoteCards = React.memo( {noteIds.length ? ( - {getNotesByIds(noteIds).map((note) => ( + {items.map((note) => ( @@ -93,11 +83,9 @@ export const NoteCards = React.memo( ) : null} 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 20faf93616a8c..5a1540b970300 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 @@ -15,7 +15,6 @@ import { import { timelineDefaults } from '../../store/timeline/defaults'; import { setTimelineRangeDatePicker as dispatchSetTimelineRangeDatePicker } from '../../../common/store/inputs/actions'; import { - setKqlFilterQueryDraft as dispatchSetKqlFilterQueryDraft, applyKqlFilterQuery as dispatchApplyKqlFilterQuery, addTimeline as dispatchAddTimeline, addNote as dispatchAddGlobalTimelineNote, @@ -45,6 +44,7 @@ import { mockTimeline as mockSelectedTimeline, mockTemplate as mockSelectedTemplate, } from './__mocks__'; +import { TimelineTabs } from '../../store/timeline/model'; jest.mock('../../../common/store/inputs/actions'); jest.mock('../../../common/components/url_state/normalize_time_range.ts'); @@ -237,6 +237,7 @@ describe('helpers', () => { const newTimeline = defaultTimelineToTimelineModel(timeline, false); expect(newTimeline).toEqual({ + activeTab: TimelineTabs.query, columns: [ { columnHeaderType: 'not-filtered', @@ -302,7 +303,6 @@ describe('helpers', () => { kqlMode: 'filter', kqlQuery: { filterQuery: null, - filterQueryDraft: null, }, loadingEventIds: [], noteIds: [], @@ -336,6 +336,7 @@ describe('helpers', () => { const newTimeline = defaultTimelineToTimelineModel(timeline, false, TimelineType.template); expect(newTimeline).toEqual({ + activeTab: TimelineTabs.query, columns: [ { columnHeaderType: 'not-filtered', @@ -401,7 +402,6 @@ describe('helpers', () => { kqlMode: 'filter', kqlQuery: { filterQuery: null, - filterQueryDraft: null, }, loadingEventIds: [], noteIds: [], @@ -435,6 +435,7 @@ describe('helpers', () => { const newTimeline = defaultTimelineToTimelineModel(timeline, false, TimelineType.default); expect(newTimeline).toEqual({ + activeTab: TimelineTabs.query, columns: [ { columnHeaderType: 'not-filtered', @@ -500,7 +501,6 @@ describe('helpers', () => { kqlMode: 'filter', kqlQuery: { filterQuery: null, - filterQueryDraft: null, }, loadingEventIds: [], noteIds: [], @@ -532,6 +532,7 @@ describe('helpers', () => { const newTimeline = defaultTimelineToTimelineModel(timeline, false); expect(newTimeline).toEqual({ + activeTab: TimelineTabs.query, columns: [ { columnHeaderType: 'not-filtered', @@ -597,7 +598,6 @@ describe('helpers', () => { kqlMode: 'filter', kqlQuery: { filterQuery: null, - filterQueryDraft: null, }, loadingEventIds: [], noteIds: [], @@ -629,6 +629,7 @@ describe('helpers', () => { const newTimeline = defaultTimelineToTimelineModel(timeline, false); expect(newTimeline).toEqual({ + activeTab: TimelineTabs.query, savedObjectId: 'savedObject-1', columns: [ { @@ -732,7 +733,6 @@ describe('helpers', () => { kqlMode: 'filter', kqlQuery: { filterQuery: null, - filterQueryDraft: null, }, loadingEventIds: [], title: '', @@ -795,6 +795,7 @@ describe('helpers', () => { const newTimeline = defaultTimelineToTimelineModel(timeline, false); expect(newTimeline).toEqual({ + activeTab: TimelineTabs.query, savedObjectId: 'savedObject-1', columns: [ { @@ -899,7 +900,6 @@ describe('helpers', () => { kqlMode: 'filter', kqlQuery: { filterQuery: null, - filterQueryDraft: null, }, loadingEventIds: [], title: '', @@ -932,6 +932,7 @@ describe('helpers', () => { const newTimeline = defaultTimelineToTimelineModel(timeline, false, TimelineType.template); expect(newTimeline).toEqual({ + activeTab: TimelineTabs.query, columns: [ { columnHeaderType: 'not-filtered', @@ -997,7 +998,6 @@ describe('helpers', () => { kqlMode: 'filter', kqlQuery: { filterQuery: null, - filterQueryDraft: null, }, loadingEventIds: [], noteIds: [], @@ -1031,6 +1031,7 @@ describe('helpers', () => { const newTimeline = defaultTimelineToTimelineModel(timeline, false, TimelineType.default); expect(newTimeline).toEqual({ + activeTab: TimelineTabs.query, columns: [ { columnHeaderType: 'not-filtered', @@ -1096,7 +1097,6 @@ describe('helpers', () => { kqlMode: 'filter', kqlQuery: { filterQuery: null, - filterQueryDraft: null, }, loadingEventIds: [], noteIds: [], @@ -1394,7 +1394,6 @@ describe('helpers', () => { timeline: mockTimelineModel, })(); - expect(dispatchSetKqlFilterQueryDraft).not.toHaveBeenCalled(); expect(dispatchApplyKqlFilterQuery).not.toHaveBeenCalled(); }); @@ -1419,7 +1418,6 @@ describe('helpers', () => { kuery: null, serializedQuery: 'some-serialized-query', }, - filterQueryDraft: null, }, }; timelineDispatch({ @@ -1431,7 +1429,6 @@ describe('helpers', () => { timeline: mockTimeline, })(); - expect(dispatchSetKqlFilterQueryDraft).not.toHaveBeenCalled(); expect(dispatchApplyKqlFilterQuery).not.toHaveBeenCalled(); }); @@ -1443,7 +1440,6 @@ describe('helpers', () => { kuery: { expression: 'expression', kind: 'kuery' as KueryFilterQueryKind }, serializedQuery: 'some-serialized-query', }, - filterQueryDraft: null, }, }; timelineDispatch({ @@ -1455,13 +1451,6 @@ describe('helpers', () => { timeline: mockTimeline, })(); - expect(dispatchSetKqlFilterQueryDraft).toHaveBeenCalledWith({ - id: TimelineId.active, - filterQueryDraft: { - kind: 'kuery', - expression: 'expression', - }, - }); expect(dispatchApplyKqlFilterQuery).toHaveBeenCalledWith({ id: TimelineId.active, filterQuery: { 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 a0090baeb9923..1ee529cc77a91 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 @@ -38,12 +38,15 @@ import { setRelativeRangeDatePicker as dispatchSetRelativeRangeDatePicker, } from '../../../common/store/inputs/actions'; import { - setKqlFilterQueryDraft as dispatchSetKqlFilterQueryDraft, applyKqlFilterQuery as dispatchApplyKqlFilterQuery, addTimeline as dispatchAddTimeline, addNote as dispatchAddGlobalTimelineNote, } from '../../../timelines/store/timeline/actions'; -import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model'; +import { + ColumnHeaderOptions, + TimelineModel, + TimelineTabs, +} from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { @@ -309,6 +312,7 @@ export const formatTimelineResultToModel = ( }; export interface QueryTimelineById { + activeTimelineTab?: TimelineTabs; apolloClient: ApolloClient | ApolloClient<{}> | undefined; duplicate?: boolean; graphEventId?: string; @@ -327,6 +331,7 @@ export interface QueryTimelineById { } export const queryTimelineById = ({ + activeTimelineTab = TimelineTabs.query, apolloClient, duplicate = false, graphEventId = '', @@ -370,6 +375,7 @@ export const queryTimelineById = ({ notes, timeline: { ...timeline, + activeTab: activeTimelineTab, graphEventId, show: openTimeline, dateRange: { start: from, end: to }, @@ -424,15 +430,6 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli timeline.kqlQuery.filterQuery.kuery != null && timeline.kqlQuery.filterQuery.kuery.expression !== '' ) { - dispatch( - dispatchSetKqlFilterQueryDraft({ - id, - filterQueryDraft: { - kind: 'kuery', - expression: timeline.kqlQuery.filterQuery.kuery.expression || '', - }, - }) - ); dispatch( dispatchApplyKqlFilterQuery({ id, @@ -448,8 +445,7 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli } if (duplicate && ruleNote != null && !isEmpty(ruleNote)) { - const getNewNoteId = (): string => uuid.v4(); - const newNote = createNote({ newNote: ruleNote, getNewNoteId }); + const newNote = createNote({ newNote: ruleNote }); dispatch(dispatchUpdateNote({ note: newNote })); dispatch(dispatchAddGlobalTimelineNote({ noteId: newNote.id, id })); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx index f6ac1ab4cec3e..9ca5d0c7b438a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx @@ -18,7 +18,7 @@ import '../../../common/mock/formatted_relative'; import { SecurityPageName } from '../../../app/types'; import { TimelineType } from '../../../../common/types/timeline'; -import { TestProviders, apolloClient, mockOpenTimelineQueryResults } from '../../../common/mock'; +import { TestProviders, mockOpenTimelineQueryResults } from '../../../common/mock'; import { getTimelineTabsUrl } from '../../../common/components/link_to'; import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../pages/timelines_page'; @@ -123,7 +123,6 @@ describe('StatefulOpenTimeline', () => { { { { { { { { { { { { { { { { { { { { { - apolloClient: ApolloClient; /** Displays open timeline in modal */ isModal: boolean; closeModalTimeline?: () => void; @@ -62,8 +59,7 @@ export type OpenTimelineOwnProps = OwnProps & Pick< OpenTimelineProps, 'defaultPageSize' | 'title' | 'importDataModalToggle' | 'setImportDataModalToggle' - > & - PropsFromRedux; + >; /** Returns a collection of selected timeline ids */ export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): string[] => @@ -78,20 +74,17 @@ export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): str /** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */ export const StatefulOpenTimelineComponent = React.memo( ({ - apolloClient, closeModalTimeline, - createNewTimeline, defaultPageSize, hideActions = [], isModal = false, importDataModalToggle, onOpenTimeline, setImportDataModalToggle, - timeline, title, - updateTimeline, - updateIsLoading, }) => { + const apolloClient = useApolloClient(); + const dispatch = useDispatch(); /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ const [itemIdToExpandedNotesRowMap, setItemIdToExpandedNotesRowMap] = useState< Record @@ -111,11 +104,21 @@ export const StatefulOpenTimelineComponent = React.memo( /** The requested field to sort on */ const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timelineSavedObjectId = useShallowEqualSelector( + (state) => getTimeline(state, TimelineId.active)?.savedObjectId ?? '' + ); + const existingIndexNamesSelector = useMemo( () => sourcererSelectors.getAllExistingIndexNamesSelector(), [] ); - const existingIndexNames = useShallowEqualSelector(existingIndexNamesSelector); + const existingIndexNames = useDeepEqualSelector(existingIndexNamesSelector); + + const updateTimeline = useMemo(() => dispatchUpdateTimeline(dispatch), [dispatch]); + const updateIsLoading = useCallback((payload) => dispatch(dispatchUpdateIsLoading(payload)), [ + dispatch, + ]); const { customTemplateTimelineCount, @@ -199,16 +202,18 @@ export const StatefulOpenTimelineComponent = React.memo( const deleteTimelines: DeleteTimelines = useCallback( async (timelineIds: string[]) => { - if (timelineIds.includes(timeline.savedObjectId || '')) { - createNewTimeline({ - id: TimelineId.active, - columns: defaultHeaders, - indexNames: existingIndexNames, - show: false, - }); + if (timelineIds.includes(timelineSavedObjectId)) { + dispatch( + dispatchCreateNewTimeline({ + id: TimelineId.active, + columns: defaultHeaders, + indexNames: existingIndexNames, + show: false, + }) + ); } - await apolloClient.mutate< + await apolloClient!.mutate< DeleteTimelineMutation.Mutation, DeleteTimelineMutation.Variables >({ @@ -218,7 +223,7 @@ export const StatefulOpenTimelineComponent = React.memo( }); refetch(); }, - [apolloClient, createNewTimeline, existingIndexNames, refetch, timeline] + [apolloClient, dispatch, existingIndexNames, refetch, timelineSavedObjectId] ); const onDeleteOneTimeline: OnDeleteOneTimeline = useCallback( @@ -379,36 +384,4 @@ export const StatefulOpenTimelineComponent = React.memo( } ); -const makeMapStateToProps = () => { - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const mapStateToProps = (state: State) => { - const timeline = getTimeline(state, TimelineId.active) ?? timelineDefaults; - return { - timeline, - }; - }; - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => ({ - createNewTimeline: ({ - id, - columns, - indexNames, - show, - }: { - id: string; - columns: ColumnHeaderOptions[]; - indexNames: string[]; - show?: boolean; - }) => dispatch(dispatchCreateNewTimeline({ id, columns, indexNames, show })), - updateIsLoading: ({ id, isLoading }: { id: string; isLoading: boolean }) => - dispatch(dispatchUpdateIsLoading({ id, isLoading })), - updateTimeline: dispatchUpdateTimeline(dispatch), -}); - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const StatefulOpenTimeline = connector(StatefulOpenTimelineComponent); +export const StatefulOpenTimeline = React.memo(StatefulOpenTimelineComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index e9ae66703f017..ae5c7f39dbda6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -160,6 +160,7 @@ export const OpenTimeline = React.memo( }, [onDeleteSelected, deleteTimelines, timelineStatus]); const SearchRowContent = useMemo(() => <>{templateTimelineFilter}, [templateTimelineFilter]); + return ( <> ( - ({ hideActions = [], modalTitle, onClose, onOpen }) => { - const apolloClient = useApolloClient(); - - if (!apolloClient) return null; - - return ( - - - - - - ); - } + ({ hideActions = [], modalTitle, onClose, onOpen }) => ( + + + + + + ) ); OpenTimelineModal.displayName = 'OpenTimelineModal'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index ab07b4e756476..adddb90657252 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -38,7 +38,6 @@ export const OpenTimelineModalBody = memo( onToggleShowNotes, pageIndex, pageSize, - query, searchResults, selectedItems, sortDirection, diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx index 3c3ec1689b244..00cd5453e9669 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx @@ -23,6 +23,7 @@ import React, { useState, useCallback, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { RowRendererId } from '../../../../common/types/timeline'; import { State } from '../../../common/store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; @@ -77,13 +78,16 @@ interface StatefulRowRenderersBrowserProps { timelineId: string; } +const emptyExcludedRowRendererIds: RowRendererId[] = []; + const StatefulRowRenderersBrowserComponent: React.FC = ({ timelineId, }) => { const tableRef = useRef>(); const dispatch = useDispatch(); const excludedRowRendererIds = useShallowEqualSelector( - (state: State) => state.timeline.timelineById[timelineId]?.excludedRowRendererIds || [] + (state: State) => + state.timeline.timelineById[timelineId]?.excludedRowRendererIds || emptyExcludedRowRendererIds ); const [show, setShow] = useState(false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap deleted file mode 100644 index 6081620a27774..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap +++ /dev/null @@ -1,922 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Timeline rendering renders correctly against snapshot 1`] = ` - - - - - - - - - - - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/auto_save_warning/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/auto_save_warning/index.tsx index 98faa84db851e..4fbba4fca75d6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/auto_save_warning/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/auto_save_warning/index.tsx @@ -12,8 +12,9 @@ import { } from '@elastic/eui'; import { getOr } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; -import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import { useDispatch } from 'react-redux'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { setTimelineRangeDatePicker } from '../../../../common/store/inputs/actions'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { useStateToaster } from '../../../../common/components/toasters'; @@ -22,9 +23,8 @@ import * as i18n from './translations'; const AutoSaveWarningMsgComponent = () => { const dispatch = useDispatch(); const dispatchToaster = useStateToaster()[1]; - const { timelineId, newTimelineModel } = useSelector( - timelineSelectors.autoSaveMsgSelector, - shallowEqual + const { timelineId, newTimelineModel } = useDeepEqualSelector( + timelineSelectors.autoSaveMsgSelector ); const handleClick = useCallback(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx index a82821675d956..af8045bf624c3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx @@ -7,39 +7,33 @@ import React from 'react'; import { TimelineType, TimelineStatus } from '../../../../../../common/types/timeline'; -import { AssociateNote, UpdateNote } from '../../../notes/helpers'; +import { AssociateNote } from '../../../notes/helpers'; import * as i18n from '../translations'; import { NotesButton } from '../../properties/helpers'; -import { Note } from '../../../../../common/lib/note'; import { ActionIconItem } from './action_icon_item'; interface AddEventNoteActionProps { associateNote: AssociateNote; - getNotesByIds: (noteIds: string[]) => Note[]; noteIds: string[]; showNotes: boolean; status: TimelineStatus; timelineType: TimelineType; toggleShowNotes: () => void; - updateNote: UpdateNote; } const AddEventNoteActionComponent: React.FC = ({ associateNote, - getNotesByIds, noteIds, showNotes, status, timelineType, toggleShowNotes, - updateNote, }) => ( = ({ toolTip={ timelineType === TimelineType.template ? i18n.NOTES_DISABLE_TOOLTIP : i18n.NOTES_TOOLTIP } - updateNote={updateNote} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 13c2b14d26eca..7772bcede76fc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -1,591 +1,475 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` - - - - - - - - - - - - - + "agent.name": Object { + "aggregatable": true, + "category": "agent", + "description": "Name of the agent. This is a name that can be given to an agent. This can be helpful if for example two Filebeat instances are running on the same host but a human readable separation is needed on which Filebeat instance data is coming from. If no name is given, the name is often left empty.", + "example": "foo", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "agent.name", + "searchable": true, + "type": "string", + }, + }, + }, + "auditd": Object { + "fields": Object { + "auditd.data.a0": Object { + "aggregatable": true, + "category": "auditd", + "description": null, + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + ], + "name": "auditd.data.a0", + "searchable": true, + "type": "string", + }, + "auditd.data.a1": Object { + "aggregatable": true, + "category": "auditd", + "description": null, + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + ], + "name": "auditd.data.a1", + "searchable": true, + "type": "string", + }, + "auditd.data.a2": Object { + "aggregatable": true, + "category": "auditd", + "description": null, + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + ], + "name": "auditd.data.a2", + "searchable": true, + "type": "string", + }, + }, + }, + "base": Object { + "fields": Object { + "@timestamp": Object { + "aggregatable": true, + "category": "base", + "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": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "@timestamp", + "searchable": true, + "type": "date", + }, + }, + }, + "client": Object { + "fields": Object { + "client.address": Object { + "aggregatable": true, + "category": "client", + "description": "Some event client addresses are defined ambiguously. The event will sometimes list an IP, a domain or a unix socket. You should always store the raw address in the \`.address\` field. Then it should be duplicated to \`.ip\` or \`.domain\`, depending on which one it is.", + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "client.address", + "searchable": true, + "type": "string", + }, + "client.bytes": Object { + "aggregatable": true, + "category": "client", + "description": "Bytes sent from the client to the server.", + "example": "184", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "client.bytes", + "searchable": true, + "type": "number", + }, + "client.domain": Object { + "aggregatable": true, + "category": "client", + "description": "Client domain.", + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "client.domain", + "searchable": true, + "type": "string", + }, + "client.geo.country_iso_code": Object { + "aggregatable": true, + "category": "client", + "description": "Country ISO code.", + "example": "CA", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "client.geo.country_iso_code", + "searchable": true, + "type": "string", + }, + }, + }, + "cloud": Object { + "fields": Object { + "cloud.account.id": Object { + "aggregatable": true, + "category": "cloud", + "description": "The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier.", + "example": "666777888999", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "cloud.account.id", + "searchable": true, + "type": "string", + }, + "cloud.availability_zone": Object { + "aggregatable": true, + "category": "cloud", + "description": "Availability zone in which this host is running.", + "example": "us-east-1c", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "cloud.availability_zone", + "searchable": true, + "type": "string", + }, + }, + }, + "container": Object { + "fields": Object { + "container.id": Object { + "aggregatable": true, + "category": "container", + "description": "Unique container id.", + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "container.id", + "searchable": true, + "type": "string", + }, + "container.image.name": Object { + "aggregatable": true, + "category": "container", + "description": "Name of the image the container was built on.", + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "container.image.name", + "searchable": true, + "type": "string", + }, + "container.image.tag": Object { + "aggregatable": true, + "category": "container", + "description": "Container image tag.", + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "container.image.tag", + "searchable": true, + "type": "string", + }, + }, + }, + "destination": Object { + "fields": Object { + "destination.address": Object { + "aggregatable": true, + "category": "destination", + "description": "Some event destination addresses are defined ambiguously. The event will sometimes list an IP, a domain or a unix socket. You should always store the raw address in the \`.address\` field. Then it should be duplicated to \`.ip\` or \`.domain\`, depending on which one it is.", + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "destination.address", + "searchable": true, + "type": "string", + }, + "destination.bytes": Object { + "aggregatable": true, + "category": "destination", + "description": "Bytes sent from the destination to the source.", + "example": "184", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "destination.bytes", + "searchable": true, + "type": "number", + }, + "destination.domain": Object { + "aggregatable": true, + "category": "destination", + "description": "Destination domain.", + "example": null, + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "destination.domain", + "searchable": true, + "type": "string", + }, + "destination.ip": Object { + "aggregatable": true, + "category": "destination", + "description": "IP address of the destination. Can be one or multiple IPv4 or IPv6 addresses.", + "example": "", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "destination.ip", + "searchable": true, + "type": "ip", + }, + "destination.port": Object { + "aggregatable": true, + "category": "destination", + "description": "Port of the destination.", + "example": "", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "destination.port", + "searchable": true, + "type": "long", + }, + }, + }, + "event": Object { + "fields": Object { + "event.end": Object { + "aggregatable": true, + "category": "event", + "description": "event.end contains the date when the event ended or when the activity was last observed.", + "example": null, + "format": "", + "indexes": Array [ + "apm-*-transaction*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "name": "event.end", + "searchable": true, + "type": "date", + }, + }, + }, + "source": Object { + "fields": Object { + "source.ip": Object { + "aggregatable": true, + "category": "source", + "description": "IP address of the source. Can be one or multiple IPv4 or IPv6 addresses.", + "example": "", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "source.ip", + "searchable": true, + "type": "ip", + }, + "source.port": Object { + "aggregatable": true, + "category": "source", + "description": "Port of the source.", + "example": "", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "source.port", + "searchable": true, + "type": "long", + }, + }, + }, + } + } + columnHeaders={ + Array [ + Object { + "columnHeaderType": "not-filtered", + "id": "@timestamp", + "width": 190, + }, + Object { + "columnHeaderType": "not-filtered", + "id": "message", + "width": 180, + }, + Object { + "columnHeaderType": "not-filtered", + "id": "event.category", + "width": 180, + }, + Object { + "columnHeaderType": "not-filtered", + "id": "event.action", + "width": 180, + }, + Object { + "columnHeaderType": "not-filtered", + "id": "host.name", + "width": 180, + }, + Object { + "columnHeaderType": "not-filtered", + "id": "source.ip", + "width": 180, + }, + Object { + "columnHeaderType": "not-filtered", + "id": "destination.ip", + "width": 180, + }, + Object { + "columnHeaderType": "not-filtered", + "id": "user.name", + "width": 180, + }, + ] + } + isSelectAllChecked={false} + onSelectAll={[Function]} + showEventsSelect={false} + showSelectAllCheckbox={false} + sort={ + Object { + "columnId": "fooColumn", + "sortDirection": "desc", + } + } + timelineId="test" +/> `; 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 6e21446944573..8bf9b6ceb346a 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 @@ -8,23 +8,22 @@ import React, { useCallback, useMemo } 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 { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { getDraggableFieldId } from '../../../../../common/components/drag_and_drop/helpers'; -import { OnColumnRemoved, OnColumnSorted, OnFilterChange, OnColumnResized } from '../../events'; +import { OnFilterChange } from '../../events'; import { EventsTh, EventsThContent, EventsHeadingHandle } from '../../styles'; import { Sort } from '../sort'; import { Header } from './header'; +import { timelineActions } from '../../../../store/timeline'; const RESIZABLE_ENABLE = { right: true }; interface ColumneHeaderProps { draggableIndex: number; header: ColumnHeaderOptions; - onColumnRemoved: OnColumnRemoved; - onColumnSorted: OnColumnSorted; - onColumnResized: OnColumnResized; isDragging: boolean; onFilterChange?: OnFilterChange; sort: Sort; @@ -36,12 +35,10 @@ const ColumnHeaderComponent: React.FC = ({ header, timelineId, isDragging, - onColumnRemoved, - onColumnResized, - onColumnSorted, onFilterChange, sort, }) => { + const dispatch = useDispatch(); const resizableSize = useMemo( () => ({ width: header.width, @@ -65,9 +62,15 @@ const ColumnHeaderComponent: React.FC = ({ ); const handleResizeStop: ResizeCallback = useCallback( (e, direction, ref, delta) => { - onColumnResized({ columnId: header.id, delta: delta.width }); + dispatch( + timelineActions.applyDeltaToColumnWidth({ + columnId: header.id, + delta: delta.width, + id: timelineId, + }) + ); }, - [header.id, onColumnResized] + [dispatch, header.id, timelineId] ); const draggableId = useMemo( () => @@ -90,15 +93,13 @@ const ColumnHeaderComponent: React.FC = ({
), - [header, onColumnRemoved, onColumnSorted, onFilterChange, sort, timelineId] + [header, onFilterChange, sort, timelineId] ); return ( @@ -129,9 +130,6 @@ export const ColumnHeader = React.memo( prevProps.draggableIndex === nextProps.draggableIndex && prevProps.timelineId === nextProps.timelineId && prevProps.isDragging === nextProps.isDragging && - prevProps.onColumnRemoved === nextProps.onColumnRemoved && - prevProps.onColumnResized === nextProps.onColumnResized && - prevProps.onColumnSorted === nextProps.onColumnSorted && prevProps.onFilterChange === nextProps.onFilterChange && prevProps.sort === nextProps.sort && deepEqual(prevProps.header, nextProps.header) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap index 3e5ce5a6b4999..517f537b9a01b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap @@ -29,7 +29,7 @@ exports[`Header renders correctly against snapshot 1`] = ` } } isLoading={false} - onColumnRemoved={[MockFunction]} + onColumnRemoved={[Function]} sort={ Object { "columnId": "@timestamp", 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 b211847d06a26..3ef9beb89309e 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 @@ -7,6 +7,7 @@ import { mount, shallow } from 'enzyme'; import React from 'react'; +import { timelineActions } from '../../../../../store/timeline'; import { Direction } from '../../../../../../graphql/types'; import { TestProviders } from '../../../../../../common/mock'; import { ColumnHeaderType } from '../../../../../store/timeline/model'; @@ -17,6 +18,16 @@ import { defaultHeaders } from '../default_headers'; import { HeaderComponent } from '.'; import { getNewSortDirectionOnClick, getNextSortDirection, getSortDirection } from './helpers'; +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + const filteredColumnHeader: ColumnHeaderType = 'text-filter'; describe('Header', () => { @@ -29,28 +40,18 @@ describe('Header', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( - + + + ); - expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('HeaderComponent').dive()).toMatchSnapshot(); }); describe('rendering', () => { test('it renders the header text', () => { const wrapper = mount( - + ); @@ -64,13 +65,7 @@ describe('Header', () => { const headerWithLabel = { ...columnHeader, label }; const wrapper = mount( - + ); @@ -83,13 +78,7 @@ describe('Header', () => { const headerSortable = { ...columnHeader, aggregatable: true }; const wrapper = mount( - + ); @@ -106,13 +95,7 @@ describe('Header', () => { const wrapper = mount( - + ); @@ -124,40 +107,31 @@ describe('Header', () => { describe('onColumnSorted', () => { test('it invokes the onColumnSorted callback when the header sort button is clicked', () => { - const mockOnColumnSorted = jest.fn(); const headerSortable = { ...columnHeader, aggregatable: true }; const wrapper = mount( - + ); wrapper.find('[data-test-subj="header-sort-button"]').first().simulate('click'); - expect(mockOnColumnSorted).toBeCalledWith({ - columnId: columnHeader.id, - sortDirection: 'asc', // (because the previous state was Direction.desc) - }); + expect(mockDispatch).toBeCalledWith( + timelineActions.updateSort({ + id: timelineId, + sort: { + columnId: columnHeader.id, + sortDirection: Direction.asc, // (because the previous state was Direction.desc) + }, + }) + ); }); test('it does NOT render the header sort button when aggregatable is false', () => { - const mockOnColumnSorted = jest.fn(); const headerSortable = { ...columnHeader, aggregatable: false }; const wrapper = mount( - + ); @@ -165,17 +139,10 @@ describe('Header', () => { }); test('it does NOT render the header sort button when aggregatable is missing', () => { - const mockOnColumnSorted = jest.fn(); const headerSortable = { ...columnHeader }; const wrapper = mount( - + ); @@ -187,13 +154,7 @@ describe('Header', () => { const headerSortable = { ...columnHeader, aggregatable: undefined }; const wrapper = mount( - + ); @@ -292,13 +253,7 @@ describe('Header', () => { test('truncates the header text with an ellipsis', () => { const wrapper = mount( - + ); @@ -312,13 +267,7 @@ describe('Header', () => { test('it has a tooltip to display the properties of the field', () => { const wrapper = mount( - + ); 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 1180eb8aed967..15d75cc9a4384 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 @@ -6,9 +6,11 @@ import { noop } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { timelineActions } from '../../../../../store/timeline'; import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; -import { OnColumnRemoved, OnColumnSorted, OnFilterChange } from '../../../events'; +import { OnFilterChange } from '../../../events'; import { Sort } from '../../sort'; import { Actions } from '../actions'; import { Filter } from '../filter'; @@ -18,8 +20,6 @@ import { useManageTimeline } from '../../../../manage_timeline'; interface Props { header: ColumnHeaderOptions; - onColumnRemoved: OnColumnRemoved; - onColumnSorted: OnColumnSorted; onFilterChange?: OnFilterChange; sort: Sort; timelineId: string; @@ -27,26 +27,41 @@ interface Props { export const HeaderComponent: React.FC = ({ header, - onColumnRemoved, - onColumnSorted, onFilterChange = noop, sort, timelineId, }) => { - const onClick = useCallback(() => { - onColumnSorted!({ - columnId: header.id, - sortDirection: getNewSortDirectionOnClick({ - clickedHeader: header, - currentSort: sort, - }), - }); - }, [onColumnSorted, header, sort]); + const dispatch = useDispatch(); + + const onClick = useCallback( + () => + dispatch( + timelineActions.updateSort({ + id: timelineId, + sort: { + columnId: header.id, + sortDirection: getNewSortDirectionOnClick({ + clickedHeader: header, + currentSort: sort, + }), + }, + }) + ), + [dispatch, header, timelineId, sort] + ); + + const onColumnRemoved = useCallback( + (columnId) => dispatch(timelineActions.removeColumn({ id: timelineId, columnId })), + [dispatch, timelineId] + ); + const { getManageTimelineById } = useManageTimeline(); + const isLoading = useMemo(() => getManageTimelineById(timelineId).isLoading, [ getManageTimelineById, timelineId, ]); + return ( <> { ); }); }); + + 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', + width: 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', + width: 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', + width: 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/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 6685ce7d7a018..6919f7b123167 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 @@ -35,20 +35,15 @@ describe('ColumnHeaders', () => { browserFields={mockBrowserFields} columnHeaders={defaultHeaders} isSelectAllChecked={false} - onColumnSorted={jest.fn()} - onColumnRemoved={jest.fn()} - onColumnResized={jest.fn()} onSelectAll={jest.fn} - onUpdateColumns={jest.fn()} showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} timelineId={'test'} - toggleColumn={jest.fn()} /> ); - expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('ColumnHeadersComponent')).toMatchSnapshot(); }); test('it renders the field browser', () => { @@ -59,16 +54,11 @@ describe('ColumnHeaders', () => { browserFields={mockBrowserFields} columnHeaders={defaultHeaders} isSelectAllChecked={false} - onColumnSorted={jest.fn()} - onColumnRemoved={jest.fn()} - onColumnResized={jest.fn()} onSelectAll={jest.fn} - onUpdateColumns={jest.fn()} showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} timelineId={'test'} - toggleColumn={jest.fn()} /> ); @@ -84,16 +74,11 @@ describe('ColumnHeaders', () => { browserFields={mockBrowserFields} columnHeaders={defaultHeaders} isSelectAllChecked={false} - onColumnSorted={jest.fn()} - onColumnRemoved={jest.fn()} - onColumnResized={jest.fn()} onSelectAll={jest.fn} - onUpdateColumns={jest.fn()} showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} timelineId={'test'} - toggleColumn={jest.fn()} /> ); 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 f4d4cf29ba38b..aeab6a774ca41 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 @@ -21,13 +21,7 @@ import { EXIT_FULL_SCREEN } from '../../../../../common/components/exit_full_scr import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../../../common/constants'; import { useFullScreen } from '../../../../../common/containers/use_full_screen'; import { TimelineId } from '../../../../../../common/types/timeline'; -import { - OnColumnRemoved, - OnColumnResized, - OnColumnSorted, - OnSelectAll, - OnUpdateColumns, -} from '../../events'; +import { OnSelectAll } from '../../events'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; import { StatefulFieldsBrowser } from '../../../fields_browser'; import { StatefulRowRenderersBrowser } from '../../../row_renderers_browser'; @@ -52,16 +46,11 @@ interface Props { columnHeaders: ColumnHeaderOptions[]; isEventViewer?: boolean; isSelectAllChecked: boolean; - onColumnRemoved: OnColumnRemoved; - onColumnResized: OnColumnResized; - onColumnSorted: OnColumnSorted; onSelectAll: OnSelectAll; - onUpdateColumns: OnUpdateColumns; showEventsSelect: boolean; showSelectAllCheckbox: boolean; sort: Sort; timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; } interface DraggableContainerProps { @@ -103,16 +92,11 @@ export const ColumnHeadersComponent = ({ columnHeaders, isEventViewer = false, isSelectAllChecked, - onColumnRemoved, - onColumnResized, - onColumnSorted, onSelectAll, - onUpdateColumns, showEventsSelect, showSelectAllCheckbox, sort, timelineId, - toggleColumn, }: Props) => { const [draggingIndex, setDraggingIndex] = useState(null); const { @@ -178,21 +162,10 @@ export const ColumnHeadersComponent = ({ timelineId={timelineId} header={header} isDragging={draggingIndex === draggableIndex} - onColumnRemoved={onColumnRemoved} - onColumnSorted={onColumnSorted} - onColumnResized={onColumnResized} sort={sort} /> )), - [ - columnHeaders, - timelineId, - draggingIndex, - onColumnRemoved, - onColumnSorted, - onColumnResized, - sort, - ] + [columnHeaders, timelineId, draggingIndex, sort] ); const fullScreen = useMemo( @@ -243,9 +216,7 @@ export const ColumnHeadersComponent = ({ columnHeaders={columnHeaders} data-test-subj="field-browser" height={FIELD_BROWSER_HEIGHT} - onUpdateColumns={onUpdateColumns} timelineId={timelineId} - toggleColumn={toggleColumn} width={FIELD_BROWSER_WIDTH} /> @@ -304,16 +275,11 @@ export const ColumnHeaders = React.memo( prevProps.actionsColumnWidth === nextProps.actionsColumnWidth && prevProps.isEventViewer === nextProps.isEventViewer && prevProps.isSelectAllChecked === nextProps.isSelectAllChecked && - prevProps.onColumnRemoved === nextProps.onColumnRemoved && - prevProps.onColumnResized === nextProps.onColumnResized && - prevProps.onColumnSorted === nextProps.onColumnSorted && prevProps.onSelectAll === nextProps.onSelectAll && - prevProps.onUpdateColumns === nextProps.onUpdateColumns && prevProps.showEventsSelect === nextProps.showEventsSelect && prevProps.showSelectAllCheckbox === nextProps.showSelectAllCheckbox && prevProps.sort === nextProps.sort && prevProps.timelineId === nextProps.timelineId && - prevProps.toggleColumn === nextProps.toggleColumn && deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) && deepEqual(prevProps.browserFields, nextProps.browserFields) ); 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 28a4bf6d8ac51..f7efe758837ab 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 @@ -25,7 +25,6 @@ describe('Columns', () => { columnRenderers={columnRenderers} data={mockTimelineData[0].data} ecsData={mockTimelineData[0].ecs} - onColumnResized={jest.fn()} timelineId="test" /> ); 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 0d37f25d66e3f..32e2ae2141899 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 @@ -10,7 +10,6 @@ import { getOr } from 'lodash/fp'; import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { OnColumnResized } from '../../events'; import { EventsTd, EventsTdContent, EventsTdGroupData } from '../../styles'; import { ColumnRenderer } from '../renderers/column_renderer'; import { getColumnRenderer } from '../renderers/get_column_renderer'; @@ -21,7 +20,6 @@ interface Props { columnRenderers: ColumnRenderer[]; data: TimelineNonEcsData[]; ecsData: Ecs; - onColumnResized: OnColumnResized; timelineId: 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 ae552ade665cb..693ea0502517c 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 @@ -41,10 +41,8 @@ describe('EventColumnView', () => { }, eventIdToNoteIds: {}, expanded: false, - getNotesByIds: jest.fn(), loading: false, loadingEventIds: [], - onColumnResized: jest.fn(), onEventToggled: jest.fn(), onPinEvent: jest.fn(), onRowSelected: jest.fn(), 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 15d7d750257ac..d37d5ec7be7e9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -4,16 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { pick } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; -import uuid from 'uuid'; -import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; -import { Note } from '../../../../../common/lib/note'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { AssociateNote, UpdateNote } from '../../../notes/helpers'; -import { OnColumnResized, OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; +import { AssociateNote } from '../../../notes/helpers'; +import { OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; import { EventsTrData } from '../../styles'; import { Actions } from '../actions'; import { DataDrivenColumns } from '../data_driven_columns'; @@ -31,8 +30,6 @@ import { PinEventAction } from '../actions/pin_event_action'; import { inputsModel } from '../../../../../common/store'; import { TimelineId } from '../../../../../../common/types/timeline'; -import { TimelineModel } from '../../../../store/timeline/model'; - interface Props { id: string; actionsColumnWidth: number; @@ -43,11 +40,9 @@ interface Props { ecsData: Ecs; eventIdToNoteIds: Readonly>; expanded: boolean; - getNotesByIds: (noteIds: string[]) => Note[]; isEventPinned: boolean; isEventViewer?: boolean; loadingEventIds: Readonly; - onColumnResized: OnColumnResized; onEventToggled: () => void; onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; @@ -59,11 +54,8 @@ interface Props { showNotes: boolean; timelineId: string; toggleShowNotes: () => void; - updateNote: UpdateNote; } -export const getNewNoteId = (): string => uuid.v4(); - const emptyNotes: string[] = []; export const EventColumnView = React.memo( @@ -77,11 +69,9 @@ export const EventColumnView = React.memo( ecsData, eventIdToNoteIds, expanded, - getNotesByIds, isEventPinned = false, isEventViewer = false, loadingEventIds, - onColumnResized, onEventToggled, onPinEvent, onRowSelected, @@ -93,10 +83,9 @@ export const EventColumnView = React.memo( showNotes, timelineId, toggleShowNotes, - updateNote, }) => { - const { timelineType, status } = useShallowEqualSelector( - (state) => state.timeline.timelineById[timelineId] + const { timelineType, status } = useDeepEqualSelector((state) => + pick(['timelineType', 'status'], state.timeline.timelineById[timelineId]) ); const handlePinClicked = useCallback( @@ -134,11 +123,9 @@ export const EventColumnView = React.memo( , @@ -166,7 +153,6 @@ export const EventColumnView = React.memo( ecsData, eventIdToNoteIds, eventType, - getNotesByIds, handlePinClicked, id, isEventPinned, @@ -178,7 +164,6 @@ export const EventColumnView = React.memo( timelineId, timelineType, toggleShowNotes, - updateNote, ] ); @@ -203,7 +188,6 @@ export const EventColumnView = React.memo( columnRenderers={columnRenderers} data={data} ecsData={ecsData} - onColumnResized={onColumnResized} timelineId={timelineId} /> 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 19d657b0537a5..f6c178caa7fb8 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 @@ -13,9 +13,7 @@ import { TimelineNonEcsData, } from '../../../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { Note } from '../../../../../common/lib/note'; -import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; -import { OnColumnResized, OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; +import { OnRowSelected } from '../../events'; import { EventsTbody } from '../../styles'; import { ColumnRenderer } from '../renderers/column_renderer'; import { RowRenderer } from '../renderers/row_renderer'; @@ -24,80 +22,61 @@ import { eventIsPinned } from '../helpers'; interface Props { actionsColumnWidth: number; - addNoteToEvent: AddNoteToEvent; browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; data: TimelineItem[]; eventIdToNoteIds: Readonly>; - getNotesByIds: (noteIds: string[]) => Note[]; id: string; isEventViewer?: boolean; loadingEventIds: Readonly; - onColumnResized: OnColumnResized; - onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; - onUnPinEvent: OnUnPinEvent; pinnedEventIds: Readonly>; refetch: inputsModel.Refetch; onRuleChange?: () => void; rowRenderers: RowRenderer[]; selectedEventIds: Readonly>; showCheckboxes: boolean; - toggleColumn: (column: ColumnHeaderOptions) => void; - updateNote: UpdateNote; } const EventsComponent: React.FC = ({ actionsColumnWidth, - addNoteToEvent, browserFields, columnHeaders, columnRenderers, data, eventIdToNoteIds, - getNotesByIds, id, isEventViewer = false, loadingEventIds, - onColumnResized, - onPinEvent, onRowSelected, - onUnPinEvent, pinnedEventIds, refetch, onRuleChange, rowRenderers, selectedEventIds, showCheckboxes, - updateNote, }) => ( {data.map((event) => ( ))} 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 6c28c0ce16df1..3d3c87be42824 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 @@ -4,21 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useRef, useState, useCallback } from 'react'; -import uuid from 'uuid'; +import React, { useRef, useMemo, useState, useCallback } from 'react'; import { useDispatch } from 'react-redux'; +import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { TimelineId } from '../../../../../../common/types/timeline'; import { BrowserFields } from '../../../../../common/containers/source'; -import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { TimelineItem, TimelineNonEcsData, } from '../../../../../../common/search_strategy/timeline'; -import { Note } from '../../../../../common/lib/note'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; -import { OnColumnResized, OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; +import { OnPinEvent, OnRowSelected } from '../../events'; import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers'; import { EventsTrGroup, EventsTrSupplement, EventsTrSupplementContainer } from '../../styles'; import { ColumnRenderer } from '../renderers/column_renderer'; @@ -34,19 +31,14 @@ import { activeTimeline } from '../../../../containers/active_timeline_context'; interface Props { actionsColumnWidth: number; - addNoteToEvent: AddNoteToEvent; browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; event: TimelineItem; eventIdToNoteIds: Readonly>; - getNotesByIds: (noteIds: string[]) => Note[]; isEventViewer?: boolean; loadingEventIds: Readonly; - onColumnResized: OnColumnResized; - onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; - onUnPinEvent: OnUnPinEvent; isEventPinned: boolean; refetch: inputsModel.Refetch; onRuleChange?: () => void; @@ -54,11 +46,8 @@ interface Props { selectedEventIds: Readonly>; showCheckboxes: boolean; timelineId: string; - updateNote: UpdateNote; } -export const getNewNoteId = (): string => uuid.v4(); - const emptyNotes: string[] = []; const EventsTrSupplementContainerWrapper = React.memo(({ children }) => { @@ -70,32 +59,26 @@ EventsTrSupplementContainerWrapper.displayName = 'EventsTrSupplementContainerWra const StatefulEventComponent: React.FC = ({ actionsColumnWidth, - addNoteToEvent, browserFields, columnHeaders, columnRenderers, event, eventIdToNoteIds, - getNotesByIds, isEventViewer = false, isEventPinned = false, loadingEventIds, - onColumnResized, - onPinEvent, onRowSelected, - onUnPinEvent, refetch, onRuleChange, rowRenderers, selectedEventIds, showCheckboxes, timelineId, - updateNote, }) => { const dispatch = useDispatch(); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); - const { expandedEvent, status: timelineStatus } = useDeepEqualSelector( - (state) => state.timeline.timelineById[timelineId] + const expandedEvent = useDeepEqualSelector( + (state) => state.timeline.timelineById[timelineId].expandedEvent ); const divElement = useRef(null); @@ -109,6 +92,16 @@ const StatefulEventComponent: React.FC = ({ setShowNotes((prevShowNotes) => ({ ...prevShowNotes, [eventId]: !prevShowNotes[eventId] })); }, [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 handleOnEventToggled = useCallback(() => { const eventId = event._id; const indexName = event._index!; @@ -131,12 +124,22 @@ const StatefulEventComponent: React.FC = ({ const associateNote = useCallback( (noteId: string) => { - addNoteToEvent({ eventId: event._id, noteId }); + dispatch(timelineActions.addNoteToEvent({ eventId: event._id, id: timelineId, noteId })); if (!isEventPinned) { onPinEvent(event._id); // pin the event, because it has notes } }, - [addNoteToEvent, event, isEventPinned, onPinEvent] + [dispatch, event, isEventPinned, onPinEvent, timelineId] + ); + + const RowRendererContent = useMemo( + () => + getRowRenderer(event.ecs, rowRenderers).renderRow({ + browserFields, + data: event.ecs, + timelineId, + }), + [browserFields, event.ecs, rowRenderers, timelineId] ); return ( @@ -159,11 +162,9 @@ const StatefulEventComponent: React.FC = ({ ecsData={event.ecs} eventIdToNoteIds={eventIdToNoteIds} expanded={isExpanded} - getNotesByIds={getNotesByIds} isEventPinned={isEventPinned} isEventViewer={isEventViewer} loadingEventIds={loadingEventIds} - onColumnResized={onColumnResized} onEventToggled={handleOnEventToggled} onPinEvent={onPinEvent} onRowSelected={onRowSelected} @@ -175,7 +176,6 @@ const StatefulEventComponent: React.FC = ({ showNotes={!!showNotes[event._id]} timelineId={timelineId} toggleShowNotes={onToggleShowNotes} - updateNote={updateNote} /> @@ -186,21 +186,13 @@ const StatefulEventComponent: React.FC = ({ - {getRowRenderer(event.ecs, rowRenderers).renderRow({ - browserFields, - data: event.ecs, - timelineId, - })} + {RowRendererContent} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx index 3ea7b8d471a44..1d4cea700d003 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx @@ -10,16 +10,18 @@ import { useDispatch } from 'react-redux'; import { Ecs } from '../../../../../common/ecs'; import { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; -import { updateTimelineGraphEventId } from '../../../store/timeline/actions'; +import { setActiveTabTimeline, updateTimelineGraphEventId } from '../../../store/timeline/actions'; import { TimelineEventsType, TimelineTypeLiteral, TimelineType, + TimelineId, } from '../../../../../common/types/timeline'; import { OnPinEvent, OnUnPinEvent } from '../events'; import { ActionIconItem } from './actions/action_icon_item'; import * as i18n from './translations'; +import { TimelineTabs } from '../../../store/timeline/model'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const omitTypenameAndEmpty = (k: string, v: any): any | undefined => @@ -130,10 +132,12 @@ const InvestigateInResolverActionComponent: React.FC { const dispatch = useDispatch(); const isDisabled = useMemo(() => !isInvestigateInResolverActionEnabled(ecsData), [ecsData]); - const handleClick = useCallback( - () => dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: ecsData._id })), - [dispatch, ecsData._id, timelineId] - ); + const handleClick = useCallback(() => { + dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: ecsData._id })); + if (TimelineId.active) { + dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph })); + } + }, [dispatch, ecsData._id, timelineId]); return ( []; const mockSort: Sort = { columnId: '@timestamp', sortDirection: Direction.desc, }; +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + jest.mock('../../../../common/hooks/use_selector', () => ({ useShallowEqualSelector: jest.fn().mockReturnValue(mockTimelineModel), useDeepEqualSelector: jest.fn().mockReturnValue(mockTimelineModel), @@ -50,42 +59,29 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({ describe('Body', () => { const mount = useMountAppended(); - const props: BodyProps = { - addNoteToEvent: jest.fn(), + const props: StatefulBodyProps = { browserFields: mockBrowserFields, + clearSelected: (jest.fn() as unknown) as StatefulBodyProps['clearSelected'], columnHeaders: defaultHeaders, - columnRenderers, data: mockTimelineData, - docValueFields: [], eventIdToNoteIds: {}, + excludedRowRendererIds: [], + id: 'timeline-test', isSelectAllChecked: false, - getNotesByIds: mockGetNotesByIds, loadingEventIds: [], - onColumnRemoved: jest.fn(), - onColumnResized: jest.fn(), - onColumnSorted: jest.fn(), - onPinEvent: jest.fn(), - onRowSelected: jest.fn(), - onSelectAll: jest.fn(), - onUnPinEvent: jest.fn(), - onUpdateColumns: jest.fn(), pinnedEventIds: {}, refetch: jest.fn(), - rowRenderers, selectedEventIds: {}, - show: true, + setSelected: (jest.fn() as unknown) as StatefulBodyProps['setSelected'], sort: mockSort, showCheckboxes: false, - timelineId: 'timeline-test', - toggleColumn: jest.fn(), - updateNote: jest.fn(), }; describe('rendering', () => { test('it renders the column headers', () => { const wrapper = mount( - + ); @@ -95,7 +91,7 @@ describe('Body', () => { test('it renders the scroll container', () => { const wrapper = mount( - + ); @@ -105,7 +101,7 @@ describe('Body', () => { test('it renders events', () => { const wrapper = mount( - + ); @@ -117,7 +113,7 @@ describe('Body', () => { const testProps = { ...props, columnHeaders: headersJustTimestamp }; const wrapper = mount( - + ); wrapper.update(); @@ -138,7 +134,7 @@ describe('Body', () => { test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_BODY_CLASS_NAME}`, () => { const wrapper = mount( - + ); expect( @@ -148,40 +144,9 @@ describe('Body', () => { .exists() ).toEqual(true); }); - describe('when there is a graphEventId', () => { - beforeEach(() => { - props.graphEventId = 'graphEventId'; // any string w/ length > 0 works - }); - it('should not render the timeline body', () => { - const wrapper = mount( - - - - ); - - // The value returned if `wrapper.find` returns a `TimelineBody` instance. - type TimelineBodyEnzymeWrapper = ReactWrapper>; - - // The first TimelineBody component - const timelineBody: TimelineBodyEnzymeWrapper = wrapper - .find('[data-test-subj="timeline-body"]') - .first() as TimelineBodyEnzymeWrapper; - - // the timeline body still renders, but it gets a `display: none` style via `styled-components`. - expect(timelineBody.props().visible).toBe(false); - }); - }); }); describe('action on event', () => { - const dispatchAddNoteToEvent = jest.fn(); - const dispatchOnPinEvent = jest.fn(); - const testProps = { - ...props, - addNoteToEvent: dispatchAddNoteToEvent, - onPinEvent: dispatchOnPinEvent, - }; - const addaNoteToEvent = (wrapper: ReturnType, note: string) => { wrapper.find('[data-test-subj="add-note"]').first().find('button').simulate('click'); wrapper.update(); @@ -194,38 +159,75 @@ describe('Body', () => { }; beforeEach(() => { - dispatchAddNoteToEvent.mockClear(); - dispatchOnPinEvent.mockClear(); + mockDispatch.mockClear(); }); test('Add a Note to an event', () => { const wrapper = mount( - + ); addaNoteToEvent(wrapper, 'hello world'); - expect(dispatchAddNoteToEvent).toHaveBeenCalled(); - expect(dispatchOnPinEvent).toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + payload: { + eventId: '1', + id: 'timeline-test', + noteId: expect.anything(), + }, + type: timelineActions.addNoteToEvent({ + eventId: '1', + id: 'timeline-test', + noteId: '11', + }).type, + }) + ); + expect(mockDispatch).toHaveBeenNthCalledWith( + 3, + timelineActions.pinEvent({ + eventId: '1', + id: 'timeline-test', + }) + ); }); test('Add two Note to an event', () => { - const Proxy = (proxyProps: BodyProps) => ( + const Proxy = (proxyProps: StatefulBodyProps) => ( - + ); - const wrapper = mount(); + const wrapper = mount(); addaNoteToEvent(wrapper, 'hello world'); - dispatchAddNoteToEvent.mockClear(); - dispatchOnPinEvent.mockClear(); + mockDispatch.mockClear(); wrapper.setProps({ pinnedEventIds: { 1: true } }); wrapper.update(); addaNoteToEvent(wrapper, 'new hello world'); - expect(dispatchAddNoteToEvent).toHaveBeenCalled(); - expect(dispatchOnPinEvent).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + payload: { + eventId: '1', + id: 'timeline-test', + noteId: expect.anything(), + }, + type: timelineActions.addNoteToEvent({ + eventId: '1', + id: 'timeline-test', + noteId: '11', + }).type, + }) + ); + expect(mockDispatch).not.toHaveBeenCalledWith( + timelineActions.pinEvent({ + eventId: '1', + id: 'timeline-test', + }) + ); }); }); }); 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 05a66c6853f6c..a7e25a20e5e47 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 @@ -4,67 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; - -import { inputsModel } from '../../../../common/store'; -import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; -import { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; -import { Note } from '../../../../common/lib/note'; -import { ColumnHeaderOptions } from '../../../store/timeline/model'; -import { AddNoteToEvent, UpdateNote } from '../../notes/helpers'; -import { - OnColumnRemoved, - OnColumnResized, - OnColumnSorted, - OnPinEvent, - OnRowSelected, - OnSelectAll, - OnUnPinEvent, - OnUpdateColumns, -} from '../events'; +import memoizeOne from 'memoize-one'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; + +import { RowRendererId, TimelineId } 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 { timelineDefaults } from '../../../store/timeline/defaults'; +import { timelineActions, timelineSelectors } from '../../../store/timeline'; +import { OnRowSelected, OnSelectAll } from '../events'; +import { getActionsColumnWidth, getColumnHeaders } from './column_headers/helpers'; +import { getEventIdToDataMapping } from './helpers'; +import { columnRenderers, rowRenderers } from './renderers'; +import { Sort } from './sort'; +import { plainRowRenderer } from './renderers/plain_row_renderer'; import { EventsTable, TimelineBody, TimelineBodyGlobalStyle } from '../styles'; import { ColumnHeaders } from './column_headers'; -import { getActionsColumnWidth } from './column_headers/helpers'; import { Events } from './events'; -import { ColumnRenderer } from './renderers/column_renderer'; -import { RowRenderer } from './renderers/row_renderer'; -import { Sort } from './sort'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; -import { TimelineEventsType, TimelineId } from '../../../../../common/types/timeline'; -export interface BodyProps { - addNoteToEvent: AddNoteToEvent; +interface OwnProps { browserFields: BrowserFields; - columnHeaders: ColumnHeaderOptions[]; - columnRenderers: ColumnRenderer[]; data: TimelineItem[]; - docValueFields: DocValueFields[]; - getNotesByIds: (noteIds: string[]) => Note[]; - graphEventId?: string; + id: string; isEventViewer?: boolean; - isSelectAllChecked: boolean; - eventIdToNoteIds: Readonly>; - eventType?: TimelineEventsType; - loadingEventIds: Readonly; - onColumnRemoved: OnColumnRemoved; - onColumnResized: OnColumnResized; - onColumnSorted: OnColumnSorted; - onRowSelected: OnRowSelected; - onSelectAll: OnSelectAll; - onPinEvent: OnPinEvent; - onUpdateColumns: OnUpdateColumns; - onUnPinEvent: OnUnPinEvent; - pinnedEventIds: Readonly>; + sort: Sort; refetch: inputsModel.Refetch; onRuleChange?: () => void; - rowRenderers: RowRenderer[]; - selectedEventIds: Readonly>; - show: boolean; - showCheckboxes: boolean; - sort: Sort; - timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; - updateNote: UpdateNote; } export const hasAdditionalActions = (id: TimelineId): boolean => @@ -74,50 +45,91 @@ export const hasAdditionalActions = (id: TimelineId): boolean => const EXTRA_WIDTH = 4; // px -/** Renders the timeline body */ -export const Body = React.memo( +export type StatefulBodyProps = OwnProps & PropsFromRedux; + +export const BodyComponent = React.memo( ({ - addNoteToEvent, browserFields, columnHeaders, - columnRenderers, data, eventIdToNoteIds, - getNotesByIds, - graphEventId, + excludedRowRendererIds, + id, isEventViewer = false, isSelectAllChecked, loadingEventIds, - onColumnRemoved, - onColumnResized, - onColumnSorted, - onRowSelected, - onSelectAll, - onPinEvent, - onUpdateColumns, - onUnPinEvent, pinnedEventIds, - rowRenderers, - refetch, - onRuleChange, selectedEventIds, - show, + setSelected, + clearSelected, + onRuleChange, showCheckboxes, + refetch, sort, - toggleColumn, - timelineId, - updateNote, }) => { + const { getManageTimelineById } = useManageTimeline(); + const { queryFields, selectAll } = useMemo(() => getManageTimelineById(id), [ + getManageTimelineById, + 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]); + const actionsColumnWidth = useMemo( () => getActionsColumnWidth( isEventViewer, showCheckboxes, - hasAdditionalActions(timelineId as TimelineId) - ? DEFAULT_ICON_BUTTON_WIDTH + EXTRA_WIDTH - : 0 + hasAdditionalActions(id as TimelineId) ? DEFAULT_ICON_BUTTON_WIDTH + EXTRA_WIDTH : 0 ), - [isEventViewer, showCheckboxes, timelineId] + [isEventViewer, showCheckboxes, id] ); const columnWidths = useMemo( @@ -128,11 +140,7 @@ export const Body = React.memo( return ( <> - + ( columnHeaders={columnHeaders} isEventViewer={isEventViewer} isSelectAllChecked={isSelectAllChecked} - onColumnRemoved={onColumnRemoved} - onColumnResized={onColumnResized} - onColumnSorted={onColumnSorted} onSelectAll={onSelectAll} - onUpdateColumns={onUpdateColumns} showEventsSelect={false} showSelectAllCheckbox={showCheckboxes} sort={sort} - timelineId={timelineId} - toggleColumn={toggleColumn} + timelineId={id} /> ); - } + }, + (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.eventIdToNoteIds, nextProps.eventIdToNoteIds) && + deepEqual(prevProps.pinnedEventIds, nextProps.pinnedEventIds) && + deepEqual(prevProps.selectedEventIds, nextProps.selectedEventIds) && + deepEqual(prevProps.loadingEventIds, nextProps.loadingEventIds) && + prevProps.id === nextProps.id && + prevProps.isEventViewer === nextProps.isEventViewer && + prevProps.isSelectAllChecked === nextProps.isSelectAllChecked && + prevProps.showCheckboxes === nextProps.showCheckboxes ); -Body.displayName = 'Body'; +BodyComponent.displayName = 'BodyComponent'; + +const makeMapStateToProps = () => { + const memoizedColumnHeaders: ( + headers: ColumnHeaderOptions[], + browserFields: BrowserFields + ) => ColumnHeaderOptions[] = memoizeOne(getColumnHeaders); + + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + const mapStateToProps = (state: State, { browserFields, id }: OwnProps) => { + const timeline: TimelineModel = getTimeline(state, id) ?? timelineDefaults; + const { + columns, + eventIdToNoteIds, + excludedRowRendererIds, + isSelectAllChecked, + loadingEventIds, + pinnedEventIds, + selectedEventIds, + showCheckboxes, + } = timeline; + + return { + columnHeaders: memoizedColumnHeaders(columns, browserFields), + eventIdToNoteIds, + excludedRowRendererIds, + isSelectAllChecked, + loadingEventIds, + id, + pinnedEventIds, + selectedEventIds, + showCheckboxes, + }; + }; + return mapStateToProps; +}; + +const mapDispatchToProps = { + clearSelected: timelineActions.clearSelected, + setSelected: timelineActions.setSelected, +}; + +const connector = connect(makeMapStateToProps, mapDispatchToProps); + +type PropsFromRedux = ConnectedProps; + +export const StatefulBody = connector(BodyComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.test.tsx deleted file mode 100644 index 3e03e9f37c0bc..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mockBrowserFields } from '../../../../common/containers/source/mock'; - -import { defaultHeaders } from './column_headers/default_headers'; -import { getColumnHeaders } from './column_headers/helpers'; - -describe('stateful_body', () => { - 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', - width: 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', - width: 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', - width: 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/security_solution/public/timelines/components/timeline/body/stateful_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx deleted file mode 100644 index 120b3ce165909..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx +++ /dev/null @@ -1,308 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import memoizeOne from 'memoize-one'; -import React, { useCallback, useEffect, useMemo } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; -import deepEqual from 'fast-deep-equal'; - -import { RowRendererId, TimelineId } from '../../../../../common/types/timeline'; -import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; -import { TimelineItem } from '../../../../../common/search_strategy/timeline'; -import { Note } from '../../../../common/lib/note'; -import { appSelectors, inputsModel, State } from '../../../../common/store'; -import { appActions } from '../../../../common/store/actions'; -import { useManageTimeline } from '../../manage_timeline'; -import { ColumnHeaderOptions, TimelineModel } from '../../../store/timeline/model'; -import { timelineDefaults } from '../../../store/timeline/defaults'; -import { timelineActions, timelineSelectors } from '../../../store/timeline'; -import { AddNoteToEvent, UpdateNote } from '../../notes/helpers'; -import { - OnColumnRemoved, - OnColumnResized, - OnColumnSorted, - OnPinEvent, - OnRowSelected, - OnSelectAll, - OnUnPinEvent, - OnUpdateColumns, -} from '../events'; -import { getColumnHeaders } from './column_headers/helpers'; -import { getEventIdToDataMapping } from './helpers'; -import { Body } from './index'; -import { columnRenderers, rowRenderers } from './renderers'; -import { Sort } from './sort'; -import { plainRowRenderer } from './renderers/plain_row_renderer'; - -interface OwnProps { - browserFields: BrowserFields; - data: TimelineItem[]; - docValueFields: DocValueFields[]; - id: string; - isEventViewer?: boolean; - sort: Sort; - toggleColumn: (column: ColumnHeaderOptions) => void; - refetch: inputsModel.Refetch; - onRuleChange?: () => void; -} - -type StatefulBodyComponentProps = OwnProps & PropsFromRedux; - -export const emptyColumnHeaders: ColumnHeaderOptions[] = []; - -const StatefulBodyComponent = React.memo( - ({ - addNoteToEvent, - applyDeltaToColumnWidth, - browserFields, - columnHeaders, - data, - docValueFields, - eventIdToNoteIds, - excludedRowRendererIds, - id, - isEventViewer = false, - isSelectAllChecked, - loadingEventIds, - notesById, - pinEvent, - pinnedEventIds, - removeColumn, - selectedEventIds, - setSelected, - clearSelected, - onRuleChange, - show, - showCheckboxes, - graphEventId, - refetch, - sort, - toggleColumn, - unPinEvent, - updateColumns, - updateNote, - updateSort, - }) => { - const { getManageTimelineById } = useManageTimeline(); - const { queryFields, selectAll } = useMemo(() => getManageTimelineById(id), [ - getManageTimelineById, - id, - ]); - - const getNotesByIds = useCallback( - (noteIds: string[]): Note[] => appSelectors.getNotes(notesById, noteIds), - [notesById] - ); - - const onAddNoteToEvent: AddNoteToEvent = useCallback( - ({ eventId, noteId }: { eventId: string; noteId: string }) => - addNoteToEvent!({ id, eventId, noteId }), - [id, addNoteToEvent] - ); - - 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] - ); - - const onColumnSorted: OnColumnSorted = useCallback( - (sorted) => { - updateSort!({ id, sort: sorted }); - }, - [id, updateSort] - ); - - const onColumnRemoved: OnColumnRemoved = useCallback( - (columnId) => removeColumn!({ id, columnId }), - [id, removeColumn] - ); - - const onColumnResized: OnColumnResized = useCallback( - ({ columnId, delta }) => applyDeltaToColumnWidth!({ id, columnId, delta }), - [applyDeltaToColumnWidth, id] - ); - - const onPinEvent: OnPinEvent = useCallback((eventId) => pinEvent!({ id, eventId }), [ - id, - pinEvent, - ]); - - const onUnPinEvent: OnUnPinEvent = useCallback((eventId) => unPinEvent!({ id, eventId }), [ - id, - unPinEvent, - ]); - - const onUpdateNote: UpdateNote = useCallback((note: Note) => updateNote!({ note }), [ - updateNote, - ]); - - const onUpdateColumns: OnUpdateColumns = useCallback( - (columns) => updateColumns!({ id, columns }), - [id, updateColumns] - ); - - // 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]); - - 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.docValueFields, nextProps.docValueFields) && - prevProps.eventIdToNoteIds === nextProps.eventIdToNoteIds && - prevProps.graphEventId === nextProps.graphEventId && - deepEqual(prevProps.notesById, nextProps.notesById) && - prevProps.id === nextProps.id && - prevProps.isEventViewer === nextProps.isEventViewer && - prevProps.isSelectAllChecked === nextProps.isSelectAllChecked && - prevProps.loadingEventIds === nextProps.loadingEventIds && - prevProps.pinnedEventIds === nextProps.pinnedEventIds && - prevProps.show === nextProps.show && - prevProps.selectedEventIds === nextProps.selectedEventIds && - prevProps.showCheckboxes === nextProps.showCheckboxes && - prevProps.sort === nextProps.sort -); - -StatefulBodyComponent.displayName = 'StatefulBodyComponent'; - -const makeMapStateToProps = () => { - const memoizedColumnHeaders: ( - headers: ColumnHeaderOptions[], - browserFields: BrowserFields - ) => ColumnHeaderOptions[] = memoizeOne(getColumnHeaders); - - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const getNotesByIds = appSelectors.notesByIdsSelector(); - const mapStateToProps = (state: State, { browserFields, id }: OwnProps) => { - const timeline: TimelineModel = getTimeline(state, id) ?? timelineDefaults; - const { - columns, - eventIdToNoteIds, - excludedRowRendererIds, - graphEventId, - isSelectAllChecked, - loadingEventIds, - pinnedEventIds, - selectedEventIds, - show, - showCheckboxes, - } = timeline; - - return { - columnHeaders: memoizedColumnHeaders(columns, browserFields), - eventIdToNoteIds, - excludedRowRendererIds, - graphEventId, - isSelectAllChecked, - loadingEventIds, - notesById: getNotesByIds(state), - id, - pinnedEventIds, - selectedEventIds, - show, - showCheckboxes, - }; - }; - return mapStateToProps; -}; - -const mapDispatchToProps = { - addNoteToEvent: timelineActions.addNoteToEvent, - applyDeltaToColumnWidth: timelineActions.applyDeltaToColumnWidth, - clearSelected: timelineActions.clearSelected, - pinEvent: timelineActions.pinEvent, - removeColumn: timelineActions.removeColumn, - removeProvider: timelineActions.removeProvider, - setSelected: timelineActions.setSelected, - unPinEvent: timelineActions.unPinEvent, - updateColumns: timelineActions.updateColumns, - updateNote: appActions.updateNote, - updateSort: timelineActions.updateSort, -}; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const StatefulBody = connector(StatefulBodyComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/data_providers.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/data_providers.test.tsx.snap deleted file mode 100644 index a8818517fb94b..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/data_providers.test.tsx.snap +++ /dev/null @@ -1,151 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DataProviders rendering renders correctly against snapshot 1`] = ` - - - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx index ff3df357f7337..39a07e2c35504 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { pick } from 'lodash/fp'; import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, @@ -19,7 +20,7 @@ import { useDispatch } from 'react-redux'; import { BrowserFields } from '../../../../common/containers/source'; import { TimelineType } from '../../../../../common/types/timeline'; -import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { StatefulEditDataProvider } from '../../edit_data_provider'; import { addContentToTimeline } from './helpers'; import { DataProviderType } from './data_provider'; @@ -37,8 +38,10 @@ const AddDataProviderPopoverComponent: React.FC = ( }) => { const dispatch = useDispatch(); const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); - const timelineById = useShallowEqualSelector(timelineSelectors.timelineByIdSelector); - const { dataProviders, timelineType } = timelineById[timelineId] ?? {}; + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { dataProviders, timelineType } = useDeepEqualSelector((state) => + pick(['dataProviders', 'timelineType'], getTimeline(state, timelineId)) + ); const handleOpenPopover = useCallback(() => setIsAddFilterPopoverOpen(true), [ setIsAddFilterPopoverOpen, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.test.tsx similarity index 69% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx rename to x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.test.tsx index a7ae14dea510f..4d6487feb98d2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.test.tsx @@ -4,21 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../../../common/mock/test_providers'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { DataProviders } from '.'; -import { DataProvider } from './data_provider'; -import { mockDataProviders } from './mock/mock_data_providers'; 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'); + return { + ...actual, + useDeepEqualSelector: jest.fn().mockReturnValue([]), + }; +}); + const filterManager = new FilterManager(mockUiSettingsForFilterManager); describe('DataProviders', () => { const mount = useMountAppended(); @@ -33,27 +38,21 @@ describe('DataProviders', () => { filterManager, }, }; - const wrapper = shallow( + const wrapper = mount( - + ); - expect(wrapper.find(`[data-test-subj="dataProviders-container"]`).dive()).toMatchSnapshot(); + expect(wrapper.find(`[data-test-subj="dataProviders-container"]`)).toBeTruthy(); + expect(wrapper.find(`[date-test-subj="drop-target-data-providers"]`)).toBeTruthy(); }); test('it should render a placeholder when there are zero data providers', () => { - const dataProviders: DataProvider[] = []; - const wrapper = mount( - + ); @@ -63,14 +62,12 @@ describe('DataProviders', () => { test('it renders the data providers', () => { const wrapper = mount( - + ); - mockDataProviders.forEach((dataProvider) => - expect(wrapper.text()).toContain( - dataProvider.queryMatch.displayValue || dataProvider.queryMatch.value - ) + expect(wrapper.find('[data-test-subj="empty"]').last().text()).toEqual( + 'Drop anythinghighlightedhere to build anORquery+ Add field' ); }); }); 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 b892ca089eb4c..0a7b0e7ef4c29 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 @@ -7,23 +7,25 @@ import { rgba } from 'polished'; import React, { useMemo } from 'react'; import styled from 'styled-components'; +import uuid from 'uuid'; -import { BrowserFields } from '../../../../common/containers/source'; +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 { droppableTimelineProvidersPrefix, IS_DRAGGING_CLASS_NAME, } from '../../../../common/components/drag_and_drop/helpers'; -import { DataProvider } from './data_provider'; import { Empty } from './empty'; import { Providers } from './providers'; import { useManageTimeline } from '../../manage_timeline'; +import { timelineSelectors } from '../../../store/timeline'; +import { timelineDefaults } from '../../../store/timeline/defaults'; interface Props { - browserFields: BrowserFields; timelineId: string; - dataProviders: DataProvider[]; } const DropTargetDataProvidersContainer = styled.div` @@ -49,18 +51,19 @@ const DropTargetDataProviders = styled.div` justify-content: center; padding-bottom: 2px; position: relative; - border: 0.2rem dashed ${(props) => props.theme.eui.euiColorMediumShade}; + border: 0.2rem dashed ${({ theme }) => theme.eui.euiColorMediumShade}; border-radius: 5px; padding: 5px 0; margin: 2px 0 2px 0; min-height: 100px; overflow-y: auto; - background-color: ${(props) => props.theme.eui.euiFormBackgroundColor}; + background-color: ${({ theme }) => theme.eui.euiFormBackgroundColor}; `; DropTargetDataProviders.displayName = 'DropTargetDataProviders'; -const getDroppableId = (id: string): string => `${droppableTimelineProvidersPrefix}${id}`; +const getDroppableId = (id: string): string => + `${droppableTimelineProvidersPrefix}${id}${uuid.v4()}`; /** * Renders the data providers section of the timeline. @@ -79,12 +82,19 @@ const getDroppableId = (id: string): string => `${droppableTimelineProvidersPref * the user to drop anything with a facet count into * the data pro section. */ -export const DataProviders = React.memo(({ browserFields, dataProviders, timelineId }) => { +export const DataProviders = React.memo(({ timelineId }) => { + const { browserFields } = useSourcererScope(SourcererScopeName.timeline); const { getManageTimelineById } = useManageTimeline(); const isLoading = useMemo(() => getManageTimelineById(timelineId).isLoading, [ getManageTimelineById, timelineId, ]); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const dataProviders = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).dataProviders + ); + const droppableId = useMemo(() => getDroppableId(timelineId), [timelineId]); + return ( (({ browserFields, dataProviders, dataProviders={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 fc06d37b9663f..8f7138ff2f721 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 @@ -60,8 +60,14 @@ export const ProviderItemBadge = React.memo( val, type = DataProviderType.default, }) => { - const timelineById = useShallowEqualSelector(timelineSelectors.timelineByIdSelector); - const timelineType = timelineId ? timelineById[timelineId]?.timelineType : TimelineType.default; + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timelineType = useShallowEqualSelector((state) => { + if (!timelineId) { + return TimelineType.default; + } + + return getTimeline(state, timelineId)?.timelineType ?? TimelineType.default; + }); const { getManageTimelineById } = useManageTimeline(); const isLoading = useMemo(() => getManageTimelineById(timelineId ?? '').isLoading, [ getManageTimelineById, 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 4b6f3c6701794..1f0b606c49da9 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 @@ -10,6 +10,7 @@ import React, { useCallback, useMemo } from 'react'; import { Draggable, DraggingStyle, Droppable, NotDraggingStyle } from 'react-beautiful-dnd'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; import { timelineActions } from '../../../store/timeline'; @@ -298,7 +299,14 @@ export const DataProvidersGroupItem = React.memo( {DraggableContent} ); - } + }, + (prevProps, nextProps) => + prevProps.groupIndex === nextProps.groupIndex && + prevProps.index === nextProps.index && + prevProps.timelineId === nextProps.timelineId && + deepEqual(prevProps.browserFields, nextProps.browserFields) && + deepEqual(prevProps.group, nextProps.group) && + deepEqual(prevProps.dataProvider, nextProps.dataProvider) ); DataProvidersGroupItem.displayName = 'DataProvidersGroupItem'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/index.tsx new file mode 100644 index 0000000000000..87a870a5f933e --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiToolTip, EuiSwitch } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; + +import { inputsActions, inputsSelectors } from '../../../../common/store/inputs'; +import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import * as i18n from './translations'; + +const TimelineDatePickerLockComponent = () => { + const dispatch = useDispatch(); + const getGlobalInput = useMemo(() => inputsSelectors.globalSelector(), []); + const isDatePickerLocked = useShallowEqualSelector((state) => + getGlobalInput(state).linkTo.includes('timeline') + ); + + const onToggleLock = useCallback( + () => dispatch(inputsActions.toggleTimelineLinkTo({ linkToId: 'timeline' })), + [dispatch] + ); + + return ( + + + + ); +}; + +TimelineDatePickerLockComponent.displayName = 'TimelineDatePickerLockComponent'; + +export const TimelineDatePickerLock = React.memo(TimelineDatePickerLockComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts new file mode 100644 index 0000000000000..58729f69402e1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const LOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( + 'xpack.securitySolution.timeline.properties.lockDatePickerTooltip', + { + defaultMessage: + 'Disable syncing of date/time range between the currently viewed page and your timeline', + } +); + +export const UNLOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( + 'xpack.securitySolution.timeline.properties.unlockDatePickerTooltip', + { + defaultMessage: + 'Enable syncing of date/time range between the currently viewed page and your timeline', + } +); + +export const LOCK_SYNC_MAIN_DATE_PICKER_LABEL = i18n.translate( + 'xpack.securitySolution.timeline.properties.lockedDatePickerLabel', + { + defaultMessage: 'Date picker is locked to global date picker', + } +); + +export const UNLOCK_SYNC_MAIN_DATE_PICKER_LABEL = i18n.translate( + 'xpack.securitySolution.timeline.properties.unlockedDatePickerLabel', + { + defaultMessage: 'Date picker is NOT locked to global date picker', + } +); + +export const LOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( + 'xpack.securitySolution.timeline.properties.lockDatePickerDescription', + { + defaultMessage: 'Lock date picker to global date picker', + } +); + +export const UNLOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( + 'xpack.securitySolution.timeline.properties.unlockDatePickerDescription', + { + defaultMessage: 'Unlock date picker to global date picker', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx index 4b595fad9be6f..ed9b20f7a5e2d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx @@ -14,7 +14,6 @@ import { EuiSpacer } from '@elastic/eui'; import React from 'react'; import deepEqual from 'fast-deep-equal'; -import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { BrowserFields, DocValueFields } from '../../../common/containers/source'; import { ExpandableEvent, @@ -26,29 +25,26 @@ interface EventDetailsProps { browserFields: BrowserFields; docValueFields: DocValueFields[]; timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; } const EventDetailsComponent: React.FC = ({ browserFields, docValueFields, timelineId, - toggleColumn, }) => { const expandedEvent = useDeepEqualSelector( - (state) => state.timeline.timelineById[timelineId]?.expandedEvent ?? {} + (state) => state.timeline.timelineById[timelineId]?.expandedEvent ); return ( <> - + ); @@ -59,6 +55,5 @@ export const EventDetails = React.memo( (prevProps, nextProps) => deepEqual(prevProps.browserFields, nextProps.browserFields) && deepEqual(prevProps.docValueFields, nextProps.docValueFields) && - prevProps.timelineId === nextProps.timelineId && - prevProps.toggleColumn === nextProps.toggleColumn + prevProps.timelineId === nextProps.timelineId ); 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 8ab3a71604bf1..54755fbc84277 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 @@ -42,9 +42,6 @@ export type OnColumnRemoved = (columnId: ColumnId) => void; export type OnColumnResized = ({ columnId, delta }: { columnId: ColumnId; delta: number }) => void; -/** Invoked when a user clicks to change the number items to show per page */ -export type OnChangeItemsPerPage = (itemsPerPage: number) => void; - /** Invoked when a user clicks to load more item */ export type OnChangePage = (nextPage: number) => void; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx index 77aee2c4bf012..77a37d8b9a929 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx @@ -4,37 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiTextColor, EuiLoadingContent, EuiTitle } from '@elastic/eui'; -import React, { useCallback } from 'react'; -import styled from 'styled-components'; -import { useDispatch } from 'react-redux'; +import { find } from 'lodash/fp'; +import { EuiTextColor, EuiLoadingContent, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; import { TimelineExpandedEvent } from '../../../../../common/types/timeline'; -import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; -import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; -import { StatefulEventDetails } from '../../../../common/components/event_details/stateful_event_details'; -import { LazyAccordion } from '../../lazy_accordion'; +import { + EventDetails, + EventsViewType, + View, +} from '../../../../common/components/event_details/event_details'; +import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; import { useTimelineEventsDetails } from '../../../containers/details'; -import { timelineActions, timelineSelectors } from '../../../store/timeline'; -import { getColumnHeaders } from '../body/column_headers/helpers'; -import { timelineDefaults } from '../../../store/timeline/defaults'; import * as i18n from './translations'; -const ExpandableDetails = styled.div` - .euiAccordion__button { - display: none; - } -`; - -ExpandableDetails.displayName = 'ExpandableDetails'; - interface Props { browserFields: BrowserFields; docValueFields: DocValueFields[]; event: TimelineExpandedEvent; timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; } export const ExpandableEventTitle = React.memo(() => ( @@ -46,15 +35,8 @@ export const ExpandableEventTitle = React.memo(() => ( ExpandableEventTitle.displayName = 'ExpandableEventTitle'; export const ExpandableEvent = React.memo( - ({ browserFields, docValueFields, event, timelineId, toggleColumn }) => { - const dispatch = useDispatch(); - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - - const columnHeaders = useDeepEqualSelector((state) => { - const { columns } = getTimeline(state, timelineId) ?? timelineDefaults; - - return getColumnHeaders(columns, browserFields); - }); + ({ browserFields, docValueFields, event, timelineId }) => { + const [view, setView] = useState(EventsViewType.tableView); const [loading, detailsData] = useTimelineEventsDetails({ docValueFields, @@ -63,33 +45,18 @@ export const ExpandableEvent = React.memo( skip: !event.eventId, }); - const onUpdateColumns = useCallback( - (columns) => dispatch(timelineActions.updateColumns({ id: timelineId, columns })), - [dispatch, timelineId] - ); + const message = useMemo(() => { + if (detailsData) { + const messageField = find({ category: 'base', field: 'message' }, detailsData) as + | TimelineEventsDetailsItem + | undefined; - const handleRenderExpandedContent = useCallback( - () => ( - - ), - [ - browserFields, - columnHeaders, - detailsData, - event.eventId, - onUpdateColumns, - timelineId, - toggleColumn, - ] - ); + if (messageField?.originalValue) { + return messageField?.originalValue; + } + } + return null; + }, [detailsData]); if (!event.eventId) { return {i18n.EVENT_DETAILS_PLACEHOLDER}; @@ -100,14 +67,18 @@ export const ExpandableEvent = React.memo( } return ( - - + {message} + + - + ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx index a4c4679c82058..4acdab1b7c140 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx @@ -23,7 +23,7 @@ export const EVENT = i18n.translate( export const EVENT_DETAILS_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.timeline.expandableEvent.placeholder', { - defaultMessage: 'Select an event to show its details', + defaultMessage: 'Select an event to show event details', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/fetch_kql_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/fetch_kql_timeline.tsx deleted file mode 100644 index cec889fe6ee34..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/fetch_kql_timeline.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { memo, useEffect } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; -import deepEqual from 'fast-deep-equal'; -import { IIndexPattern } from 'src/plugins/data/public'; - -import { State } from '../../../common/store'; -import { inputsActions } from '../../../common/store/actions'; -import { InputsModelId } from '../../../common/store/inputs/constants'; -import { useUpdateKql } from '../../../common/utils/kql/use_update_kql'; -import { timelineSelectors } from '../../store/timeline'; -export interface TimelineKqlFetchProps { - id: string; - indexPattern: IIndexPattern; - inputId: InputsModelId; -} - -type OwnProps = TimelineKqlFetchProps & PropsFromRedux; - -const TimelineKqlFetchComponent = memo( - ({ id, indexPattern, inputId, kueryFilterQuery, kueryFilterQueryDraft, setTimelineQuery }) => { - useEffect(() => { - setTimelineQuery({ - id: 'kql', - inputId, - inspect: null, - loading: false, - /* eslint-disable-next-line react-hooks/rules-of-hooks */ - refetch: useUpdateKql({ - indexPattern, - kueryFilterQuery, - kueryFilterQueryDraft, - storeType: 'timelineType', - timelineId: id, - }), - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kueryFilterQueryDraft, kueryFilterQuery, id]); - return null; - }, - (prevProps, nextProps) => - prevProps.id === nextProps.id && - prevProps.inputId === nextProps.inputId && - prevProps.setTimelineQuery === nextProps.setTimelineQuery && - deepEqual(prevProps.kueryFilterQuery, nextProps.kueryFilterQuery) && - deepEqual(prevProps.kueryFilterQueryDraft, nextProps.kueryFilterQueryDraft) && - deepEqual(prevProps.indexPattern, nextProps.indexPattern) -); - -const makeMapStateToProps = () => { - const getTimelineKueryFilterQueryDraft = timelineSelectors.getKqlFilterQueryDraftSelector(); - const getTimelineKueryFilterQuery = timelineSelectors.getKqlFilterKuerySelector(); - const mapStateToProps = (state: State, { id }: TimelineKqlFetchProps) => { - return { - kueryFilterQuery: getTimelineKueryFilterQuery(state, id), - kueryFilterQueryDraft: getTimelineKueryFilterQueryDraft(state, id), - }; - }; - return mapStateToProps; -}; - -const mapDispatchToProps = { - setTimelineQuery: inputsActions.setQuery, -}; - -export const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const TimelineKqlFetch = connector(TimelineKqlFetchComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 35e7de2981973..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,94 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Footer Timeline Component rendering it renders the default timeline footer 1`] = ` - - - - - - 1 rows - , - - 5 rows - , - - 10 rows - , - - 20 rows - , - ] - } - itemsCount={2} - onClick={[Function]} - serverSideEventCount={15546} - /> - - - - - - - - - - -`; 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 8c4858af9d61f..6cfdeb9e0ced3 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 @@ -13,49 +13,50 @@ import { FooterComponent, PagingControlComponent } from './index'; describe('Footer Timeline Component', () => { const loadMore = jest.fn(); - const onChangeItemsPerPage = jest.fn(); const updatedAt = 1546878704036; const serverSideEventCount = 15546; const itemsCount = 2; describe('rendering', () => { test('it renders the default timeline footer', () => { - const wrapper = shallow( - + const wrapper = mount( + + + ); - expect(wrapper).toMatchSnapshot(); + 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(); @@ -74,7 +75,6 @@ describe('Footer Timeline Component', () => { itemsCount={itemsCount} itemsPerPage={2} itemsPerPageOptions={[1, 5, 10, 20]} - onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} totalCount={serverSideEventCount} /> @@ -115,27 +115,6 @@ describe('Footer Timeline Component', () => { }); 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( { height={100} id={'timeline-id'} isLive={false} - isLoading={false} + isLoading={true} itemsCount={itemsCount} - itemsPerPage={1} + itemsPerPage={2} itemsPerPageOptions={[1, 5, 10, 20]} - onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} totalCount={serverSideEventCount} /> ); - wrapper.find('[data-test-subj="timelineSizeRowPopover"] button').first().simulate('click'); - expect(wrapper.find('[data-test-subj="timelinePickSizeRow"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="timeline-pagination"]').exists()).toBeFalsy(); }); - }); - describe('Events', () => { - test('should call loadmore when clicking on the button load more', () => { + test('it render popover to select new itemsPerPage in timeline', () => { const wrapper = mount( { isLive={false} isLoading={false} itemsCount={itemsCount} - itemsPerPage={2} + itemsPerPage={1} itemsPerPageOptions={[1, 5, 10, 20]} - onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} totalCount={serverSideEventCount} /> ); - wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click'); - expect(loadMore).toBeCalled(); + wrapper.find('[data-test-subj="timelineSizeRowPopover"] button').first().simulate('click'); + expect(wrapper.find('[data-test-subj="timelinePickSizeRow"]').exists()).toBeTruthy(); }); + }); - test('Should call onChangeItemsPerPage when you pick a new limit', () => { + describe('Events', () => { + test('should call loadmore when clicking on the button load more', () => { const wrapper = mount( { isLive={false} isLoading={false} itemsCount={itemsCount} - itemsPerPage={1} + itemsPerPage={2} itemsPerPageOptions={[1, 5, 10, 20]} - onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} totalCount={serverSideEventCount} /> ); - 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(); + 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( @@ -224,7 +222,6 @@ describe('Footer Timeline Component', () => { itemsCount={itemsCount} itemsPerPage={2} itemsPerPageOptions={[1, 5, 10, 20]} - onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} totalCount={serverSideEventCount} /> @@ -248,7 +245,6 @@ describe('Footer Timeline Component', () => { itemsCount={itemsCount} itemsPerPage={2} itemsPerPageOptions={[1, 5, 10, 20]} - onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} totalCount={serverSideEventCount} /> 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 f56d7d90cf2df..17d57b46d730c 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 @@ -21,14 +21,16 @@ import { 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 { LoadingPanel } from '../../loading'; -import { OnChangeItemsPerPage, OnChangePage } from '../events'; +import { OnChangePage } from '../events'; 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'; export const isCompactFooter = (width: number): boolean => width < 600; @@ -232,7 +234,6 @@ interface FooterProps { itemsCount: number; itemsPerPage: number; itemsPerPageOptions: number[]; - onChangeItemsPerPage: OnChangeItemsPerPage; onChangePage: OnChangePage; totalCount: number; } @@ -248,10 +249,10 @@ export const FooterComponent = ({ itemsCount, itemsPerPage, itemsPerPageOptions, - onChangeItemsPerPage, onChangePage, totalCount, }: FooterProps) => { + const dispatch = useDispatch(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [paginationLoading, setPaginationLoading] = useState(false); @@ -273,8 +274,15 @@ export const FooterComponent = ({ isPopoverOpen, setIsPopoverOpen, ]); + const closePopover = useCallback(() => setIsPopoverOpen(false), [setIsPopoverOpen]); + const onChangeItemsPerPage = useCallback( + (itemsChangedPerPage) => + dispatch(timelineActions.updateItemsPerPage({ id, itemsPerPage: itemsChangedPerPage })), + [dispatch, id] + ); + const rowItems = useMemo( () => itemsPerPageOptions && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx new file mode 100644 index 0000000000000..84ac74550ffd7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; + +import { timelineSelectors } from '../../../store/timeline'; +import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { GraphOverlay } from '../../graph_overlay'; + +interface GraphTabContentProps { + timelineId: string; +} + +const GraphTabContentComponent: React.FC = ({ timelineId }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => getTimeline(state, timelineId)?.graphEventId + ); + + if (!graphEventId) { + return null; + } + + return ; +}; + +GraphTabContentComponent.displayName = 'GraphTabContentComponent'; + +const GraphTabContent = React.memo(GraphTabContentComponent); + +// eslint-disable-next-line import/no-default-export +export { GraphTabContent as default }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap index 66758268fb39e..b6559114f6d2b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap @@ -3,145 +3,9 @@ exports[`Header rendering renders correctly against snapshot 1`] = ` diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx index 329bcf24ba7ed..13ac4ed782807 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx @@ -58,18 +58,6 @@ describe('Header', () => { expect(wrapper.find('[data-test-subj="dataProviders"]').exists()).toEqual(true); }); - test('it does NOT render the data providers when show is false', () => { - const testProps = { ...props, show: false }; - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="dataProviders"]').exists()).toEqual(false); - }); - test('it renders the unauthorized call out providers', () => { const testProps = { ...props, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index 22d28737e5d61..248267fb2e052 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -6,13 +6,10 @@ import { EuiCallOut } from '@elastic/eui'; import React from 'react'; -import { FilterManager, IIndexPattern } from 'src/plugins/data/public'; -import deepEqual from 'fast-deep-equal'; +import { FilterManager } from 'src/plugins/data/public'; import { DataProviders } from '../data_providers'; -import { DataProvider } from '../data_providers/data_provider'; import { StatefulSearchOrFilter } from '../search_or_filter'; -import { BrowserFields } from '../../../../common/containers/source'; import * as i18n from './translations'; import { @@ -21,24 +18,14 @@ import { } from '../../../../../common/types/timeline'; interface Props { - browserFields: BrowserFields; - dataProviders: DataProvider[]; filterManager: FilterManager; - graphEventId?: string; - indexPattern: IIndexPattern; - show: boolean; showCallOutUnauthorizedMsg: boolean; status: TimelineStatusLiteralWithNull; timelineId: string; } const TimelineHeaderComponent: React.FC = ({ - browserFields, - indexPattern, - dataProviders, filterManager, - graphEventId, - show, showCallOutUnauthorizedMsg, status, timelineId, @@ -62,35 +49,10 @@ const TimelineHeaderComponent: React.FC = ({ size="s" /> )} - {show && !graphEventId && ( - <> - + - - - )} + ); -export const TimelineHeader = React.memo( - TimelineHeaderComponent, - (prevProps, nextProps) => - deepEqual(prevProps.browserFields, nextProps.browserFields) && - deepEqual(prevProps.indexPattern, nextProps.indexPattern) && - deepEqual(prevProps.dataProviders, nextProps.dataProviders) && - prevProps.filterManager === nextProps.filterManager && - prevProps.graphEventId === nextProps.graphEventId && - prevProps.show === nextProps.show && - prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && - prevProps.status === nextProps.status && - prevProps.timelineId === nextProps.timelineId -); +export const TimelineHeader = React.memo(TimelineHeaderComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx index 476ef8d1dd5a1..f3bd4a88ca236 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx @@ -7,8 +7,6 @@ import { EuiButtonIcon, EuiOverlayMask, EuiModal, EuiToolTip } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { timelineActions } from '../../../store/timeline'; import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; import { TimelineTitleAndDescription } from './title_and_description'; @@ -26,26 +24,6 @@ export const SaveTimelineButton = React.memo( setShowSaveTimelineOverlay((prevShowSaveTimelineOverlay) => !prevShowSaveTimelineOverlay); }, [setShowSaveTimelineOverlay]); - const dispatch = useDispatch(); - const updateTitle = useCallback( - ({ id, title, disableAutoSave }: { id: string; title: string; disableAutoSave?: boolean }) => - dispatch(timelineActions.updateTitle({ id, title, disableAutoSave })), - [dispatch] - ); - - const updateDescription = useCallback( - ({ - id, - description, - disableAutoSave, - }: { - id: string; - description: string; - disableAutoSave?: boolean; - }) => dispatch(timelineActions.updateDescription({ id, description, disableAutoSave })), - [dispatch] - ); - const saveTimelineButtonIcon = useMemo( () => ( ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx index 3597b26e2663a..eca889a788bca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx @@ -22,7 +22,7 @@ import { TimelineType } from '../../../../../common/types/timeline'; import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; import { TimelineInput } from '../../../store/timeline/actions'; -import { Description, Name, UpdateTitle, UpdateDescription } from '../properties/helpers'; +import { Description, Name } from '../properties/helpers'; import { TIMELINE_TITLE, DESCRIPTION, OPTIONAL } from '../properties/translations'; import { useCreateTimelineButton } from '../properties/use_create_timeline'; import * as i18n from './translations'; @@ -31,8 +31,6 @@ interface TimelineTitleAndDescriptionProps { showWarning?: boolean; timelineId: string; toggleSaveTimeline: () => void; - updateTitle: UpdateTitle; - updateDescription: UpdateDescription; } const Wrapper = styled(EuiModalBody)` @@ -63,12 +61,12 @@ const usePrevious = (value: unknown) => { // the modal is used as a reminder for users to save / discard // the unsaved timeline / template export const TimelineTitleAndDescription = React.memo( - ({ timelineId, toggleSaveTimeline, updateTitle, updateDescription, showWarning }) => { + ({ timelineId, toggleSaveTimeline, showWarning }) => { const timeline = useShallowEqualSelector((state) => timelineSelectors.selectTimeline(state, timelineId) ); - const { description, isSaving, savedObjectId, title, timelineType } = timeline; + const { isSaving, savedObjectId, title, timelineType } = timeline; const prevIsSaving = usePrevious(isSaving); const dispatch = useDispatch(); @@ -156,11 +154,6 @@ export const TimelineTitleAndDescription = React.memo @@ -169,14 +162,11 @@ export const TimelineTitleAndDescription = React.memo diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index d2737de7e75dc..085a9bf8cba3f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -18,7 +18,7 @@ import { TestProviders, } from '../../../common/mock'; -import { StatefulTimeline, OwnProps as StatefulTimelineOwnProps } from './index'; +import { StatefulTimeline, Props as StatefulTimelineOwnProps } from './index'; import { useTimelineEvents } from '../../containers/index'; jest.mock('../../containers/index', () => ({ @@ -40,7 +40,6 @@ jest.mock('react-router-dom', () => { useHistory: jest.fn(), }; }); -jest.mock('../flyout/header_with_close_button'); jest.mock('../../../common/containers/sourcerer', () => { const originalModule = jest.requireActual('../../../common/containers/sourcerer'); @@ -57,9 +56,7 @@ jest.mock('../../../common/containers/sourcerer', () => { }); describe('StatefulTimeline', () => { const props: StatefulTimelineOwnProps = { - id: 'id', - onClose: jest.fn(), - usersViewing: [], + timelineId: 'id', }; beforeEach(() => { 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 baa62b629567d..6b27eea64aeb9 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 @@ -4,243 +4,83 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash/fp'; -import React, { useEffect, useCallback } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; -import deepEqual from 'fast-deep-equal'; +import { pick } from 'lodash/fp'; +import { EuiProgress } from '@elastic/eui'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; -import { inputsModel, inputsSelectors, State } from '../../../common/store'; import { timelineActions, timelineSelectors } from '../../store/timeline'; -import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { defaultHeaders } from './body/column_headers/default_headers'; -import { OnChangeItemsPerPage } from './events'; -import { Timeline } from './timeline'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header'; +import { TimelineType } from '../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { activeTimeline } from '../../containers/active_timeline_context'; - -export interface OwnProps { - id: string; - onClose: () => void; - usersViewing: string[]; +import * as i18n from './translations'; +import { TabsContent } from './tabs_content'; + +const TimelineContainer = styled.div` + height: 100%; + display: flex; + flex-direction: column; + position: relative; +`; + +const TimelineTemplateBadge = styled.div` + background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; + color: #fff; + padding: 10px 15px; + font-size: 0.8em; +`; + +export interface Props { + timelineId: string; } -export type Props = OwnProps & PropsFromRedux; - -const isTimerangeSame = (prevProps: Props, nextProps: Props) => - prevProps.end === nextProps.end && - prevProps.start === nextProps.start && - prevProps.timerangeKind === nextProps.timerangeKind; - -const StatefulTimelineComponent = React.memo( - ({ - columns, - createTimeline, - dataProviders, - end, - filters, - graphEventId, - id, - isLive, - isSaving, - isTimelineExists, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - kqlQueryExpression, - onClose, - removeColumn, - show, - showCallOutUnauthorizedMsg, - sort, - start, - status, - timelineType, - timerangeKind, - updateItemsPerPage, - upsertColumn, - usersViewing, - }) => { - const { - browserFields, - docValueFields, - loading, - indexPattern, - selectedPatterns, - } = useSourcererScope(SourcererScopeName.timeline); - - const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback( - (itemsChangedPerPage) => updateItemsPerPage!({ id, itemsPerPage: itemsChangedPerPage }), - [id, updateItemsPerPage] - ); - - const toggleColumn = useCallback( - (column: ColumnHeaderOptions) => { - const exists = columns.findIndex((c) => c.id === column.id) !== -1; - - if (!exists && upsertColumn != null) { - upsertColumn({ - column, - id, - index: 1, - }); - } - - if (exists && removeColumn != null) { - removeColumn({ - columnId: column.id, - id, - }); - } - }, - [columns, id, removeColumn, upsertColumn] - ); - - useEffect(() => { - if (createTimeline != null && !isTimelineExists) { - createTimeline({ - id, +const StatefulTimelineComponent: React.FC = ({ timelineId }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { selectedPatterns } = useSourcererScope(SourcererScopeName.timeline); + const { graphEventId, isSaving, savedObjectId, timelineType } = useDeepEqualSelector((state) => + pick( + ['graphEventId', 'isSaving', 'savedObjectId', 'timelineType'], + getTimeline(state, timelineId) ?? timelineDefaults + ) + ); + + useEffect(() => { + if (!savedObjectId) { + dispatch( + timelineActions.createTimeline({ + id: timelineId, columns: defaultHeaders, indexNames: selectedPatterns, - show: false, expandedEvent: activeTimeline.getExpandedEvent(), - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - ); - }, - (prevProps, nextProps) => { - return ( - isTimerangeSame(prevProps, nextProps) && - prevProps.graphEventId === nextProps.graphEventId && - prevProps.id === nextProps.id && - prevProps.isLive === nextProps.isLive && - prevProps.isSaving === nextProps.isSaving && - prevProps.isTimelineExists === nextProps.isTimelineExists && - prevProps.itemsPerPage === nextProps.itemsPerPage && - prevProps.kqlMode === nextProps.kqlMode && - prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && - prevProps.show === nextProps.show && - prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && - prevProps.timelineType === nextProps.timelineType && - prevProps.status === nextProps.status && - deepEqual(prevProps.columns, nextProps.columns) && - deepEqual(prevProps.dataProviders, nextProps.dataProviders) && - deepEqual(prevProps.filters, nextProps.filters) && - deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && - deepEqual(prevProps.sort, nextProps.sort) && - deepEqual(prevProps.usersViewing, nextProps.usersViewing) - ); - } -); - -StatefulTimelineComponent.displayName = 'StatefulTimelineComponent'; - -const makeMapStateToProps = () => { - const getShowCallOutUnauthorizedMsg = timelineSelectors.getShowCallOutUnauthorizedMsg(); - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const getKqlQueryTimeline = timelineSelectors.getKqlFilterQuerySelector(); - const getInputsTimeline = inputsSelectors.getTimelineSelector(); - const mapStateToProps = (state: State, { id }: OwnProps) => { - const timeline: TimelineModel = getTimeline(state, id) ?? timelineDefaults; - const input: inputsModel.InputsRange = getInputsTimeline(state); - const { - columns, - dataProviders, - eventType, - filters, - graphEventId, - itemsPerPage, - itemsPerPageOptions, - isSaving, - kqlMode, - show, - sort, - status, - timelineType, - } = timeline; - const kqlQueryTimeline = getKqlQueryTimeline(state, id)!; - const timelineFilter = kqlMode === 'filter' ? filters || [] : []; - - // return events on empty search - const kqlQueryExpression = - isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template' - ? ' ' - : kqlQueryTimeline; - return { - columns, - dataProviders, - eventType, - end: input.timerange.to, - filters: timelineFilter, - graphEventId, - id, - isLive: input.policy.kind === 'interval', - isSaving, - isTimelineExists: getTimeline(state, id) != null, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - kqlQueryExpression, - show, - showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), - sort, - start: input.timerange.from, - status, - timelineType, - timerangeKind: input.timerange.kind, - }; - }; - return mapStateToProps; -}; - -const mapDispatchToProps = { - addProvider: timelineActions.addProvider, - createTimeline: timelineActions.createTimeline, - removeColumn: timelineActions.removeColumn, - updateColumns: timelineActions.updateColumns, - updateItemsPerPage: timelineActions.updateItemsPerPage, - updateItemsPerPageOptions: timelineActions.updateItemsPerPageOptions, - updateSort: timelineActions.updateSort, - upsertColumn: timelineActions.upsertColumn, + show: false, + }) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {isSaving && } + {timelineType === TimelineType.template && ( + {i18n.TIMELINE_TEMPLATE} + )} + + + + + + + ); }; -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; +StatefulTimelineComponent.displayName = 'StatefulTimelineComponent'; -export const StatefulTimeline = connector(StatefulTimelineComponent); +export const StatefulTimeline = React.memo(StatefulTimelineComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx new file mode 100644 index 0000000000000..9855a0124b8f5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick } from 'lodash/fp'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiPanel } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; + +import { timelineActions, timelineSelectors } from '../../../store/timeline'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { TimelineStatus } from '../../../../../common/types/timeline'; +import { appSelectors } from '../../../../common/store/app'; +import { timelineDefaults } from '../../../store/timeline/defaults'; +import { AddNote } from '../../notes/add_note'; +import { InMemoryTable } from '../../notes'; +import { columns } from '../../notes/columns'; +import { search } from '../../notes/helpers'; + +const FullWidthFlexGroup = styled(EuiFlexGroup)` + width: 100%; + margin: 0; + overflow: hidden; +`; + +const ScrollableFlexItem = styled(EuiFlexItem)` + overflow: auto; +`; + +const VerticalRule = styled.div` + width: 2px; + height: 100%; + background: ${({ theme }) => theme.eui.euiColorLightShade}; +`; + +const StyledPanel = styled(EuiPanel)` + border: 0; + box-shadow: none; +`; + +interface NotesTabContentProps { + timelineId: string; +} + +const NotesTabContentComponent: React.FC = ({ timelineId }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { status: timelineStatus, noteIds } = useDeepEqualSelector((state) => + pick(['noteIds', 'status'], getTimeline(state, timelineId) ?? timelineDefaults) + ); + + const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); + const [newNote, setNewNote] = useState(''); + const isImmutable = timelineStatus === TimelineStatus.immutable; + const notesById = useDeepEqualSelector(getNotesByIds); + + const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); + + const associateNote = useCallback( + (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), + [dispatch, timelineId] + ); + + return ( + + + + +

{'Notes'}

+
+ + + + {!isImmutable && ( + + )} +
+
+ + {/* SIDEBAR PLACEHOLDER */} +
+ ); +}; + +NotesTabContentComponent.displayName = 'NotesTabContentComponent'; + +const NotesTabContent = React.memo(NotesTabContentComponent); + +// eslint-disable-next-line import/no-default-export +export { NotesTabContent as default }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx index dd0695e795397..6eb9286871b68 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx @@ -4,32 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { mount, shallow } from 'enzyme'; +import { mount } from 'enzyme'; + import { Description, Name, NewTimeline, NewTimelineProps } from './helpers'; import { useCreateTimelineButton } from './use_create_timeline'; import * as i18n from './translations'; +import { mockTimelineModel, TestProviders } from '../../../../common/mock'; import { TimelineType } from '../../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -jest.mock('./use_create_timeline', () => ({ - useCreateTimelineButton: jest.fn(), -})); +jest.mock('../../../../common/hooks/use_selector'); + +jest.mock('./use_create_timeline'); -jest.mock('../../../../common/lib/kibana', () => { - return { - useKibana: jest.fn().mockReturnValue({ - services: { - application: { - navigateToApp: () => Promise.resolve(), - capabilities: { - siem: { - crud: true, - }, +jest.mock('../../../../common/lib/kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + navigateToApp: () => Promise.resolve(), + capabilities: { + siem: { + crud: true, }, }, }, - }), - }; -}); + }, + }), +})); describe('NewTimeline', () => { const mockGetButton = jest.fn(); @@ -44,7 +45,7 @@ describe('NewTimeline', () => { describe('default', () => { beforeAll(() => { (useCreateTimelineButton as jest.Mock).mockReturnValue({ getButton: mockGetButton }); - shallow(); + mount(); }); afterAll(() => { @@ -94,19 +95,27 @@ describe('Description', () => { }; test('should render tooltip', () => { - const component = shallow(); + const component = mount( + + + + ); expect( - component.find('[data-test-subj="timeline-description-tool-tip"]').prop('content') + component.find('[data-test-subj="timeline-description-tool-tip"]').first().prop('content') ).toEqual(i18n.DESCRIPTION_TOOL_TIP); }); test('should not render textarea if isTextArea is false', () => { - const component = shallow(); + const component = mount( + + + + ); expect(component.find('[data-test-subj="timeline-description-textarea"]').exists()).toEqual( false ); - expect(component.find('[data-test-subj="timeline-description"]').exists()).toEqual(true); + expect(component.find('[data-test-subj="timeline-description-input"]').exists()).toEqual(true); }); test('should render textarea if isTextArea is true', () => { @@ -114,7 +123,11 @@ describe('Description', () => { ...props, isTextArea: true, }; - const component = shallow(); + const component = mount( + + + + ); expect(component.find('[data-test-subj="timeline-description-textarea"]').exists()).toEqual( true ); @@ -129,28 +142,44 @@ describe('Name', () => { updateTitle: jest.fn(), }; + beforeAll(() => { + (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); + }); + test('should render tooltip', () => { - const component = shallow(); - expect(component.find('[data-test-subj="timeline-title-tool-tip"]').prop('content')).toEqual( - i18n.TITLE + const component = mount( + + + ); + expect( + component.find('[data-test-subj="timeline-title-tool-tip"]').first().prop('content') + ).toEqual(i18n.TITLE); }); test('should render placeholder by timelineType - timeline', () => { - const component = shallow(); - expect(component.find('[data-test-subj="timeline-title"]').prop('placeholder')).toEqual( - i18n.UNTITLED_TIMELINE + const component = mount( + + + ); + expect( + component.find('[data-test-subj="timeline-title-input"]').first().prop('placeholder') + ).toEqual(i18n.UNTITLED_TIMELINE); }); test('should render placeholder by timelineType - timeline template', () => { - const testProps = { - ...props, + (useDeepEqualSelector as jest.Mock).mockReturnValue({ + ...mockTimelineModel, timelineType: TimelineType.template, - }; - const component = shallow(); - expect(component.find('[data-test-subj="timeline-title"]').prop('placeholder')).toEqual( - i18n.UNTITLED_TEMPLATE + }); + const component = mount( + + + ); + expect( + component.find('[data-test-subj="timeline-title-input"]').first().prop('placeholder') + ).toEqual(i18n.UNTITLED_TEMPLATE); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 25039dbc9529a..bc83d42d31c98 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -7,7 +7,6 @@ import { EuiBadge, EuiButton, - EuiButtonEmpty, EuiButtonIcon, EuiFieldText, EuiFlexGroup, @@ -18,41 +17,31 @@ import { EuiToolTip, EuiTextArea, } from '@elastic/eui'; +import { pick } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import uuid from 'uuid'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; -import { APP_ID } from '../../../../../common/constants'; import { TimelineTypeLiteral, - TimelineStatus, TimelineType, TimelineStatusLiteral, - TimelineId, } from '../../../../../common/types/timeline'; -import { SecurityPageName } from '../../../../app/types'; -import { timelineSelectors } from '../../../../timelines/store/timeline'; -import { getCreateCaseUrl } from '../../../../common/components/link_to'; -import { useKibana } from '../../../../common/lib/kibana'; -import { Note } from '../../../../common/lib/note'; -import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; +import { + useDeepEqualSelector, + useShallowEqualSelector, +} from '../../../../common/hooks/use_selector'; import { Notes } from '../../notes'; -import { AssociateNote, UpdateNote } from '../../notes/helpers'; +import { AssociateNote } from '../../notes/helpers'; import { NOTES_PANEL_WIDTH } from './notes_size'; -import { - ButtonContainer, - DescriptionContainer, - LabelText, - NameField, - NameWrapper, - StyledStar, -} from './styles'; +import { ButtonContainer, DescriptionContainer, LabelText, NameField, NameWrapper } from './styles'; import * as i18n from './translations'; -import { setInsertTimeline, showTimeline, TimelineInput } from '../../../store/timeline/actions'; +import { TimelineInput } from '../../../store/timeline/actions'; import { useCreateTimelineButton } from './use_create_timeline'; +import { timelineDefaults } from '../../../store/timeline/defaults'; export const historyToolTip = 'The chronological history of actions related to this timeline'; export const streamLiveToolTip = 'Update the Timeline as new data arrives'; @@ -65,94 +54,74 @@ const NotesCountBadge = (styled(EuiBadge)` NotesCountBadge.displayName = 'NotesCountBadge'; -type CreateTimeline = ({ - id, - show, - timelineType, -}: { - id: string; - show?: boolean; - timelineType?: TimelineTypeLiteral; -}) => void; -type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; -export type UpdateTitle = ({ - id, - title, - disableAutoSave, -}: { - id: string; - title: string; - disableAutoSave?: boolean; -}) => void; -export type UpdateDescription = ({ - id, - description, - disableAutoSave, -}: { - id: string; - description: string; - disableAutoSave?: boolean; -}) => void; export type SaveTimeline = (args: TimelineInput) => void; -export const StarIcon = React.memo<{ - isFavorite: boolean; +interface AddToFavoritesButtonProps { timelineId: string; - updateIsFavorite: UpdateIsFavorite; -}>(({ isFavorite, timelineId: id, updateIsFavorite }) => { - const handleClick = useCallback(() => updateIsFavorite({ id, isFavorite: !isFavorite }), [ - id, - isFavorite, - updateIsFavorite, - ]); +} + +const AddToFavoritesButtonComponent: React.FC = ({ timelineId }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + + const isFavorite = useShallowEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).isFavorite + ); + + const handleClick = useCallback( + () => dispatch(timelineActions.updateIsFavorite({ id: timelineId, isFavorite: !isFavorite })), + [dispatch, timelineId, isFavorite] + ); return ( - // TODO: 1 error is: Visible, non-interactive elements with click handlers must have at least one keyboard listener - // TODO: 2 error is: Elements with the 'button' interactive role must be focusable - // TODO: Investigate this error - // eslint-disable-next-line -
- {isFavorite ? ( - - - - ) : ( - - - - )} -
+ + {isFavorite ? i18n.REMOVE_FROM_FAVORITES : i18n.ADD_TO_FAVORITES} + ); -}); -StarIcon.displayName = 'StarIcon'; +}; +AddToFavoritesButtonComponent.displayName = 'AddToFavoritesButtonComponent'; + +export const AddToFavoritesButton = React.memo(AddToFavoritesButtonComponent); interface DescriptionProps { - description: string; timelineId: string; - updateDescription: UpdateDescription; isTextArea?: boolean; disableAutoSave?: boolean; disableTooltip?: boolean; disabled?: boolean; - marginRight?: number; } export const Description = React.memo( ({ - description, timelineId, - updateDescription, isTextArea = false, disableAutoSave = false, disableTooltip = false, disabled = false, - marginRight, }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + + const description = useShallowEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).description + ); + const onDescriptionChanged = useCallback( (e) => { - updateDescription({ id: timelineId, description: e.target.value, disableAutoSave }); + dispatch( + timelineActions.updateDescription({ + id: timelineId, + description: e.target.value, + disableAutoSave, + }) + ); }, - [updateDescription, disableAutoSave, timelineId] + [dispatch, disableAutoSave, timelineId] ); const inputField = useMemo( @@ -161,7 +130,6 @@ export const Description = React.memo( ( ) : ( ( [description, isTextArea, onDescriptionChanged, disabled] ); return ( - + {disableTooltip ? ( inputField ) : ( @@ -204,11 +171,6 @@ interface NameProps { disableTooltip?: boolean; disabled?: boolean; timelineId: string; - timelineType: TimelineType; - title: string; - updateTitle: UpdateTitle; - width?: string; - marginRight?: number; } export const Name = React.memo( @@ -218,17 +180,21 @@ export const Name = React.memo( disableTooltip = false, disabled = false, timelineId, - timelineType, - title, - updateTitle, - width, - marginRight, }) => { + const dispatch = useDispatch(); const timelineNameRef = useRef(null); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + + const { title, timelineType } = useDeepEqualSelector((state) => + pick(['title', 'timelineType'], getTimeline(state, timelineId) ?? timelineDefaults) + ); const handleChange = useCallback( - (e) => updateTitle({ id: timelineId, title: e.target.value, disableAutoSave }), - [timelineId, updateTitle, disableAutoSave] + (e) => + dispatch( + timelineActions.updateTitle({ id: timelineId, title: e.target.value, disableAutoSave }) + ), + [dispatch, timelineId, disableAutoSave] ); useEffect(() => { @@ -241,7 +207,7 @@ export const Name = React.memo( () => ( ( } spellCheck={true} value={title} - width={width} - marginRight={marginRight} inputRef={timelineNameRef} /> ), - [handleChange, marginRight, timelineType, title, width, disabled] + [handleChange, timelineType, title, disabled] ); return ( @@ -272,123 +236,7 @@ export const Name = React.memo( ); Name.displayName = 'Name'; -interface NewCaseProps { - compact?: boolean; - graphEventId?: string; - onClosePopover: () => void; - timelineId: string; - timelineStatus: TimelineStatus; - timelineTitle: string; -} - -export const NewCase = React.memo( - ({ compact, graphEventId, onClosePopover, timelineId, timelineStatus, timelineTitle }) => { - const dispatch = useDispatch(); - const { savedObjectId } = useShallowEqualSelector((state) => - timelineSelectors.selectTimeline(state, timelineId) - ); - const { navigateToApp } = useKibana().services.application; - const buttonText = compact ? i18n.ATTACH_TO_NEW_CASE : i18n.ATTACH_TIMELINE_TO_NEW_CASE; - - const handleClick = useCallback(() => { - onClosePopover(); - - dispatch(showTimeline({ id: TimelineId.active, show: false })); - - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCreateCaseUrl(), - }).then(() => - dispatch( - setInsertTimeline({ - graphEventId, - timelineId, - timelineSavedObjectId: savedObjectId, - timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, - }) - ) - ); - }, [ - dispatch, - graphEventId, - navigateToApp, - onClosePopover, - savedObjectId, - timelineId, - timelineTitle, - ]); - - const button = useMemo( - () => ( - - {buttonText} - - ), - [compact, timelineStatus, handleClick, buttonText] - ); - return timelineStatus === TimelineStatus.draft ? ( - - {button} - - ) : ( - button - ); - } -); -NewCase.displayName = 'NewCase'; - -interface ExistingCaseProps { - compact?: boolean; - onClosePopover: () => void; - onOpenCaseModal: () => void; - timelineStatus: TimelineStatus; -} -export const ExistingCase = React.memo( - ({ compact, onClosePopover, onOpenCaseModal, timelineStatus }) => { - const handleClick = useCallback(() => { - onClosePopover(); - onOpenCaseModal(); - }, [onOpenCaseModal, onClosePopover]); - const buttonText = compact - ? i18n.ATTACH_TO_EXISTING_CASE - : i18n.ATTACH_TIMELINE_TO_EXISTING_CASE; - - const button = useMemo( - () => ( - - {buttonText} - - ), - [buttonText, handleClick, timelineStatus, compact] - ); - return timelineStatus === TimelineStatus.draft ? ( - - {button} - - ) : ( - button - ); - } -); -ExistingCase.displayName = 'ExistingCase'; - export interface NewTimelineProps { - createTimeline?: CreateTimeline; closeGearMenu?: () => void; outline?: boolean; timelineId: string; @@ -412,7 +260,6 @@ NewTimeline.displayName = 'NewTimeline'; interface NotesButtonProps { animate?: boolean; associateNote: AssociateNote; - getNotesByIds: (noteIds: string[]) => Note[]; noteIds: string[]; size: 's' | 'l'; status: TimelineStatusLiteral; @@ -420,12 +267,9 @@ interface NotesButtonProps { toggleShowNotes: () => void; text?: string; toolTip?: string; - updateNote: UpdateNote; timelineType: TimelineTypeLiteral; } -const getNewNoteId = (): string => uuid.v4(); - interface LargeNotesButtonProps { noteIds: string[]; text?: string; @@ -433,11 +277,7 @@ interface LargeNotesButtonProps { } const LargeNotesButton = React.memo(({ noteIds, text, toggleShowNotes }) => ( - toggleShowNotes()} - size="m" - > + @@ -468,7 +308,7 @@ const SmallNotesButton = React.memo(({ toggleShowNotes, t aria-label={i18n.NOTES} data-test-subj="timeline-notes-button-small" iconType="editorComment" - onClick={() => toggleShowNotes()} + onClick={toggleShowNotes} isDisabled={isTemplate} /> ); @@ -482,14 +322,12 @@ const NotesButtonComponent = React.memo( ({ animate = true, associateNote, - getNotesByIds, noteIds, showNotes, size, status, toggleShowNotes, text, - updateNote, timelineType, }) => ( @@ -506,14 +344,7 @@ const NotesButtonComponent = React.memo( maxWidth={NOTES_PANEL_WIDTH} onClose={toggleShowNotes} > - + ) : null} @@ -527,7 +358,6 @@ export const NotesButton = React.memo( ({ animate = true, associateNote, - getNotesByIds, noteIds, showNotes, size, @@ -536,20 +366,17 @@ export const NotesButton = React.memo( toggleShowNotes, toolTip, text, - updateNote, }) => showNotes ? ( ) : ( @@ -557,14 +384,12 @@ export const NotesButton = React.memo( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx deleted file mode 100644 index a6740a0cdb0f3..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ /dev/null @@ -1,402 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount } from 'enzyme'; -import React from 'react'; - -import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; -import { - mockGlobalState, - apolloClientObservable, - SUB_PLUGINS_REDUCER, - createSecuritySolutionStorageMock, - TestProviders, - kibanaObservable, -} from '../../../../common/mock'; -import '../../../../common/mock/match_media'; -import { createStore, State } from '../../../../common/store'; -import { useThrottledResizeObserver } from '../../../../common/components/utils'; -import { Properties, showDescriptionThreshold, showNotesThreshold } from '.'; -import { setInsertTimeline } from '../../../store/timeline/actions'; -export { nextTick } from '@kbn/test/jest'; -import { waitFor } from '@testing-library/react'; - -jest.mock('../../../../common/components/link_to'); - -const mockNavigateToApp = jest.fn().mockImplementation(() => Promise.resolve()); -jest.mock('../../../../common/lib/kibana', () => { - const original = jest.requireActual('../../../../common/lib/kibana'); - - return { - ...original, - useKibana: () => ({ - services: { - application: { - capabilities: { - siem: { - crud: true, - }, - }, - navigateToApp: mockNavigateToApp, - }, - }, - }), - useUiSetting$: jest.fn().mockReturnValue([]), - useGetUserSavedObjectPermissions: jest.fn(), - }; -}); - -const mockDispatch = jest.fn(); -jest.mock('../../../../common/components/utils', () => { - return { - useThrottledResizeObserver: jest.fn(), - }; -}); - -jest.mock('react-redux', () => { - const original = jest.requireActual('react-redux'); - - return { - ...original, - useDispatch: () => mockDispatch, - useSelector: jest.fn().mockReturnValue({ savedObjectId: '1', urlState: {} }), - }; -}); - -jest.mock('react-router-dom', () => { - const original = jest.requireActual('react-router-dom'); - - return { - ...original, - useHistory: () => ({ - push: jest.fn(), - }), - }; -}); - -jest.mock('./use_create_timeline', () => ({ - useCreateTimelineButton: jest.fn().mockReturnValue({ getButton: jest.fn() }), -})); -const usersViewing = ['elastic']; -const defaultProps = { - associateNote: jest.fn(), - createTimeline: jest.fn(), - isDataInTimeline: false, - isDatepickerLocked: false, - isFavorite: false, - title: '', - timelineType: TimelineType.default, - description: '', - getNotesByIds: jest.fn(), - noteIds: [], - saveTimeline: jest.fn(), - status: TimelineStatus.active, - timelineId: 'abc', - toggleLock: jest.fn(), - updateDescription: jest.fn(), - updateIsFavorite: jest.fn(), - updateTitle: jest.fn(), - updateNote: jest.fn(), - usersViewing, -}; -describe('Properties', () => { - const state: State = mockGlobalState; - const { storage } = createSecuritySolutionStorageMock(); - let mockedWidth = 1000; - - let store = createStore( - state, - SUB_PLUGINS_REDUCER, - apolloClientObservable, - kibanaObservable, - storage - ); - - beforeEach(() => { - jest.clearAllMocks(); - store = createStore( - state, - SUB_PLUGINS_REDUCER, - apolloClientObservable, - kibanaObservable, - storage - ); - (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: mockedWidth }); - }); - - test('renders correctly', () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); - - expect(wrapper.find('[data-test-subj="timeline-properties"]').exists()).toEqual(true); - expect(wrapper.find('button[data-test-subj="attach-timeline-case"]').prop('disabled')).toEqual( - false - ); - expect( - wrapper.find('button[data-test-subj="attach-timeline-existing-case"]').prop('disabled') - ).toEqual(false); - }); - - test('renders correctly draft timeline', () => { - const testProps = { ...defaultProps, status: TimelineStatus.draft }; - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); - - expect(wrapper.find('button[data-test-subj="attach-timeline-case"]').prop('disabled')).toEqual( - true - ); - expect( - wrapper.find('button[data-test-subj="attach-timeline-existing-case"]').prop('disabled') - ).toEqual(true); - }); - - test('it renders an empty star icon when it is NOT a favorite', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="timeline-favorite-empty-star"]').exists()).toEqual(true); - }); - - test('it renders a filled star icon when it is a favorite', () => { - const testProps = { ...defaultProps, isFavorite: true }; - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="timeline-favorite-filled-star"]').exists()).toEqual(true); - }); - - test('it renders the title of the timeline', () => { - const title = 'foozle'; - const testProps = { ...defaultProps, title }; - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="timeline-title"]').first().props().value).toEqual(title); - }); - - test('it renders the date picker with the lock icon', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="properties-left"]') - .find('[data-test-subj="timeline-date-picker-container"]') - .exists() - ).toEqual(true); - }); - - test('it renders the lock icon when isDatepickerLocked is true', () => { - const testProps = { ...defaultProps, isDatepickerLocked: true }; - - const wrapper = mount( - - - - ); - expect( - wrapper - .find('[data-test-subj="properties-left"]') - .find('[data-test-subj="timeline-date-picker-lock-button"]') - .exists() - ).toEqual(true); - }); - - test('it renders the unlock icon when isDatepickerLocked is false', () => { - const wrapper = mount( - - - - ); - expect( - wrapper - .find('[data-test-subj="properties-left"]') - .find('[data-test-subj="timeline-date-picker-unlock-button"]') - .exists() - ).toEqual(true); - }); - - test('it renders a description on the left when the width is at least as wide as the threshold', () => { - const description = 'strange'; - const testProps = { ...defaultProps, description }; - - // mockedWidth = showDescriptionThreshold; - - (useThrottledResizeObserver as jest.Mock).mockReset(); - (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: showDescriptionThreshold }); - - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="properties-left"]') - .find('[data-test-subj="timeline-description"]') - .first() - .props().value - ).toEqual(description); - }); - - test('it does NOT render a description on the left when the width is less than the threshold', () => { - const description = 'strange'; - const testProps = { ...defaultProps, description }; - - // mockedWidth = showDescriptionThreshold - 1; - - (useThrottledResizeObserver as jest.Mock).mockReset(); - (useThrottledResizeObserver as jest.Mock).mockReturnValue({ - width: showDescriptionThreshold - 1, - }); - - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="properties-left"]') - .find('[data-test-subj="timeline-description"]') - .exists() - ).toEqual(false); - }); - - test('it renders a notes button on the left when the width is at least as wide as the threshold', () => { - mockedWidth = showNotesThreshold; - - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="properties-left"]') - .find('[data-test-subj="timeline-notes-button-large"]') - .exists() - ).toEqual(true); - }); - - test('it does NOT render a a notes button on the left when the width is less than the threshold', () => { - (useThrottledResizeObserver as jest.Mock).mockReset(); - (useThrottledResizeObserver as jest.Mock).mockReturnValue({ - width: showNotesThreshold - 1, - }); - - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="properties-left"]') - .find('[data-test-subj="timeline-notes-button-large"]') - .exists() - ).toEqual(false); - }); - - test('it renders a settings icon', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toEqual(true); - }); - - test('it renders an avatar for the current user viewing the timeline when it has a title', () => { - const title = 'port scan'; - const testProps = { ...defaultProps, title }; - - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').exists()).toEqual(true); - }); - - test('it does NOT render an avatar for the current user viewing the timeline when it does NOT have a title', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').exists()).toEqual(false); - }); - - test('insert timeline - new case', async () => { - const testProps = { ...defaultProps, title: 'coolness' }; - - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); - wrapper.find('[data-test-subj="attach-timeline-case"]').first().simulate('click'); - - await waitFor(() => { - expect(mockNavigateToApp).toBeCalledWith('securitySolution:case', { path: '/create' }); - expect(mockDispatch).toBeCalledWith( - setInsertTimeline({ - timelineId: defaultProps.timelineId, - timelineSavedObjectId: '1', - timelineTitle: 'coolness', - }) - ); - }); - }); - - test('insert timeline - existing case', async () => { - const testProps = { ...defaultProps, title: 'coolness' }; - - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); - wrapper.find('[data-test-subj="attach-timeline-existing-case"]').first().simulate('click'); - - await waitFor(() => { - expect(wrapper.find('[data-test-subj="all-cases-modal"]').exists()).toBeTruthy(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx deleted file mode 100644 index 9df2b585449a0..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useState, useCallback, useMemo } from 'react'; - -import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline'; -import { useThrottledResizeObserver } from '../../../../common/components/utils'; -import { Note } from '../../../../common/lib/note'; -import { InputsModelId } from '../../../../common/store/inputs/constants'; - -import { AssociateNote, UpdateNote } from '../../notes/helpers'; - -import { TimelineProperties } from './styles'; -import { PropertiesRight } from './properties_right'; -import { PropertiesLeft } from './properties_left'; -import { useAllCasesModal } from '../../../../cases/components/use_all_cases_modal'; - -type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; -type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; -type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; -type ToggleLock = ({ linkToId }: { linkToId: InputsModelId }) => void; - -interface Props { - associateNote: AssociateNote; - description: string; - getNotesByIds: (noteIds: string[]) => Note[]; - graphEventId?: string; - isDataInTimeline: boolean; - isDatepickerLocked: boolean; - isFavorite: boolean; - noteIds: string[]; - timelineId: string; - timelineType: TimelineTypeLiteral; - status: TimelineStatusLiteral; - title: string; - toggleLock: ToggleLock; - updateDescription: UpdateDescription; - updateIsFavorite: UpdateIsFavorite; - updateNote: UpdateNote; - updateTitle: UpdateTitle; - usersViewing: string[]; -} - -const rightGutter = 60; // px -export const datePickerThreshold = 600; -export const showNotesThreshold = 810; -export const showDescriptionThreshold = 970; - -const starIconWidth = 30; -const nameWidth = 155; -const descriptionWidth = 165; -const noteWidth = 130; -const settingsWidth = 55; - -/** Displays the properties of a timeline, i.e. name, description, notes, etc */ -export const Properties = React.memo( - ({ - associateNote, - description, - getNotesByIds, - graphEventId, - isDataInTimeline, - isDatepickerLocked, - isFavorite, - noteIds, - status, - timelineId, - timelineType, - title, - toggleLock, - updateDescription, - updateIsFavorite, - updateNote, - updateTitle, - usersViewing, - }) => { - const { ref, width = 0 } = useThrottledResizeObserver(300); - const [showActions, setShowActions] = useState(false); - const [showNotes, setShowNotes] = useState(false); - const [showTimelineModal, setShowTimelineModal] = useState(false); - - const onButtonClick = useCallback(() => setShowActions(!showActions), [showActions]); - const onToggleShowNotes = useCallback(() => setShowNotes(!showNotes), [showNotes]); - const onClosePopover = useCallback(() => setShowActions(false), []); - const onCloseTimelineModal = useCallback(() => setShowTimelineModal(false), []); - const onToggleLock = useCallback(() => toggleLock({ linkToId: 'timeline' }), [toggleLock]); - const onOpenTimelineModal = useCallback(() => { - onClosePopover(); - setShowTimelineModal(true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId }); - - const datePickerWidth = useMemo( - () => - width - - rightGutter - - starIconWidth - - nameWidth - - (width >= showDescriptionThreshold ? descriptionWidth : 0) - - noteWidth - - settingsWidth, - [width] - ); - - return ( - - datePickerThreshold ? datePickerThreshold : datePickerWidth - } - description={description} - getNotesByIds={getNotesByIds} - isDatepickerLocked={isDatepickerLocked} - isFavorite={isFavorite} - noteIds={noteIds} - onToggleShowNotes={onToggleShowNotes} - status={status} - showDescription={width >= showDescriptionThreshold} - showNotes={showNotes} - showNotesFromWidth={width >= showNotesThreshold} - timelineId={timelineId} - timelineType={timelineType} - title={title} - toggleLock={onToggleLock} - updateDescription={updateDescription} - updateIsFavorite={updateIsFavorite} - updateNote={updateNote} - updateTitle={updateTitle} - /> - 0} - status={status} - timelineId={timelineId} - timelineType={timelineType} - title={title} - updateDescription={updateDescription} - updateNote={updateNote} - usersViewing={usersViewing} - /> - - - ); - } -); - -Properties.displayName = 'Properties'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx index b6e921ae9c001..e7585c3ef06a8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx @@ -100,10 +100,10 @@ describe('NewTemplateTimeline', () => { ); }); - test('no render', () => { + test('render', () => { expect( wrapper.find('[data-test-subj="template-timeline-new-with-border"]').exists() - ).toBeFalsy(); + ).toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx index b5aadaa6f1ef8..e0c4aebb5d396 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { TimelineId, TimelineType } from '../../../../../common/types/timeline'; -import { useKibana } from '../../../../common/lib/kibana'; import { useCreateTimelineButton } from './use_create_timeline'; interface OwnProps { @@ -24,9 +23,6 @@ export const NewTemplateTimelineComponent: React.FC = ({ title, timelineId = TimelineId.active, }) => { - const uiCapabilities = useKibana().services.application.capabilities; - const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.siem.crud; - const { getButton } = useCreateTimelineButton({ timelineId, timelineType: TimelineType.template, @@ -35,7 +31,7 @@ export const NewTemplateTimelineComponent: React.FC = ({ const button = getButton({ outline, title }); - return capabilitiesCanUserCRUD ? button : null; + return button; }; export const NewTemplateTimeline = React.memo(NewTemplateTimelineComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx deleted file mode 100644 index 6b181a5af7bf3..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; - -import React from 'react'; -import styled from 'styled-components'; -import { Description, Name, NotesButton, StarIcon } from './helpers'; -import { AssociateNote, UpdateNote } from '../../notes/helpers'; - -import { Note } from '../../../../common/lib/note'; -import { SuperDatePicker } from '../../../../common/components/super_date_picker'; -import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../common/types/timeline'; - -import * as i18n from './translations'; -import { SaveTimelineButton } from '../header/save_timeline_button'; -import { ENABLE_NEW_TIMELINE } from '../../../../../common/constants'; - -type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; -type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; -type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; - -interface Props { - isFavorite: boolean; - timelineId: string; - timelineType: TimelineTypeLiteral; - updateIsFavorite: UpdateIsFavorite; - showDescription: boolean; - description: string; - title: string; - updateTitle: UpdateTitle; - updateDescription: UpdateDescription; - showNotes: boolean; - status: TimelineStatusLiteral; - associateNote: AssociateNote; - showNotesFromWidth: boolean; - getNotesByIds: (noteIds: string[]) => Note[]; - onToggleShowNotes: () => void; - noteIds: string[]; - updateNote: UpdateNote; - isDatepickerLocked: boolean; - toggleLock: () => void; - datePickerWidth: number; -} - -export const PropertiesLeftStyle = styled(EuiFlexGroup)` - width: 100%; -`; - -PropertiesLeftStyle.displayName = 'PropertiesLeftStyle'; - -export const LockIconContainer = styled(EuiFlexItem)` - margin-right: 2px; -`; - -LockIconContainer.displayName = 'LockIconContainer'; - -interface WidthProp { - width: number; -} - -export const DatePicker = styled(EuiFlexItem).attrs(({ width }) => ({ - style: { - width: `${width}px`, - }, -}))` - .euiSuperDatePicker__flexWrapper { - max-width: none; - width: auto; - } -`; - -DatePicker.displayName = 'DatePicker'; - -export const PropertiesLeft = React.memo( - ({ - isFavorite, - timelineId, - updateIsFavorite, - showDescription, - description, - title, - timelineType, - updateTitle, - updateDescription, - status, - showNotes, - showNotesFromWidth, - associateNote, - getNotesByIds, - noteIds, - onToggleShowNotes, - updateNote, - isDatepickerLocked, - toggleLock, - datePickerWidth, - }) => ( - - - - - - - - {showDescription ? ( - - - - ) : null} - - {ENABLE_NEW_TIMELINE && } - - {showNotesFromWidth ? ( - - - - ) : null} - - - - - - - - - - - - - - - ) -); - -PropertiesLeft.displayName = 'PropertiesLeft'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx deleted file mode 100644 index 3f02772b46bb3..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount, ReactWrapper } from 'enzyme'; -import React from 'react'; - -import { PropertiesRight } from './properties_right'; -import { useKibana } from '../../../../common/lib/kibana'; -import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; - -jest.mock('../../../../common/lib/kibana', () => { - return { - useKibana: jest.fn(), - useUiSetting$: jest.fn().mockReturnValue([]), - }; -}); - -jest.mock('./new_template_timeline', () => { - return { - NewTemplateTimeline: jest.fn(() =>
), - }; -}); - -jest.mock('./helpers', () => { - return { - Description: jest.fn().mockReturnValue(
), - ExistingCase: jest.fn().mockReturnValue(
), - NewCase: jest.fn().mockReturnValue(
), - NewTimeline: jest.fn().mockReturnValue(
), - NotesButton: jest.fn().mockReturnValue(
), - }; -}); - -jest.mock('../../../../common/components/inspect', () => { - return { - InspectButton: jest.fn().mockReturnValue(
), - InspectButtonContainer: jest.fn(({ children }) =>
{children}
), - }; -}); - -describe('Properties Right', () => { - let wrapper: ReactWrapper; - const props = { - onButtonClick: jest.fn(), - onClosePopover: jest.fn(), - showActions: true, - createTimeline: jest.fn(), - timelineId: 'timelineId', - isDataInTimeline: false, - showNotes: false, - showNotesFromWidth: false, - showDescription: false, - showUsersView: false, - usersViewing: [], - description: 'desc', - updateDescription: jest.fn(), - associateNote: jest.fn(), - getNotesByIds: jest.fn(), - noteIds: [], - onToggleShowNotes: jest.fn(), - onCloseTimelineModal: jest.fn(), - onOpenCaseModal: jest.fn(), - onOpenTimelineModal: jest.fn(), - status: TimelineStatus.active, - showTimelineModal: false, - timelineType: TimelineType.default, - title: 'title', - updateNote: jest.fn(), - }; - - describe('with crud', () => { - describe('render', () => { - beforeAll(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - application: { - capabilities: { - siem: { - crud: true, - }, - }, - }, - }, - }); - wrapper = mount(); - }); - - afterAll(() => { - (useKibana as jest.Mock).mockReset(); - }); - - test('it renders settings-gear', () => { - expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toBeTruthy(); - }); - - test('it renders create timeline btn', () => { - expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); - }); - - test('it renders create attach timeline to a case btn', () => { - expect(wrapper.find('[data-test-subj="NewCase"]').exists()).toBeTruthy(); - }); - - test('it renders no NotesButton', () => { - expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).not.toBeTruthy(); - }); - - test('it renders no Description', () => { - expect(wrapper.find('[data-test-subj="Description"]').exists()).not.toBeTruthy(); - }); - }); - - describe('render with notes button', () => { - beforeAll(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - application: { - capabilities: { - siem: { - crud: true, - }, - }, - }, - }, - }); - const propsWithshowNotes = { - ...props, - showNotesFromWidth: true, - }; - wrapper = mount(); - }); - - afterAll(() => { - (useKibana as jest.Mock).mockReset(); - }); - - test('it renders NotesButton', () => { - expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).toBeTruthy(); - }); - }); - - describe('render with description', () => { - beforeAll(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - application: { - capabilities: { - siem: { - crud: true, - }, - }, - }, - }, - }); - const propsWithshowDescription = { - ...props, - showDescription: true, - }; - wrapper = mount(); - }); - - afterAll(() => { - (useKibana as jest.Mock).mockReset(); - }); - - test('it renders Description', () => { - expect(wrapper.find('[data-test-subj="Description"]').exists()).toBeTruthy(); - }); - }); - }); - - describe('with no crud', () => { - describe('render', () => { - beforeAll(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - application: { - capabilities: { - siem: { - crud: false, - }, - }, - }, - }, - }); - wrapper = mount(); - }); - - afterAll(() => { - (useKibana as jest.Mock).mockReset(); - }); - - test('it renders settings-gear', () => { - expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toBeTruthy(); - }); - - test('it renders create timeline template btn', () => { - expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual(true); - }); - - test('it renders create attach timeline to a case btn', () => { - expect(wrapper.find('[data-test-subj="NewCase"]').exists()).toBeTruthy(); - }); - - test('it renders no NotesButton', () => { - expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).not.toBeTruthy(); - }); - - test('it renders no Description', () => { - expect(wrapper.find('[data-test-subj="Description"]').exists()).not.toBeTruthy(); - }); - }); - - describe('render with notes button', () => { - beforeAll(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - application: { - capabilities: { - siem: { - crud: false, - }, - }, - }, - }, - }); - const propsWithshowNotes = { - ...props, - showNotesFromWidth: true, - }; - wrapper = mount(); - }); - - afterAll(() => { - (useKibana as jest.Mock).mockReset(); - }); - - test('it renders NotesButton', () => { - expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).toBeTruthy(); - }); - }); - - describe('render with description', () => { - beforeAll(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - application: { - capabilities: { - siem: { - crud: false, - }, - }, - }, - }, - }); - const propsWithshowDescription = { - ...props, - showDescription: true, - }; - wrapper = mount(); - }); - - afterAll(() => { - (useKibana as jest.Mock).mockReset(); - }); - - test('it renders Description', () => { - expect(wrapper.find('[data-test-subj="Description"]').exists()).toBeTruthy(); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx deleted file mode 100644 index 12eab4942128f..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import styled from 'styled-components'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiIcon, - EuiToolTip, - EuiAvatar, -} from '@elastic/eui'; -import { NewTimeline, Description, NotesButton, NewCase, ExistingCase } from './helpers'; - -import { - TimelineStatusLiteral, - TimelineTypeLiteral, - TimelineType, -} from '../../../../../common/types/timeline'; -import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect'; -import { Note } from '../../../../common/lib/note'; - -import { AssociateNote } from '../../notes/helpers'; -import { OpenTimelineModalButton } from '../../open_timeline/open_timeline_modal/open_timeline_modal_button'; -import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal'; - -import * as i18n from './translations'; -import { NewTemplateTimeline } from './new_template_timeline'; - -export const PropertiesRightStyle = styled(EuiFlexGroup)` - margin-right: 5px; -`; - -PropertiesRightStyle.displayName = 'PropertiesRightStyle'; - -const DescriptionPopoverMenuContainer = styled.div` - margin-top: 15px; -`; - -DescriptionPopoverMenuContainer.displayName = 'DescriptionPopoverMenuContainer'; - -const SettingsIcon = styled(EuiIcon)` - margin-left: 4px; - cursor: pointer; -`; - -SettingsIcon.displayName = 'SettingsIcon'; - -const HiddenFlexItem = styled(EuiFlexItem)` - display: none; -`; - -HiddenFlexItem.displayName = 'HiddenFlexItem'; - -const Avatar = styled(EuiAvatar)` - margin-left: 5px; -`; - -Avatar.displayName = 'Avatar'; - -type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; -export type UpdateNote = (note: Note) => void; - -interface PropertiesRightComponentProps { - associateNote: AssociateNote; - description: string; - getNotesByIds: (noteIds: string[]) => Note[]; - graphEventId?: string; - isDataInTimeline: boolean; - noteIds: string[]; - onButtonClick: () => void; - onClosePopover: () => void; - onCloseTimelineModal: () => void; - onOpenCaseModal: () => void; - onOpenTimelineModal: () => void; - onToggleShowNotes: () => void; - showActions: boolean; - showDescription: boolean; - showNotes: boolean; - showNotesFromWidth: boolean; - showTimelineModal: boolean; - showUsersView: boolean; - status: TimelineStatusLiteral; - timelineId: string; - title: string; - timelineType: TimelineTypeLiteral; - updateDescription: UpdateDescription; - updateNote: UpdateNote; - usersViewing: string[]; -} - -const PropertiesRightComponent: React.FC = ({ - associateNote, - description, - getNotesByIds, - graphEventId, - isDataInTimeline, - noteIds, - onButtonClick, - onClosePopover, - onCloseTimelineModal, - onOpenCaseModal, - onOpenTimelineModal, - onToggleShowNotes, - showActions, - showDescription, - showNotes, - showNotesFromWidth, - showTimelineModal, - showUsersView, - status, - timelineType, - timelineId, - title, - updateDescription, - updateNote, - usersViewing, -}) => { - return ( - - - - - } - id="timelineSettingsPopover" - isOpen={showActions} - closePopover={onClosePopover} - repositionOnScroll - > - - - - - - - - - - - - - - {timelineType === TimelineType.default && ( - <> - - - - - - - - )} - - - - - - {showNotesFromWidth ? ( - - - - ) : null} - - {showDescription ? ( - - - - - - ) : null} - - - - - - {showUsersView - ? usersViewing.map((user) => ( - // Hide the hard-coded elastic user avatar as the 7.2 release does not implement - // support for multi-user-collaboration as proposed in elastic/ingest-dev#395 - - - - - - )) - : null} - - {showTimelineModal ? : null} - - ); -}; - -export const PropertiesRight = React.memo(PropertiesRightComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx index e4504d40bc0a7..7dc5b8601955a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiFieldText, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { EuiFieldText } from '@elastic/eui'; import styled, { keyframes } from 'styled-components'; const fadeInEffect = keyframes` @@ -13,37 +12,7 @@ const fadeInEffect = keyframes` to { opacity: 1; } `; -interface WidthProp { - width: number; -} - -export const TimelineProperties = styled.div` - flex: 1; - align-items: center; - display: flex; - flex-direction: row; - justify-content: space-between; - user-select: none; -`; - -TimelineProperties.displayName = 'TimelineProperties'; - -export const DatePicker = styled(EuiFlexItem).attrs(({ width }) => ({ - style: { - width: `${width}px`, - }, -}))` - .euiSuperDatePicker__flexWrapper { - max-width: none; - width: auto; - } -`; -DatePicker.displayName = 'DatePicker'; - -export const NameField = styled(({ width, marginRight, ...rest }) => )` - width: ${({ width = '150px' }) => width}; - margin-right: ${({ marginRight = 10 }) => marginRight} px; - +export const NameField = styled(EuiFieldText)` .euiToolTipAnchor { display: block; } @@ -57,11 +26,7 @@ export const NameWrapper = styled.div` `; NameWrapper.displayName = 'NameWrapper'; -export const DescriptionContainer = styled.div<{ marginRight?: number }>` - animation: ${fadeInEffect} 0.3s; - margin-right: ${({ marginRight = 5 }) => marginRight}px; - min-width: 150px; - +export const DescriptionContainer = styled.div` .euiToolTipAnchor { display: block; } @@ -77,31 +42,3 @@ export const LabelText = styled.div` margin-left: 10px; `; LabelText.displayName = 'LabelText'; - -export const StyledStar = styled(EuiIcon)` - margin-right: 5px; - cursor: pointer; -`; -StyledStar.displayName = 'StyledStar'; - -export const Facet = styled.div` - align-items: center; - display: inline-flex; - justify-content: center; - border-radius: 4px; - background: #e4e4e4; - color: #000; - font-size: 12px; - line-height: 16px; - height: 20px; - min-width: 20px; - padding-left: 8px; - padding-right: 8px; - user-select: none; -`; -Facet.displayName = 'Facet'; - -export const LockIconContainer = styled(EuiFlexItem)` - margin-right: 2px; -`; -LockIconContainer.displayName = 'LockIconContainer'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts index 78d01b2d98ab3..ad3aa4a4932e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts @@ -17,17 +17,17 @@ export const TITLE = i18n.translate('xpack.securitySolution.timeline.properties. defaultMessage: 'Title', }); -export const FAVORITE = i18n.translate( - 'xpack.securitySolution.timeline.properties.favoriteTooltip', +export const ADD_TO_FAVORITES = i18n.translate( + 'xpack.securitySolution.timeline.properties.addToFavoriteButtonLabel', { - defaultMessage: 'Favorite', + defaultMessage: 'Add to favorites', } ); -export const NOT_A_FAVORITE = i18n.translate( - 'xpack.securitySolution.timeline.properties.notAFavoriteTooltip', +export const REMOVE_FROM_FAVORITES = i18n.translate( + 'xpack.securitySolution.timeline.properties.removeFromFavoritesButtonLabel', { - defaultMessage: 'Not a Favorite', + defaultMessage: 'Remove from favorites', } ); @@ -62,7 +62,7 @@ export const UNTITLED_TEMPLATE = i18n.translate( export const DESCRIPTION = i18n.translate( 'xpack.securitySolution.timeline.properties.descriptionPlaceholder', { - defaultMessage: 'Description', + defaultMessage: 'Add a description', } ); @@ -123,6 +123,13 @@ export const NEW_TEMPLATE_TIMELINE = i18n.translate( } ); +export const ADD_TIMELINE = i18n.translate( + 'xpack.securitySolution.timeline.properties.addTimelineButtonLabel', + { + defaultMessage: 'Add new timeline or template', + } +); + export const ATTACH_TIMELINE_TO_NEW_CASE = i18n.translate( 'xpack.securitySolution.timeline.properties.newCaseButtonLabel', { @@ -130,6 +137,13 @@ export const ATTACH_TIMELINE_TO_NEW_CASE = i18n.translate( } ); +export const ATTACH_TO_CASE = i18n.translate( + 'xpack.securitySolution.timeline.properties.attachToCaseButtonLabel', + { + defaultMessage: 'Attach to case', + } +); + export const ATTACH_TO_NEW_CASE = i18n.translate( 'xpack.securitySolution.timeline.properties.attachToNewCaseButtonLabel', { @@ -165,36 +179,6 @@ export const STREAM_LIVE = i18n.translate( } ); -export const LOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( - 'xpack.securitySolution.timeline.properties.lockDatePickerTooltip', - { - defaultMessage: - 'Disable syncing of date/time range between the currently viewed page and your timeline', - } -); - -export const UNLOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( - 'xpack.securitySolution.timeline.properties.unlockDatePickerTooltip', - { - defaultMessage: - 'Enable syncing of date/time range between the currently viewed page and your timeline', - } -); - -export const LOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( - 'xpack.securitySolution.timeline.properties.lockDatePickerDescription', - { - defaultMessage: 'Lock date picker to global date picker', - } -); - -export const UNLOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( - 'xpack.securitySolution.timeline.properties.unlockDatePickerDescription', - { - defaultMessage: 'Unlock date picker to global date picker', - } -); - export const OPTIONAL = i18n.translate( 'xpack.securitySolution.timeline.properties.timelineDescriptionOptional', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx index b4d168cc980b6..4043ceeb85b7e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx @@ -15,28 +15,26 @@ import { TimelineType, TimelineTypeLiteral, } from '../../../../../common/types/timeline'; -import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { inputsActions, inputsSelectors } from '../../../../common/store/inputs'; import { sourcererActions, sourcererSelectors } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; -export const useCreateTimelineButton = ({ - timelineId, - timelineType, - closeGearMenu, -}: { +interface Props { timelineId?: string; timelineType: TimelineTypeLiteral; closeGearMenu?: () => void; -}) => { +} + +export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: Props) => { const dispatch = useDispatch(); const existingIndexNamesSelector = useMemo( () => sourcererSelectors.getAllExistingIndexNamesSelector(), [] ); - const existingIndexNames = useShallowEqualSelector(existingIndexNamesSelector); + const existingIndexNames = useDeepEqualSelector(existingIndexNamesSelector); const { timelineFullScreen, setTimelineFullScreen } = useFullScreen(); - const globalTimeRange = useShallowEqualSelector(inputsSelectors.globalTimeRangeSelector); + const globalTimeRange = useDeepEqualSelector(inputsSelectors.globalTimeRangeSelector); const createTimeline = useCallback( ({ id, show }) => { if (id === TimelineId.active && timelineFullScreen) { @@ -85,13 +83,23 @@ export const useCreateTimelineButton = ({ ] ); - const handleButtonClick = useCallback(() => { + const handleCreateNewTimeline = useCallback(() => { createTimeline({ id: timelineId, show: true, timelineType }); if (typeof closeGearMenu === 'function') { closeGearMenu(); } }, [createTimeline, timelineId, timelineType, closeGearMenu]); + return handleCreateNewTimeline; +}; + +export const useCreateTimelineButton = ({ timelineId, timelineType, closeGearMenu }: Props) => { + const handleCreateNewTimeline = useCreateTimeline({ + timelineId, + timelineType, + closeGearMenu, + }); + const getButton = useCallback( ({ outline, @@ -108,11 +116,12 @@ export const useCreateTimelineButton = ({ }) => { const buttonProps = { iconType, - onClick: handleButtonClick, + onClick: handleCreateNewTimeline, fill, }; const dataTestSubjPrefix = timelineType === TimelineType.template ? `template-timeline-new` : `timeline-new`; + return outline ? ( {title} @@ -123,7 +132,7 @@ export const useCreateTimelineButton = ({ ); }, - [handleButtonClick, timelineType] + [handleCreateNewTimeline, timelineType] ); return { getButton }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx index a07ea0273cd1e..1226dabe48559 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx @@ -29,16 +29,12 @@ const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; jest.mock('../../../../common/lib/kibana'); describe('Timeline QueryBar ', () => { - const mockApplyKqlFilterQuery = jest.fn(); const mockSetFilters = jest.fn(); - const mockSetKqlFilterQueryDraft = jest.fn(); const mockSetSavedQueryId = jest.fn(); const mockUpdateReduxTime = jest.fn(); beforeEach(() => { - mockApplyKqlFilterQuery.mockClear(); mockSetFilters.mockClear(); - mockSetKqlFilterQueryDraft.mockClear(); mockSetSavedQueryId.mockClear(); mockUpdateReduxTime.mockClear(); }); @@ -77,24 +73,19 @@ describe('Timeline QueryBar ', () => { const wrapper = mount( { expect(queryBarProps.dateRangeFrom).toEqual('now-24h'); expect(queryBarProps.dateRangeTo).toEqual('now'); expect(queryBarProps.filterQuery).toEqual({ query: 'here: query', language: 'kuery' }); - expect(queryBarProps.savedQuery).toEqual(null); + expect(queryBarProps.savedQuery).toEqual(undefined); expect(queryBarProps.filters).toHaveLength(1); expect(queryBarProps.filters[0].query).toEqual(filters[1].query); }); - describe('#onChangeQuery', () => { - test(' is the only reference that changed when filterQueryDraft props get updated', () => { - const Proxy = (props: QueryBarTimelineComponentProps) => ( - - - - ); - - const wrapper = mount( - - ); - const queryBarProps = wrapper.find(QueryBar).props(); - const onChangedQueryRef = queryBarProps.onChangedQuery; - const onSubmitQueryRef = queryBarProps.onSubmitQuery; - const onSavedQueryRef = queryBarProps.onSavedQuery; - - wrapper.setProps({ filterQueryDraft: { expression: 'new: one', kind: 'kuery' } }); - wrapper.update(); - - expect(onChangedQueryRef).not.toEqual(wrapper.find(QueryBar).props().onChangedQuery); - expect(onSubmitQueryRef).toEqual(wrapper.find(QueryBar).props().onSubmitQuery); - expect(onSavedQueryRef).toEqual(wrapper.find(QueryBar).props().onSavedQuery); - }); - }); - describe('#onSubmitQuery', () => { test(' is the only reference that changed when filterQuery props get updated', () => { const Proxy = (props: QueryBarTimelineComponentProps) => ( @@ -168,31 +112,25 @@ describe('Timeline QueryBar ', () => { const wrapper = mount( ); const queryBarProps = wrapper.find(QueryBar).props(); - const onChangedQueryRef = queryBarProps.onChangedQuery; const onSubmitQueryRef = queryBarProps.onSubmitQuery; const onSavedQueryRef = queryBarProps.onSavedQuery; @@ -200,7 +138,6 @@ describe('Timeline QueryBar ', () => { wrapper.update(); expect(onSubmitQueryRef).not.toEqual(wrapper.find(QueryBar).props().onSubmitQuery); - expect(onChangedQueryRef).toEqual(wrapper.find(QueryBar).props().onChangedQuery); expect(onSavedQueryRef).toEqual(wrapper.find(QueryBar).props().onSavedQuery); }); @@ -213,31 +150,25 @@ describe('Timeline QueryBar ', () => { const wrapper = mount( ); const queryBarProps = wrapper.find(QueryBar).props(); - const onChangedQueryRef = queryBarProps.onChangedQuery; const onSubmitQueryRef = queryBarProps.onSubmitQuery; const onSavedQueryRef = queryBarProps.onSavedQuery; @@ -245,7 +176,6 @@ describe('Timeline QueryBar ', () => { wrapper.update(); expect(onSubmitQueryRef).not.toEqual(wrapper.find(QueryBar).props().onSubmitQuery); - expect(onChangedQueryRef).toEqual(wrapper.find(QueryBar).props().onChangedQuery); expect(onSavedQueryRef).toEqual(wrapper.find(QueryBar).props().onSavedQuery); }); }); @@ -260,31 +190,25 @@ describe('Timeline QueryBar ', () => { const wrapper = mount( ); const queryBarProps = wrapper.find(QueryBar).props(); - const onChangedQueryRef = queryBarProps.onChangedQuery; const onSubmitQueryRef = queryBarProps.onSubmitQuery; const onSavedQueryRef = queryBarProps.onSavedQuery; @@ -292,7 +216,6 @@ describe('Timeline QueryBar ', () => { wrapper.update(); expect(onSavedQueryRef).not.toEqual(wrapper.find(QueryBar).props().onSavedQuery); - expect(onChangedQueryRef).toEqual(wrapper.find(QueryBar).props().onChangedQuery); expect(onSubmitQueryRef).toEqual(wrapper.find(QueryBar).props().onSubmitQuery); }); @@ -305,31 +228,25 @@ describe('Timeline QueryBar ', () => { const wrapper = mount( ); const queryBarProps = wrapper.find(QueryBar).props(); - const onChangedQueryRef = queryBarProps.onChangedQuery; const onSubmitQueryRef = queryBarProps.onSubmitQuery; const onSavedQueryRef = queryBarProps.onSavedQuery; @@ -339,7 +256,6 @@ describe('Timeline QueryBar ', () => { wrapper.update(); expect(onSavedQueryRef).not.toEqual(wrapper.find(QueryBar).props().onSavedQuery); - expect(onChangedQueryRef).toEqual(wrapper.find(QueryBar).props().onChangedQuery); expect(onSubmitQueryRef).toEqual(wrapper.find(QueryBar).props().onSubmitQuery); }); }); 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 3b882c1e1bd14..034c4c3ab3757 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 @@ -6,11 +6,13 @@ import { isEmpty } from 'lodash/fp'; import React, { memo, useCallback, useState, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import { Subscription } from 'rxjs'; import deepEqual from 'fast-deep-equal'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { - IIndexPattern, Query, Filter, esFilters, @@ -18,8 +20,6 @@ import { SavedQuery, SavedQueryTimeFilter, } from '../../../../../../../../src/plugins/data/public'; - -import { BrowserFields } from '../../../../common/containers/source'; import { convertKueryToElasticSearchQuery } from '../../../../common/lib/keury'; import { KueryFilterQuery, KueryFilterQueryKind } from '../../../../common/store'; import { KqlMode } from '../../../../timelines/store/timeline/model'; @@ -28,24 +28,20 @@ import { DispatchUpdateReduxTime } from '../../../../common/components/super_dat import { QueryBar } from '../../../../common/components/query_bar'; import { DataProvider } from '../data_providers/data_provider'; import { buildGlobalQuery } from '../helpers'; +import { timelineActions } from '../../../store/timeline'; export interface QueryBarTimelineComponentProps { - applyKqlFilterQuery: (expression: string, kind: KueryFilterQueryKind) => void; - browserFields: BrowserFields; dataProviders: DataProvider[]; filters: Filter[]; filterManager: FilterManager; filterQuery: KueryFilterQuery; - filterQueryDraft: KueryFilterQuery; from: string; fromStr: string; kqlMode: KqlMode; - indexPattern: IIndexPattern; isRefreshPaused: boolean; refreshInterval: number; savedQueryId: string | null; setFilters: (filters: Filter[]) => void; - setKqlFilterQueryDraft: (expression: string, kind: KueryFilterQueryKind) => void; setSavedQueryId: (savedQueryId: string | null) => void; timelineId: string; to: string; @@ -60,21 +56,16 @@ const getNonDropAreaFilters = (filters: Filter[] = []) => export const QueryBarTimeline = memo( ({ - applyKqlFilterQuery, - browserFields, dataProviders, filters, filterManager, filterQuery, - filterQueryDraft, from, fromStr, kqlMode, - indexPattern, isRefreshPaused, savedQueryId, setFilters, - setKqlFilterQueryDraft, setSavedQueryId, refreshInterval, timelineId, @@ -82,14 +73,16 @@ export const QueryBarTimeline = memo( toStr, updateReduxTime, }) => { + const dispatch = useDispatch(); const [dateRangeFrom, setDateRangeFrom] = useState( fromStr != null ? fromStr : new Date(from).toISOString() ); const [dateRangeTo, setDateRangTo] = useState( toStr != null ? toStr : new Date(to).toISOString() ); + const { browserFields, indexPattern } = useSourcererScope(SourcererScopeName.timeline); - const [savedQuery, setSavedQuery] = useState(null); + const [savedQuery, setSavedQuery] = useState(undefined); const [filterQueryConverted, setFilterQueryConverted] = useState({ query: filterQuery != null ? filterQuery.expression : '', language: filterQuery != null ? filterQuery.kind : 'kuery', @@ -102,6 +95,23 @@ export const QueryBarTimeline = memo( ); const savedQueryServices = useSavedQueryServices(); + const applyKqlFilterQuery = useCallback( + (expression: string, kind) => + dispatch( + timelineActions.applyKqlFilterQuery({ + id: timelineId, + filterQuery: { + kuery: { + kind, + expression, + }, + serializedQuery: convertKueryToElasticSearchQuery(expression, indexPattern), + }, + }) + ), + [dispatch, indexPattern, timelineId] + ); + useEffect(() => { let isSubscribed = true; const subscriptions = new Subscription(); @@ -181,10 +191,10 @@ export const QueryBarTimeline = memo( }); } } catch (exc) { - setSavedQuery(null); + setSavedQuery(undefined); } } else if (isSubscribed) { - setSavedQuery(null); + setSavedQuery(undefined); } } setSavedQueryByServices(); @@ -194,23 +204,6 @@ export const QueryBarTimeline = memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, [savedQueryId]); - const onChangedQuery = useCallback( - (newQuery: Query) => { - if ( - filterQueryDraft == null || - (filterQueryDraft != null && filterQueryDraft.expression !== newQuery.query) || - filterQueryDraft.kind !== newQuery.language - ) { - setKqlFilterQueryDraft( - newQuery.query as string, - newQuery.language as KueryFilterQueryKind - ); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [filterQueryDraft] - ); - const onSubmitQuery = useCallback( (newQuery: Query, timefilter?: SavedQueryTimeFilter) => { if ( @@ -218,10 +211,6 @@ export const QueryBarTimeline = memo( (filterQuery != null && filterQuery.expression !== newQuery.query) || filterQuery.kind !== newQuery.language ) { - setKqlFilterQueryDraft( - newQuery.query as string, - newQuery.language as KueryFilterQueryKind - ); applyKqlFilterQuery(newQuery.query as string, newQuery.language as KueryFilterQueryKind); } if (timefilter != null) { @@ -242,7 +231,7 @@ export const QueryBarTimeline = memo( ); const onSavedQuery = useCallback( - (newSavedQuery: SavedQuery | null) => { + (newSavedQuery: SavedQuery | undefined) => { if (newSavedQuery != null) { if (newSavedQuery.id !== savedQueryId) { setSavedQueryId(newSavedQuery.id); @@ -292,10 +281,8 @@ export const QueryBarTimeline = memo( indexPattern={indexPattern} isRefreshPaused={isRefreshPaused} filterQuery={filterQueryConverted} - filterQueryDraft={filterQueryDraft} filterManager={filterManager} filters={queryBarFilters} - onChangedQuery={onChangedQuery} onSubmitQuery={onSubmitQuery} refreshInterval={refreshInterval} savedQuery={savedQuery} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..c726e92455f25 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -0,0 +1,290 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Timeline rendering renders correctly against snapshot 1`] = ` + +`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx similarity index 61% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx rename to x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 900699503a3bb..4019f46b8c07b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -8,44 +8,40 @@ import { shallow } from 'enzyme'; import React from 'react'; import useResizeObserver from 'use-resize-observer/polyfilled'; -import { mockBrowserFields } from '../../../common/containers/source/mock'; -import { Direction } from '../../../graphql/types'; -import { - defaultHeaders, - mockTimelineData, - mockIndexPattern, - mockIndexNames, -} from '../../../common/mock'; -import '../../../common/mock/match_media'; -import { TestProviders } from '../../../common/mock/test_providers'; - -import { TimelineComponent, Props as TimelineComponentProps } from './timeline'; -import { Sort } from './body/sort'; -import { mockDataProviders } from './data_providers/mock/mock_data_providers'; -import { useMountAppended } from '../../../common/utils/use_mount_appended'; -import { TimelineId, TimelineStatus, TimelineType } from '../../../../common/types/timeline'; -import { useTimelineEvents } from '../../containers/index'; -import { useTimelineEventsDetails } from '../../containers/details/index'; - -jest.mock('../../containers/index', () => ({ +import { Direction } from '../../../../graphql/types'; +import { defaultHeaders, mockTimelineData } from '../../../../common/mock'; +import '../../../../common/mock/match_media'; +import { TestProviders } from '../../../../common/mock/test_providers'; + +import { QueryTabContentComponent, Props as QueryTabContentComponentProps } from './index'; +import { Sort } from '../body/sort'; +import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; +import { useMountAppended } from '../../../../common/utils/use_mount_appended'; +import { TimelineId, TimelineStatus } from '../../../../../common/types/timeline'; +import { useTimelineEvents } from '../../../containers/index'; +import { useTimelineEventsDetails } from '../../../containers/details/index'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks'; + +jest.mock('../../../containers/index', () => ({ useTimelineEvents: jest.fn(), })); -jest.mock('../../containers/details/index', () => ({ +jest.mock('../../../containers/details/index', () => ({ useTimelineEventsDetails: jest.fn(), })); -jest.mock('./body/events/index', () => ({ +jest.mock('../body/events/index', () => ({ // eslint-disable-next-line react/display-name Events: () => <>, })); -jest.mock('../../../common/lib/kibana'); -jest.mock('./properties/properties_right'); + +jest.mock('../../../../common/containers/sourcerer'); + const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; jest.mock('use-resize-observer/polyfilled'); - mockUseResizeObserver.mockImplementation(() => ({})); -jest.mock('../../../common/lib/kibana', () => { - const originalModule = jest.requireActual('../../../common/lib/kibana'); +jest.mock('../../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../../common/lib/kibana'); return { ...originalModule, useKibana: jest.fn().mockReturnValue({ @@ -65,8 +61,9 @@ jest.mock('../../../common/lib/kibana', () => { useGetUserSavedObjectPermissions: jest.fn(), }; }); + describe('Timeline', () => { - let props = {} as TimelineComponentProps; + let props = {} as QueryTabContentComponentProps; const sort: Sort = { columnId: '@timestamp', sortDirection: Direction.desc, @@ -74,8 +71,6 @@ describe('Timeline', () => { const startDate = '2018-03-23T18:49:23.132Z'; const endDate = '2018-03-24T03:33:52.253Z'; - const indexPattern = mockIndexPattern; - const mount = useMountAppended(); beforeEach(() => { @@ -91,34 +86,27 @@ describe('Timeline', () => { ]); (useTimelineEventsDetails as jest.Mock).mockReturnValue([false, {}]); + (useSourcererScope as jest.Mock).mockReturnValue(mockSourcererScope); + props = { - browserFields: mockBrowserFields, columns: defaultHeaders, dataProviders: mockDataProviders, - docValueFields: [], end: endDate, + eventType: 'all', + showEventDetails: false, filters: [], - id: TimelineId.test, - indexNames: mockIndexNames, - indexPattern, + timelineId: TimelineId.test, isLive: false, - isSaving: false, itemsPerPage: 5, itemsPerPageOptions: [5, 10, 20], - kqlMode: 'search' as TimelineComponentProps['kqlMode'], + kqlMode: 'search' as QueryTabContentComponentProps['kqlMode'], kqlQueryExpression: '', - loadingSourcerer: false, - onChangeItemsPerPage: jest.fn(), - onClose: jest.fn(), - show: true, showCallOutUnauthorizedMsg: false, sort, start: startDate, status: TimelineStatus.active, - timelineType: TimelineType.default, timerangeKind: 'absolute', - toggleColumn: jest.fn(), - usersViewing: ['elastic'], + updateEventTypeAndIndexesName: jest.fn(), }; }); @@ -126,39 +114,27 @@ describe('Timeline', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( - + ); - expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('QueryTabContentComponent')).toMatchSnapshot(); }); test('it renders the timeline header', () => { const wrapper = mount( - + ); expect(wrapper.find('[data-test-subj="timelineHeader"]').exists()).toEqual(true); }); - test('it renders the title field', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="timeline-title"]').first().props().placeholder - ).toContain('Untitled timeline'); - }); - test('it renders the timeline table', () => { const wrapper = mount( - + ); @@ -166,9 +142,16 @@ describe('Timeline', () => { }); test('it does NOT render the timeline table when the source is loading', () => { + (useSourcererScope as jest.Mock).mockReturnValue({ + browserFields: {}, + docValueFields: [], + loading: true, + indexPattern: {}, + selectedPatterns: [], + }); const wrapper = mount( - + ); @@ -178,7 +161,7 @@ describe('Timeline', () => { test('it does NOT render the timeline table when start is empty', () => { const wrapper = mount( - + ); @@ -188,7 +171,7 @@ describe('Timeline', () => { test('it does NOT render the timeline table when end is empty', () => { const wrapper = mount( - + ); @@ -198,7 +181,7 @@ describe('Timeline', () => { test('it does NOT render the paging footer when you do NOT have any data providers', () => { const wrapper = mount( - + ); @@ -208,7 +191,7 @@ describe('Timeline', () => { it('it shows the timeline footer', () => { const wrapper = mount( - + ); 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 new file mode 100644 index 0000000000000..8186ee8b77628 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -0,0 +1,436 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiTabbedContent, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiSpacer, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { useState, useMemo, useEffect } from 'react'; +import styled from 'styled-components'; +import { Dispatch } from 'redux'; +import { connect, ConnectedProps } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; + +import { timelineActions, timelineSelectors } from '../../../store/timeline'; +import { Direction } from '../../../../../common/search_strategy'; +import { useTimelineEvents } from '../../../containers/index'; +import { useKibana } from '../../../../common/lib/kibana'; +import { defaultHeaders } from '../body/column_headers/default_headers'; +import { StatefulBody } from '../body'; +import { Footer, footerHeight } from '../footer'; +import { TimelineHeader } from '../header'; +import { combineQueries } from '../helpers'; +import { TimelineRefetch } from '../refetch_timeline'; +import { esQuery, FilterManager } from '../../../../../../../../src/plugins/data/public'; +import { useManageTimeline } from '../../manage_timeline'; +import { TimelineEventsType } 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, State } from '../../../../common/store'; +import { sourcererActions } from '../../../../common/store/sourcerer'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { TimelineModel } from '../../../../timelines/store/timeline/model'; +import { EventDetails } from '../event_details'; +import { TimelineDatePickerLock } from '../date_picker_lock'; + +const TimelineHeaderContainer = styled.div` + margin-top: 6px; + width: 100%; +`; + +TimelineHeaderContainer.displayName = 'TimelineHeaderContainer'; + +const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` + align-items: stretch; + box-shadow: none; + display: flex; + flex-direction: column; + padding: 0; +`; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + overflow-y: hidden; + flex: 1; + + .euiFlyoutBody__overflow { + overflow: hidden; + mask-image: none; + } + + .euiFlyoutBody__overflowContent { + padding: 0; + height: 100%; + display: flex; + } +`; + +const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` + background: none; + padding: 0; +`; + +const FullWidthFlexGroup = styled(EuiFlexGroup)` + margin: 0; + width: 100%; + overflow: hidden; +`; + +const ScrollableFlexItem = styled(EuiFlexItem)` + overflow: hidden; +`; + +const DatePicker = styled(EuiFlexItem)` + .euiSuperDatePicker__flexWrapper { + max-width: none; + width: auto; + } +`; + +DatePicker.displayName = 'DatePicker'; + +const VerticalRule = styled.div` + width: 2px; + height: 100%; + background: ${({ theme }) => theme.eui.euiColorLightShade}; +`; + +VerticalRule.displayName = 'VerticalRule'; + +const StyledEuiTabbedContent = styled(EuiTabbedContent)` + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + > [role='tabpanel'] { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + } +`; + +StyledEuiTabbedContent.displayName = 'StyledEuiTabbedContent'; + +const isTimerangeSame = (prevProps: Props, nextProps: Props) => + prevProps.end === nextProps.end && + prevProps.start === nextProps.start && + prevProps.timerangeKind === nextProps.timerangeKind; + +interface OwnProps { + timelineId: string; +} + +export type Props = OwnProps & PropsFromRedux; + +export const QueryTabContentComponent: React.FC = ({ + columns, + dataProviders, + end, + eventType, + filters, + timelineId, + isLive, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + kqlQueryExpression, + showCallOutUnauthorizedMsg, + showEventDetails, + start, + status, + sort, + timerangeKind, + updateEventTypeAndIndexesName, +}) => { + const [showEventDetailsColumn, setShowEventDetailsColumn] = useState(false); + + useEffect(() => { + // it should changed only once to true and then stay visible till the component umount + setShowEventDetailsColumn((current) => { + if (showEventDetails && !current) { + return true; + } + return current; + }); + }, [showEventDetails]); + + const { + browserFields, + docValueFields, + loading: loadingSourcerer, + indexPattern, + selectedPatterns, + } = useSourcererScope(SourcererScopeName.timeline); + + const { uiSettings } = useKibana().services; + const [filterManager] = useState(new FilterManager(uiSettings)); + const esQueryConfig = useMemo(() => esQuery.getEsQueryConfig(uiSettings), [uiSettings]); + const kqlQuery = useMemo(() => ({ query: kqlQueryExpression, language: 'kuery' }), [ + kqlQueryExpression, + ]); + const combinedQueries = useMemo( + () => + combineQueries({ + config: esQueryConfig, + dataProviders, + indexPattern, + browserFields, + filters, + kqlQuery, + kqlMode, + }), + [browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery] + ); + + const canQueryTimeline = useMemo( + () => + combinedQueries != null && + loadingSourcerer != null && + !loadingSourcerer && + !isEmpty(start) && + !isEmpty(end), + [loadingSourcerer, combinedQueries, start, end] + ); + + const timelineQueryFields = useMemo(() => { + const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; + const columnFields = columnsHeader.map((c) => c.id); + + return [...columnFields, ...requiredFieldsForActions]; + }, [columns]); + + const timelineQuerySortField = useMemo( + () => ({ + field: sort.columnId, + direction: sort.sortDirection as Direction, + }), + [sort.columnId, sort.sortDirection] + ); + + const { initializeTimeline, setIsTimelineLoading } = useManageTimeline(); + useEffect(() => { + initializeTimeline({ + filterManager, + id: timelineId, + }); + }, [initializeTimeline, filterManager, timelineId]); + + const [ + isQueryLoading, + { events, inspect, totalCount, pageInfo, loadPage, updatedAt, refetch }, + ] = useTimelineEvents({ + docValueFields, + endDate: end, + id: timelineId, + indexNames: selectedPatterns, + fields: timelineQueryFields, + limit: itemsPerPage, + filterQuery: combinedQueries?.filterQuery ?? '', + startDate: start, + skip: !canQueryTimeline, + sort: timelineQuerySortField, + timerangeKind, + }); + + useEffect(() => { + setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer }); + }, [loadingSourcerer, timelineId, isQueryLoading, setIsTimelineLoading]); + + return ( + <> + + + + + + + + + + + + +
+ + + +
+ + + +
+ {canQueryTimeline ? ( + + + + + +
+ + + ) : null} + + {showEventDetailsColumn && ( + <> + + + + + + )} + + + ); +}; + +const makeMapStateToProps = () => { + const getShowCallOutUnauthorizedMsg = timelineSelectors.getShowCallOutUnauthorizedMsg(); + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + const getKqlQueryTimeline = timelineSelectors.getKqlFilterQuerySelector(); + const getInputsTimeline = inputsSelectors.getTimelineSelector(); + const mapStateToProps = (state: State, { timelineId }: OwnProps) => { + const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; + const input: inputsModel.InputsRange = getInputsTimeline(state); + const { + columns, + dataProviders, + eventType, + expandedEvent, + filters, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + sort, + status, + timelineType, + } = timeline; + const kqlQueryTimeline = getKqlQueryTimeline(state, timelineId)!; + const timelineFilter = kqlMode === 'filter' ? filters || [] : []; + + // return events on empty search + const kqlQueryExpression = + isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template' + ? ' ' + : kqlQueryTimeline; + return { + columns, + dataProviders, + eventType: eventType ?? 'raw', + end: input.timerange.to, + filters: timelineFilter, + timelineId, + isLive: input.policy.kind === 'interval', + itemsPerPage, + itemsPerPageOptions, + kqlMode, + kqlQueryExpression, + showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), + showEventDetails: !!expandedEvent.eventId, + sort, + start: input.timerange.from, + status, + timerangeKind: input.timerange.kind, + }; + }; + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ + updateEventTypeAndIndexesName: (newEventType: TimelineEventsType, newIndexNames: string[]) => { + dispatch(timelineActions.updateEventType({ id: timelineId, eventType: newEventType })); + dispatch(timelineActions.updateIndexNames({ id: timelineId, indexNames: newIndexNames })); + dispatch( + sourcererActions.setSelectedIndexPatterns({ + id: SourcererScopeName.timeline, + selectedPatterns: newIndexNames, + }) + ); + }, +}); + +const connector = connect(makeMapStateToProps, mapDispatchToProps); + +type PropsFromRedux = ConnectedProps; + +const QueryTabContent = connector( + React.memo( + QueryTabContentComponent, + (prevProps, nextProps) => + isTimerangeSame(prevProps, nextProps) && + prevProps.eventType === nextProps.eventType && + prevProps.isLive === nextProps.isLive && + prevProps.itemsPerPage === nextProps.itemsPerPage && + prevProps.kqlMode === nextProps.kqlMode && + prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && + prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && + prevProps.showEventDetails === nextProps.showEventDetails && + prevProps.status === nextProps.status && + prevProps.timelineId === nextProps.timelineId && + prevProps.updateEventTypeAndIndexesName === nextProps.updateEventTypeAndIndexesName && + deepEqual(prevProps.columns, nextProps.columns) && + deepEqual(prevProps.dataProviders, nextProps.dataProviders) && + deepEqual(prevProps.filters, nextProps.filters) && + deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && + deepEqual(prevProps.sort, nextProps.sort) + ) +); + +// eslint-disable-next-line import/no-default-export +export { QueryTabContent as default }; 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 166705128ce02..680a506c58258 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 @@ -10,33 +10,21 @@ import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; import deepEqual from 'fast-deep-equal'; +import { Filter, FilterManager } from '../../../../../../../../src/plugins/data/public'; import { - Filter, - FilterManager, - IIndexPattern, -} from '../../../../../../../../src/plugins/data/public'; -import { BrowserFields } from '../../../../common/containers/source'; -import { convertKueryToElasticSearchQuery } from '../../../../common/lib/keury'; -import { - KueryFilterQuery, SerializedFilterQuery, State, inputsModel, inputsSelectors, } from '../../../../common/store'; -import { TimelineEventsType } from '../../../../../common/types/timeline'; 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 { SourcererScopeName } from '../../../../common/store/sourcerer/model'; -import { sourcererActions } from '../../../../common/store/sourcerer'; interface OwnProps { - browserFields: BrowserFields; filterManager: FilterManager; - indexPattern: IIndexPattern; timelineId: string; } @@ -44,58 +32,24 @@ type Props = OwnProps & PropsFromRedux; const StatefulSearchOrFilterComponent = React.memo( ({ - applyKqlFilterQuery, - browserFields, dataProviders, - eventType, filters, filterManager, filterQuery, - filterQueryDraft, from, fromStr, - indexPattern, isRefreshPaused, kqlMode, refreshInterval, savedQueryId, setFilters, - setKqlFilterQueryDraft, setSavedQueryId, timelineId, to, toStr, - updateEventTypeAndIndexesName, updateKqlMode, updateReduxTime, }) => { - const applyFilterQueryFromKueryExpression = useCallback( - (expression: string, kind) => - applyKqlFilterQuery({ - id: timelineId, - filterQuery: { - kuery: { - kind, - expression, - }, - serializedQuery: convertKueryToElasticSearchQuery(expression, indexPattern), - }, - }), - [applyKqlFilterQuery, indexPattern, timelineId] - ); - - const setFilterQueryDraftFromKueryExpression = useCallback( - (expression: string, kind) => - setKqlFilterQueryDraft({ - id: timelineId, - filterQueryDraft: { - kind, - expression, - }, - }), - [timelineId, setKqlFilterQueryDraft] - ); - const setFiltersInTimeline = useCallback( (newFilters: Filter[]) => setFilters({ @@ -114,40 +68,23 @@ const StatefulSearchOrFilterComponent = React.memo( [timelineId, setSavedQueryId] ); - const handleUpdateEventTypeAndIndexesName = useCallback( - (newEventType: TimelineEventsType, indexNames: string[]) => - updateEventTypeAndIndexesName({ - id: timelineId, - eventType: newEventType, - indexNames, - }), - [timelineId, updateEventTypeAndIndexesName] - ); - return ( @@ -155,7 +92,6 @@ const StatefulSearchOrFilterComponent = React.memo( }, (prevProps, nextProps) => { return ( - prevProps.eventType === nextProps.eventType && prevProps.filterManager === nextProps.filterManager && prevProps.from === nextProps.from && prevProps.fromStr === nextProps.fromStr && @@ -164,12 +100,9 @@ const StatefulSearchOrFilterComponent = React.memo( prevProps.isRefreshPaused === nextProps.isRefreshPaused && prevProps.refreshInterval === nextProps.refreshInterval && prevProps.timelineId === nextProps.timelineId && - deepEqual(prevProps.browserFields, nextProps.browserFields) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) && deepEqual(prevProps.filters, nextProps.filters) && deepEqual(prevProps.filterQuery, nextProps.filterQuery) && - deepEqual(prevProps.filterQueryDraft, nextProps.filterQueryDraft) && - deepEqual(prevProps.indexPattern, nextProps.indexPattern) && deepEqual(prevProps.kqlMode, nextProps.kqlMode) && deepEqual(prevProps.savedQueryId, nextProps.savedQueryId) && deepEqual(prevProps.timelineId, nextProps.timelineId) @@ -180,7 +113,6 @@ StatefulSearchOrFilterComponent.displayName = 'StatefulSearchOrFilterComponent'; const makeMapStateToProps = () => { const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const getKqlFilterQueryDraft = timelineSelectors.getKqlFilterQueryDraftSelector(); const getKqlFilterQuery = timelineSelectors.getKqlFilterKuerySelector(); const getInputsTimeline = inputsSelectors.getTimelineSelector(); const getInputsPolicy = inputsSelectors.getTimelinePolicySelector(); @@ -190,9 +122,7 @@ const makeMapStateToProps = () => { const policy: inputsModel.Policy = getInputsPolicy(state); return { dataProviders: timeline.dataProviders, - eventType: timeline.eventType ?? 'raw', filterQuery: getKqlFilterQuery(state, timelineId)!, - filterQueryDraft: getKqlFilterQueryDraft(state, timelineId)!, filters: timeline.filters!, from: input.timerange.from, fromStr: input.timerange.fromStr!, @@ -215,39 +145,8 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ filterQuery, }) ), - updateEventTypeAndIndexesName: ({ - id, - eventType, - indexNames, - }: { - id: string; - eventType: TimelineEventsType; - indexNames: string[]; - }) => { - dispatch(timelineActions.updateEventType({ id, eventType })); - dispatch(timelineActions.updateIndexNames({ id, indexNames })); - dispatch( - sourcererActions.setSelectedIndexPatterns({ - id: SourcererScopeName.timeline, - selectedPatterns: indexNames, - }) - ); - }, updateKqlMode: ({ id, kqlMode }: { id: string; kqlMode: KqlMode }) => dispatch(timelineActions.updateKqlMode({ id, kqlMode })), - setKqlFilterQueryDraft: ({ - id, - filterQueryDraft, - }: { - id: string; - filterQueryDraft: KueryFilterQuery; - }) => - dispatch( - timelineActions.setKqlFilterQueryDraft({ - id, - filterQueryDraft, - }) - ), setSavedQueryId: ({ id, savedQueryId }: { id: string; savedQueryId: string | null }) => dispatch(timelineActions.setSavedQueryId({ id, savedQueryId })), setFilters: ({ id, filters }: { id: string; filters: Filter[] }) => 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 32a516497f607..fb326cf58a513 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 @@ -8,14 +8,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiSuperSelect, EuiToolTip } from '@elastic/ import React, { useCallback } from 'react'; import styled, { createGlobalStyle } from 'styled-components'; -import { - Filter, - FilterManager, - IIndexPattern, -} from '../../../../../../../../src/plugins/data/public'; -import { BrowserFields } from '../../../../common/containers/source'; -import { KueryFilterQuery, KueryFilterQueryKind } from '../../../../common/store'; -import { TimelineEventsType } from '../../../../../common/types/timeline'; +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 { DataProvider } from '../data_providers/data_provider'; @@ -23,7 +17,6 @@ import { QueryBarTimeline } from '../query_bar'; import { options } from './helpers'; import * as i18n from './translations'; -import { PickEventType } from './pick_events'; const timelineSelectModeItemsClassName = 'timelineSelectModeItemsClassName'; const searchOrFilterPopoverClassName = 'searchOrFilterPopover'; @@ -45,29 +38,22 @@ const SearchOrFilterGlobalStyle = createGlobalStyle` `; interface Props { - applyKqlFilterQuery: (expression: string, kind: KueryFilterQueryKind) => void; - browserFields: BrowserFields; dataProviders: DataProvider[]; - eventType: TimelineEventsType; filterManager: FilterManager; filterQuery: KueryFilterQuery; - filterQueryDraft: KueryFilterQuery; from: string; fromStr: string; - indexPattern: IIndexPattern; isRefreshPaused: boolean; kqlMode: KqlMode; timelineId: string; updateKqlMode: ({ id, kqlMode }: { id: string; kqlMode: KqlMode }) => void; refreshInterval: number; setFilters: (filters: Filter[]) => void; - setKqlFilterQueryDraft: (expression: string, kind: KueryFilterQueryKind) => void; setSavedQueryId: (savedQueryId: string | null) => void; filters: Filter[]; savedQueryId: string | null; to: string; toStr: string; - updateEventTypeAndIndexesName: (eventType: TimelineEventsType, indexNames: string[]) => void; updateReduxTime: DispatchUpdateReduxTime; } @@ -94,16 +80,11 @@ ModeFlexItem.displayName = 'ModeFlexItem'; export const SearchOrFilter = React.memo( ({ - applyKqlFilterQuery, - browserFields, dataProviders, - eventType, - indexPattern, isRefreshPaused, filters, filterManager, filterQuery, - filterQueryDraft, from, fromStr, kqlMode, @@ -111,11 +92,9 @@ export const SearchOrFilter = React.memo( refreshInterval, savedQueryId, setFilters, - setKqlFilterQueryDraft, setSavedQueryId, to, toStr, - updateEventTypeAndIndexesName, updateKqlMode, updateReduxTime, }) => { @@ -144,22 +123,17 @@ export const SearchOrFilter = React.memo( ( updateReduxTime={updateReduxTime} /> - - - diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/selectors.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/selectors.tsx index 2fdcf7a0eb0c1..5697507e0650c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/selectors.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/selectors.tsx @@ -21,13 +21,13 @@ export interface SourcererScopeSelector { export const getSourcererScopeSelector = () => { const getkibanaIndexPatternsSelector = sourcererSelectors.kibanaIndexPatternsSelector(); - const getScopesSelector = sourcererSelectors.scopesSelector(); + const getScopeIdSelector = sourcererSelectors.scopeIdSelector(); const getConfigIndexPatternsSelector = sourcererSelectors.configIndexPatternsSelector(); const getSignalIndexNameSelector = sourcererSelectors.signalIndexNameSelector(); const mapStateToProps = (state: State, scopeId: SourcererScopeName): SourcererScopeSelector => { const kibanaIndexPatterns = getkibanaIndexPatternsSelector(state); - const scope = getScopesSelector(state)[scopeId]; + const scope = getScopeIdSelector(state, scopeId); const configIndexPatterns = getConfigIndexPatternsSelector(state); const signalIndexName = getSignalIndexNameSelector(state); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index e4c49ce197c2a..9f9940203960c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -25,12 +25,12 @@ export const TimelineBodyGlobalStyle = createGlobalStyle` export const TimelineBody = styled.div.attrs(({ className = '' }) => ({ className: `${SELECTOR_TIMELINE_BODY_CLASS_NAME} ${className}`, -}))<{ bodyHeight?: number; visible: boolean }>` - height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; +}))` + height: auto; overflow: auto; scrollbar-width: thin; flex: 1; - display: ${({ visible }) => (visible ? 'block' : 'none')}; + display: block; &::-webkit-scrollbar { height: ${({ theme }) => theme.eui.euiScrollBar}; 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 new file mode 100644 index 0000000000000..14c6275e792c5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiLoadingContent, EuiTabs, EuiTab } from '@elastic/eui'; +import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; + +import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { timelineActions } from '../../../store/timeline'; +import { TimelineTabs } from '../../../store/timeline/model'; +import { getActiveTabSelector } from './selectors'; +import * as i18n from './translations'; + +const HideShowContainer = styled.div.attrs<{ $isVisible: boolean }>(({ $isVisible = false }) => ({ + style: { + display: $isVisible ? 'flex' : 'none', + }, +}))<{ $isVisible: boolean }>` + flex: 1; + overflow: hidden; +`; + +const QueryTabContent = lazy(() => import('../query_tab_content')); +const GraphTabContent = lazy(() => import('../graph_tab_content')); +const NotesTabContent = lazy(() => import('../notes_tab_content')); + +interface BasicTimelineTab { + timelineId: string; + graphEventId?: string; +} + +const QueryTab: React.FC = memo(({ timelineId }) => ( + }> + + +)); +QueryTab.displayName = 'QueryTab'; + +const GraphTab: React.FC = memo(({ timelineId }) => ( + }> + + +)); +GraphTab.displayName = 'GraphTab'; + +const NotesTab: React.FC = memo(({ timelineId }) => ( + }> + + +)); +NotesTab.displayName = 'NotesTab'; + +const PinnedTab: React.FC = memo(({ timelineId }) => ( + }> + + +)); +PinnedTab.displayName = 'PinnedTab'; + +const ActiveTimelineTab: React.FC = memo( + ({ activeTimelineTab, timelineId }) => { + const getTab = useCallback( + (tab: TimelineTabs) => { + switch (tab) { + case TimelineTabs.graph: + return ; + case TimelineTabs.notes: + return ; + case TimelineTabs.pinned: + return ; + default: + return null; + } + }, + [timelineId] + ); + + /* Future developer -> why are we doing that + * It is really expansive to re-render the QueryTab because the drag/drop + * Therefore, we are only hiding its dom when switching to another tab + * to avoid mounting/un-mounting === re-render + */ + return ( + <> + + + + + {activeTimelineTab !== TimelineTabs.query && getTab(activeTimelineTab)} + + + ); + } +); +ActiveTimelineTab.displayName = 'ActiveTimelineTab'; + +const TabsContentComponent: React.FC = ({ timelineId, graphEventId }) => { + const dispatch = useDispatch(); + const getActiveTab = useMemo(() => getActiveTabSelector(), []); + const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId)); + + const setQueryAsActiveTab = useCallback( + () => + dispatch( + timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query }) + ), + [dispatch, timelineId] + ); + + const setGraphAsActiveTab = useCallback( + () => + dispatch( + timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph }) + ), + [dispatch, timelineId] + ); + + const setNotesAsActiveTab = useCallback( + () => + dispatch( + timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.notes }) + ), + [dispatch, timelineId] + ); + + const setPinnedAsActiveTab = useCallback( + () => + dispatch( + timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.pinned }) + ), + [dispatch, timelineId] + ); + + useEffect(() => { + if (!graphEventId && activeTab === TimelineTabs.graph) { + setQueryAsActiveTab(); + } + }, [activeTab, graphEventId, setQueryAsActiveTab]); + + return ( + <> + + + {i18n.QUERY_TAB} + + + {i18n.GRAPH_TAB} + + + {i18n.NOTES_TAB} + + + {i18n.PINNED_TAB} + + + + + ); +}; + +export const TabsContent = memo(TabsContentComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts new file mode 100644 index 0000000000000..c140f2f6b8181 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createSelector } from 'reselect'; +import { TimelineTabs } from '../../../store/timeline/model'; +import { selectTimeline } from '../../../store/timeline/selectors'; + +export const getActiveTabSelector = () => + createSelector(selectTimeline, (timeline) => timeline?.activeTab ?? TimelineTabs.query); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts new file mode 100644 index 0000000000000..0c1942f8d9cda --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const QUERY_TAB = i18n.translate( + 'xpack.securitySolution.timeline.tabs.queyTabTimelineTitle', + { + defaultMessage: 'Query', + } +); + +export const GRAPH_TAB = i18n.translate( + 'xpack.securitySolution.timeline.tabs.graphTabTimelineTitle', + { + defaultMessage: 'Graph', + } +); + +export const NOTES_TAB = i18n.translate( + 'xpack.securitySolution.timeline.tabs.notesTabTimelineTitle', + { + defaultMessage: 'Notes', + } +); + +export const PINNED_TAB = i18n.translate( + 'xpack.securitySolution.timeline.tabs.pinnedTabTimelineTitle', + { + defaultMessage: 'Pinned', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx deleted file mode 100644 index d5148eeb3655f..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ /dev/null @@ -1,350 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiFlexGroup, - EuiFlexItem, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiProgress, -} from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; -import React, { useState, useMemo, useEffect } from 'react'; -import styled from 'styled-components'; - -import { FlyoutHeaderWithCloseButton } from '../flyout/header_with_close_button'; -import { BrowserFields, DocValueFields } from '../../../common/containers/source'; -import { Direction } from '../../../../common/search_strategy'; -import { useTimelineEvents } from '../../containers/index'; -import { useKibana } from '../../../common/lib/kibana'; -import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model'; -import { defaultHeaders } from './body/column_headers/default_headers'; -import { Sort } from './body/sort'; -import { StatefulBody } from './body/stateful_body'; -import { DataProvider } from './data_providers/data_provider'; -import { OnChangeItemsPerPage } from './events'; -import { TimelineKqlFetch } from './fetch_kql_timeline'; -import { Footer, footerHeight } from './footer'; -import { TimelineHeader } from './header'; -import { combineQueries } from './helpers'; -import { TimelineRefetch } from './refetch_timeline'; -import { TIMELINE_TEMPLATE } from './translations'; -import { - esQuery, - Filter, - FilterManager, - IIndexPattern, -} from '../../../../../../../src/plugins/data/public'; -import { useManageTimeline } from '../manage_timeline'; -import { TimelineType, TimelineStatusLiteral } from '../../../../common/types/timeline'; -import { requiredFieldsForActions } from '../../../detections/components/alerts_table/default_config'; -import { GraphOverlay } from '../graph_overlay'; -import { EventDetails } from './event_details'; - -const TimelineContainer = styled.div` - height: 100%; - display: flex; - flex-direction: column; - position: relative; -`; - -const TimelineHeaderContainer = styled.div` - margin-top: 6px; - width: 100%; -`; - -TimelineHeaderContainer.displayName = 'TimelineHeaderContainer'; - -const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` - align-items: center; - box-shadow: none; - display: flex; - flex-direction: column; - padding: 14px 10px 0 12px; -`; - -const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` - overflow-y: hidden; - flex: 1; - - .euiFlyoutBody__overflow { - overflow: hidden; - mask-image: none; - } - - .euiFlyoutBody__overflowContent { - padding: 0 10px 0 12px; - height: 100%; - display: flex; - } -`; - -const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` - background: none; - padding: 0 10px 5px 12px; -`; - -const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` - width: 100%; - overflow: hidden; - display: ${({ $visible }) => ($visible ? 'flex' : 'none')}; -`; - -const ScrollableFlexItem = styled(EuiFlexItem)` - overflow: auto; -`; - -const TimelineTemplateBadge = styled.div` - background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; - color: #fff; - padding: 10px 15px; - font-size: 0.8em; -`; - -const VerticalRule = styled.div` - width: 2px; - height: 100%; - background: ${({ theme }) => theme.eui.euiColorLightShade}; -`; - -export interface Props { - browserFields: BrowserFields; - columns: ColumnHeaderOptions[]; - dataProviders: DataProvider[]; - docValueFields: DocValueFields[]; - end: string; - filters: Filter[]; - graphEventId?: string; - id: string; - indexNames: string[]; - indexPattern: IIndexPattern; - isLive: boolean; - isSaving: boolean; - itemsPerPage: number; - itemsPerPageOptions: number[]; - kqlMode: KqlMode; - kqlQueryExpression: string; - loadingSourcerer: boolean; - onChangeItemsPerPage: OnChangeItemsPerPage; - onClose: () => void; - show: boolean; - showCallOutUnauthorizedMsg: boolean; - sort: Sort; - start: string; - status: TimelineStatusLiteral; - timelineType: TimelineType; - timerangeKind: 'absolute' | 'relative'; - toggleColumn: (column: ColumnHeaderOptions) => void; - usersViewing: string[]; -} - -/** The parent Timeline component */ -export const TimelineComponent: React.FC = ({ - browserFields, - columns, - dataProviders, - docValueFields, - end, - filters, - graphEventId, - id, - indexPattern, - indexNames, - isLive, - loadingSourcerer, - isSaving, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - kqlQueryExpression, - onChangeItemsPerPage, - onClose, - show, - showCallOutUnauthorizedMsg, - start, - status, - sort, - timelineType, - timerangeKind, - toggleColumn, - usersViewing, -}) => { - const kibana = useKibana(); - const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); - const esQueryConfig = useMemo(() => esQuery.getEsQueryConfig(kibana.services.uiSettings), [ - kibana.services.uiSettings, - ]); - const kqlQuery = useMemo(() => ({ query: kqlQueryExpression, language: 'kuery' }), [ - kqlQueryExpression, - ]); - const combinedQueries = useMemo( - () => - combineQueries({ - config: esQueryConfig, - dataProviders, - indexPattern, - browserFields, - filters, - kqlQuery, - kqlMode, - }), - [browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery] - ); - - const canQueryTimeline = useMemo( - () => - combinedQueries != null && - loadingSourcerer != null && - !loadingSourcerer && - !isEmpty(start) && - !isEmpty(end), - [loadingSourcerer, combinedQueries, start, end] - ); - const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; - const timelineQueryFields = useMemo(() => { - const columnFields = columnsHeader.map((c) => c.id); - return [...columnFields, ...requiredFieldsForActions]; - }, [columnsHeader]); - const timelineQuerySortField = useMemo( - () => ({ - field: sort.columnId, - direction: sort.sortDirection as Direction, - }), - [sort.columnId, sort.sortDirection] - ); - const [isQueryLoading, setIsQueryLoading] = useState(false); - const { initializeTimeline, setIsTimelineLoading } = useManageTimeline(); - useEffect(() => { - initializeTimeline({ - filterManager, - id, - }); - }, [initializeTimeline, filterManager, id]); - - const [ - loading, - { events, inspect, totalCount, pageInfo, loadPage, updatedAt, refetch }, - ] = useTimelineEvents({ - docValueFields, - endDate: end, - id, - indexNames, - fields: timelineQueryFields, - limit: itemsPerPage, - filterQuery: combinedQueries?.filterQuery ?? '', - startDate: start, - skip: !canQueryTimeline, - sort: timelineQuerySortField, - timerangeKind, - }); - - useEffect(() => { - setIsTimelineLoading({ id, isLoading: isQueryLoading || loadingSourcerer }); - }, [loadingSourcerer, id, isQueryLoading, setIsTimelineLoading]); - - useEffect(() => { - setIsQueryLoading(loading); - }, [loading]); - - return ( - - {isSaving && } - {timelineType === TimelineType.template && ( - {TIMELINE_TEMPLATE} - )} - - - - - - - - {canQueryTimeline ? ( - <> - - {graphEventId && ( - - )} - - - - - - -
- - - - - - - - - ) : null} - - ); -}; - -export const Timeline = React.memo(TimelineComponent); 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 a431b86047d59..8f1644550d147 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 @@ -50,7 +50,7 @@ export const useTimelineEventsDetails = ({ const timelineDetailsSearch = useCallback( (request: TimelineEventsDetailsRequestOptions | null) => { - if (request == null) { + if (request == null || skip) { return; } @@ -97,7 +97,7 @@ export const useTimelineEventsDetails = ({ abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, notifications.toasts, skip] ); useEffect(() => { @@ -109,12 +109,12 @@ export const useTimelineEventsDetails = ({ eventId, factoryQueryType: TimelineEventsQueries.details, }; - if (!skip && !deepEqual(prevRequest, myRequest)) { + if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [docValueFields, eventId, indexName, skip]); + }, [docValueFields, eventId, indexName]); useEffect(() => { timelineDetailsSearch(timelineDetailsRequest); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx index a5f8300546b5b..1948c77a488ef 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx @@ -148,7 +148,7 @@ describe('useTimelineEvents', () => { // useEffect on params request await waitForNextUpdate(); - expect(mockSearch).toHaveBeenCalledTimes(1); + expect(mockSearch).toHaveBeenCalledTimes(2); expect(result.current).toEqual([ false, { @@ -190,7 +190,7 @@ describe('useTimelineEvents', () => { }, ]); - expect(mockSearch).toHaveBeenCalledTimes(1); + expect(mockSearch).toHaveBeenCalledTimes(2); expect(result.current).toEqual([ false, 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 2465d0a536482..a168e814208e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -101,26 +101,7 @@ export const useTimelineEvents = ({ id === TimelineId.active ? activeTimeline.getActivePage() : 0 ); const [timelineRequest, setTimelineRequest] = useState( - !skip - ? { - fields: [], - fieldRequested: fields, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - pagination: { - activePage, - querySize: limit, - }, - sort, - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: TimelineEventsQueries.all, - } - : null + null ); const prevTimelineRequest = usePreviousRequest(timelineRequest); @@ -171,7 +152,7 @@ export const useTimelineEvents = ({ const timelineSearch = useCallback( (request: TimelineEventsAllRequestOptions | null) => { - if (request == null || pageName === '') { + if (request == null || pageName === '' || skip) { return; } let didCancel = false; @@ -266,11 +247,11 @@ export const useTimelineEvents = ({ abortCtrl.current.abort(); }; }, - [data.search, id, notifications.toasts, pageName, refetchGrid, wrappedLoadPage] + [data.search, id, notifications.toasts, pageName, refetchGrid, skip, wrappedLoadPage] ); useEffect(() => { - if (skip || skipQueryForDetectionsPage(id, indexNames) || indexNames.length === 0) { + if (skipQueryForDetectionsPage(id, indexNames) || indexNames.length === 0) { return; } @@ -324,11 +305,7 @@ export const useTimelineEvents = ({ activeTimeline.setActivePage(newActivePage); } } - if ( - !skip && - !skipQueryForDetectionsPage(id, indexNames) && - !deepEqual(prevRequest, currentRequest) - ) { + if (!skipQueryForDetectionsPage(id, indexNames) && !deepEqual(prevRequest, currentRequest)) { return currentRequest; } return prevRequest; @@ -344,7 +321,6 @@ export const useTimelineEvents = ({ limit, startDate, sort, - skip, fields, ]); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index 136240939e7a3..364c97b033754 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -14,7 +14,6 @@ import { HeaderPage } from '../../common/components/header_page'; import { WrapperPage } from '../../common/components/wrapper_page'; import { useKibana } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { useApolloClient } from '../../common/utils/apollo_context'; import { OverviewEmpty } from '../../overview/components/overview_empty'; import { StatefulOpenTimeline } from '../components/open_timeline'; import { NEW_TEMPLATE_TIMELINE } from '../components/timeline/properties/translations'; @@ -38,7 +37,6 @@ export const TimelinesPageComponent: React.FC = () => { }, [setImportDataModalToggle]); const { indicesExist } = useSourcererScope(); - const apolloClient = useApolloClient(); const capabilitiesCanUserCRUD: boolean = !!useKibana().services.application.capabilities.siem .crud; @@ -82,7 +80,6 @@ export const TimelinesPageComponent: React.FC = () => { ( +const TimelinesRoutesComponent = () => ( - } /> - } /> + + + + + + ); + +export const TimelinesRoutes = React.memo(TimelinesRoutesComponent); 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 c2fff49afdcbf..b8dfa698a9307 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 @@ -13,9 +13,9 @@ import { DataProviderType, QueryOperator, } from '../../../timelines/components/timeline/data_providers/data_provider'; -import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/types'; +import { SerializedFilterQuery } from '../../../common/store/types'; -import { KqlMode, TimelineModel, ColumnHeaderOptions } from './model'; +import { KqlMode, TimelineModel, ColumnHeaderOptions, TimelineTabs } from './model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, @@ -70,7 +70,6 @@ export interface TimelineInput { indexNames: string[]; kqlQuery?: { filterQuery: SerializedFilterQuery | null; - filterQueryDraft: KueryFilterQuery | null; }; show?: boolean; sort?: Sort; @@ -181,11 +180,6 @@ export const updateDescription = actionCreator<{ export const updateKqlMode = actionCreator<{ id: string; kqlMode: KqlMode }>('UPDATE_KQL_MODE'); -export const setKqlFilterQueryDraft = actionCreator<{ - id: string; - filterQueryDraft: KueryFilterQuery; -}>('SET_KQL_FILTER_QUERY_DRAFT'); - export const applyKqlFilterQuery = actionCreator<{ id: string; filterQuery: SerializedFilterQuery; @@ -285,3 +279,8 @@ export const updateIndexNames = actionCreator<{ id: string; indexNames: string[]; }>('UPDATE_INDEXES_NAME'); + +export const setActiveTabTimeline = actionCreator<{ + id: string; + activeTab: TimelineTabs; +}>('SET_ACTIVE_TAB_TIMELINE'); 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 39174c9092af5..84551de9ec628 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 @@ -9,12 +9,13 @@ import { TimelineType, TimelineStatus } from '../../../../common/types/timeline' import { Direction } from '../../../graphql/types'; 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 { SubsetTimelineModel, TimelineModel, TimelineTabs } from './model'; // 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); export const timelineDefaults: SubsetTimelineModel & Pick = { + activeTab: TimelineTabs.query, columns: defaultHeaders, dataProviders: [], dateRange: { start, end }, @@ -38,7 +39,6 @@ export const timelineDefaults: SubsetTimelineModel & Pick { describe('#convertTimelineAsInput ', () => { test('should return a TimelineInput instead of TimelineModel ', () => { const timelineModel: TimelineModel = { + activeTab: TimelineTabs.query, columns: [ { columnHeaderType: 'not-filtered', @@ -134,7 +135,6 @@ describe('Epic Timeline', () => { serializedQuery: '{"bool":{"should":[{"match_phrase":{"endgame.user_name":"zeus"}}],"minimum_should_match":1}}', }, - filterQueryDraft: { kind: 'kuery', expression: 'endgame.user_name : "zeus" ' }, }, loadingEventIds: [], title: 'saved', 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 d50de33412175..5b16a0d021a0c 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 @@ -284,6 +284,7 @@ export const createTimelineEpic = (): Epic< id: action.payload.id, timeline: { ...savedTimeline, + updated: response.timeline.updated ?? undefined, savedObjectId: response.timeline.savedObjectId, version: response.timeline.version, status: response.timeline.status ?? TimelineStatus.active, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index d6597df71526f..a2bccaddb309e 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -17,7 +17,6 @@ import { TestProviders, defaultHeaders, createSecuritySolutionStorageMock, - mockIndexPattern, kibanaObservable, } from '../../../common/mock'; @@ -32,17 +31,16 @@ import { } from './actions'; import { - TimelineComponent, - Props as TimelineComponentProps, -} from '../../components/timeline/timeline'; -import { mockBrowserFields } from '../../../common/containers/source/mock'; + QueryTabContentComponent, + Props as QueryTabContentComponentProps, +} from '../../components/timeline/query_tab_content'; import { mockDataProviders } from '../../components/timeline/data_providers/mock/mock_data_providers'; import { Sort } from '../../components/timeline/body/sort'; import { Direction } from '../../../graphql/types'; import { addTimelineInStorage } from '../../containers/local_storage'; import { isPageTimeline } from './epic_local_storage'; -import { TimelineId, TimelineStatus, TimelineType } from '../../../../common/types/timeline'; +import { TimelineId, TimelineStatus } from '../../../../common/types/timeline'; jest.mock('../../containers/local_storage'); @@ -59,7 +57,7 @@ describe('epicLocalStorage', () => { storage ); - let props = {} as TimelineComponentProps; + let props = {} as QueryTabContentComponentProps; const sort: Sort = { columnId: '@timestamp', sortDirection: Direction.desc, @@ -67,8 +65,6 @@ describe('epicLocalStorage', () => { const startDate = '2018-03-23T18:49:23.132Z'; const endDate = '2018-03-24T03:33:52.253Z'; - const indexPattern = mockIndexPattern; - beforeEach(() => { store = createStore( state, @@ -78,33 +74,24 @@ describe('epicLocalStorage', () => { storage ); props = { - browserFields: mockBrowserFields, columns: defaultHeaders, - id: 'foo', dataProviders: mockDataProviders, - docValueFields: [], end: endDate, + eventType: 'all', filters: [], - indexNames: [], - indexPattern, isLive: false, - isSaving: false, itemsPerPage: 5, itemsPerPageOptions: [5, 10, 20], - kqlMode: 'search' as TimelineComponentProps['kqlMode'], + kqlMode: 'search' as QueryTabContentComponentProps['kqlMode'], kqlQueryExpression: '', - loadingSourcerer: false, - onChangeItemsPerPage: jest.fn(), - onClose: jest.fn(), - show: true, showCallOutUnauthorizedMsg: false, + showEventDetails: false, start: startDate, status: TimelineStatus.active, sort, - timelineType: TimelineType.default, + timelineId: 'foo', timerangeKind: 'absolute', - toggleColumn: jest.fn(), - usersViewing: ['elastic'], + updateEventTypeAndIndexesName: jest.fn(), }; }); @@ -116,7 +103,7 @@ describe('epicLocalStorage', () => { it('persist adding / reordering of a column correctly', async () => { shallow( - + ); store.dispatch(upsertColumn({ id: 'test', index: 1, column: defaultHeaders[0] })); @@ -126,7 +113,7 @@ describe('epicLocalStorage', () => { it('persist timeline when removing a column ', async () => { shallow( - + ); store.dispatch(removeColumn({ id: 'test', columnId: '@timestamp' })); @@ -136,7 +123,7 @@ describe('epicLocalStorage', () => { it('persists resizing of a column', async () => { shallow( - + ); store.dispatch(applyDeltaToColumnWidth({ id: 'test', columnId: '@timestamp', delta: 80 })); @@ -146,7 +133,7 @@ describe('epicLocalStorage', () => { it('persist the resetting of the fields', async () => { shallow( - + ); store.dispatch(updateColumns({ id: 'test', columns: defaultHeaders })); @@ -156,7 +143,7 @@ describe('epicLocalStorage', () => { it('persist items per page', async () => { shallow( - + ); store.dispatch(updateItemsPerPage({ id: 'test', itemsPerPage: 50 })); @@ -166,7 +153,7 @@ describe('epicLocalStorage', () => { it('persist the sorting of a column', async () => { shallow( - + ); store.dispatch( 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 241b8c5030de7..1122b7a94e0e0 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 @@ -19,7 +19,7 @@ import { IS_OPERATOR, EXISTS_OPERATOR, } from '../../../timelines/components/timeline/data_providers/data_provider'; -import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/model'; +import { SerializedFilterQuery } from '../../../common/store/model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, @@ -177,7 +177,6 @@ interface AddNewTimelineParams { indexNames: string[]; kqlQuery?: { filterQuery: SerializedFilterQuery | null; - filterQueryDraft: KueryFilterQuery | null; }; show?: boolean; sort?: Sort; @@ -197,7 +196,7 @@ export const addNewTimeline = ({ id, itemsPerPage = timelineDefaults.itemsPerPage, indexNames, - kqlQuery = { filterQuery: null, filterQueryDraft: null }, + kqlQuery = { filterQuery: null }, sort = timelineDefaults.sort, show = false, showCheckboxes = false, @@ -581,31 +580,6 @@ export const updateTimelineKqlMode = ({ }; }; -interface UpdateKqlFilterQueryDraftParams { - id: string; - filterQueryDraft: KueryFilterQuery; - timelineById: TimelineById; -} - -export const updateKqlFilterQueryDraft = ({ - id, - filterQueryDraft, - timelineById, -}: UpdateKqlFilterQueryDraftParams): TimelineById => { - const timeline = timelineById[id]; - - return { - ...timelineById, - [id]: { - ...timeline, - kqlQuery: { - ...timeline.kqlQuery, - filterQueryDraft, - }, - }, - }; -}; - interface UpdateTimelineColumnsParams { id: string; columns: ColumnHeaderOptions[]; 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 7d015c1dc82b1..e4d1a6b512689 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 @@ -10,7 +10,7 @@ import { DataProvider } from '../../components/timeline/data_providers/data_prov import { Sort } from '../../components/timeline/body/sort'; import { PinnedEvent } from '../../../graphql/types'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; -import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/types'; +import { SerializedFilterQuery } from '../../../common/store/types'; import type { TimelineEventsType, TimelineExpandedEvent, @@ -43,7 +43,16 @@ export interface ColumnHeaderOptions { width: number; } +export enum TimelineTabs { + query = 'query', + graph = 'graph', + notes = 'notes', + pinned = 'pinned', +} + export interface TimelineModel { + /** The selected tab to displayed in the timeline */ + activeTab: TimelineTabs; /** The columns displayed in the timeline */ columns: ColumnHeaderOptions[]; /** The sources of the event data shown in the timeline */ @@ -88,7 +97,6 @@ export interface TimelineModel { /** the KQL query in the KQL bar */ kqlQuery: { filterQuery: SerializedFilterQuery | null; - filterQueryDraft: KueryFilterQuery | null; }; /** Title */ title: string; @@ -119,6 +127,8 @@ export interface TimelineModel { sort: Sort; /** status: active | draft */ status: TimelineStatus; + /** updated saved object timestamp */ + updated?: number; /** timeline is saving */ isSaving: boolean; isLoading: boolean; @@ -128,6 +138,7 @@ export interface TimelineModel { export type SubsetTimelineModel = Readonly< Pick< TimelineModel, + | 'activeTab' | 'columns' | 'dataProviders' | 'deletedEventIds' @@ -169,6 +180,7 @@ export type SubsetTimelineModel = Readonly< >; export interface TimelineUrl { + activeTab?: TimelineTabs; id: string; isOpen: boolean; graphEventId?: string; 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 cd89c9df7e3db..2ca34742affef 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 @@ -40,7 +40,7 @@ import { updateTimelineTitle, upsertTimelineColumn, } from './helpers'; -import { ColumnHeaderOptions, TimelineModel } from './model'; +import { ColumnHeaderOptions, TimelineModel, TimelineTabs } from './model'; import { timelineDefaults } from './defaults'; import { TimelineById } from './types'; @@ -68,6 +68,7 @@ const basicDataProvider: DataProvider = { kqlQuery: '', }; const basicTimeline: TimelineModel = { + activeTab: TimelineTabs.query, columns: [], dataProviders: [{ ...basicDataProvider }], dateRange: { @@ -91,7 +92,7 @@ const basicTimeline: TimelineModel = { itemsPerPage: 25, itemsPerPageOptions: [10, 25, 50], kqlMode: 'filter', - kqlQuery: { filterQuery: null, filterQueryDraft: null }, + kqlQuery: { filterQuery: null }, loadingEventIds: [], noteIds: [], pinnedEventIds: {}, 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 3f2b56b3f7dba..daf57505b6baf 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 @@ -23,11 +23,11 @@ import { removeColumn, removeProvider, setEventsDeleted, + setActiveTabTimeline, setEventsLoading, setExcludedRowRendererIds, setFilters, setInsertTimeline, - setKqlFilterQueryDraft, setSavedQueryId, setSelected, showCallOutUnauthorizedMsg, @@ -76,7 +76,6 @@ import { setSelectedTimelineEvents, unPinTimelineEvent, updateExcludedRowRenderersIds, - updateKqlFilterQueryDraft, updateTimelineColumns, updateTimelineDescription, updateTimelineIsFavorite, @@ -200,14 +199,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineById: state.timelineById, }), })) - .case(setKqlFilterQueryDraft, (state, { id, filterQueryDraft }) => ({ - ...state, - timelineById: updateKqlFilterQueryDraft({ - id, - filterQueryDraft, - timelineById: state.timelineById, - }), - })) .case(showTimeline, (state, { id, show }) => ({ ...state, timelineById: updateTimelineShowTimeline({ id, show, timelineById: state.timelineById }), @@ -519,4 +510,14 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }, }, })) + .case(setActiveTabTimeline, (state, { id, activeTab }) => ({ + ...state, + timelineById: { + ...state.timelineById, + [id]: { + ...state.timelineById[id], + activeTab, + }, + }, + })) .build(); 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 a80a28660e28b..e379caba323ca 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 @@ -6,7 +6,6 @@ import { createSelector } from 'reselect'; -import { isFromKueryExpressionValid } from '../../../common/lib/keury'; import { State } from '../../../common/store/types'; import { TimelineModel } from './model'; @@ -54,11 +53,6 @@ export const getKqlFilterQuerySelector = () => : null ); -export const getKqlFilterQueryDraftSelector = () => - createSelector(selectTimeline, (timeline) => - timeline && timeline.kqlQuery ? timeline.kqlQuery.filterQueryDraft : null - ); - export const getKqlFilterKuerySelector = () => createSelector(selectTimeline, (timeline) => timeline && @@ -68,12 +62,3 @@ export const getKqlFilterKuerySelector = () => ? timeline.kqlQuery.filterQuery.kuery : null ); - -export const isFilterQueryDraftValidSelector = () => - createSelector( - selectTimeline, - (timeline) => - timeline && - timeline.kqlQuery && - isFromKueryExpressionValid(timeline.kqlQuery.filterQueryDraft) - ); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 10e817bea0282..b8676893d8ba1 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -135,7 +135,7 @@ export class Plugin implements IPlugin インデックスパターンページからフィールドリストを更新してください", "discover.docViews.table.tableTitle": "表", "discover.docViews.table.toggleColumnInTableButtonAriaLabel": "表の列を切り替える", "discover.docViews.table.toggleColumnInTableButtonTooltip": "表の列を切り替える", @@ -10790,7 +10788,6 @@ "xpack.lens.shared.nestedLegendLabel": "ネスト済み", "xpack.lens.sugegstion.refreshSuggestionLabel": "更新", "xpack.lens.suggestion.refreshSuggestionTooltip": "選択したビジュアライゼーションに基づいて、候補を更新します。", - "xpack.lens.suggestions.currentVisLabel": "現在", "xpack.lens.visTypeAlias.title": "レンズビジュアライゼーション", "xpack.lens.visTypeAlias.type": "レンズ", "xpack.lens.xyChart.addLayer": "レイヤーを追加", @@ -16388,7 +16385,6 @@ "xpack.securitySolution.case.caseView.caseOpened": "ケースを開きました", "xpack.securitySolution.case.caseView.caseRefresh": "ケースを更新", "xpack.securitySolution.case.caseView.closeCase": "ケースを閉じる", - "xpack.securitySolution.case.caseView.closedCase": "閉じたケース", "xpack.securitySolution.case.caseView.closedOn": "終了日", "xpack.securitySolution.case.caseView.cloudDeploymentLink": "クラウド展開", "xpack.securitySolution.case.caseView.comment": "コメント", @@ -16436,7 +16432,6 @@ "xpack.securitySolution.case.caseView.pushToServiceDisableByNoConfigTitle": "外部コネクターを構成", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoConnectors": "外部システムでケースを開いて更新するには、{link}を設定する必要があります。", "xpack.securitySolution.case.caseView.reopenCase": "ケースを再開", - "xpack.securitySolution.case.caseView.reopenedCase": "ケースを再開する", "xpack.securitySolution.case.caseView.reporterLabel": "報告者", "xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "{ externalService }インシデントの更新が必要です", "xpack.securitySolution.case.caseView.sendEmalLinkAria": "クリックすると、{user}に電子メールを送信します", @@ -18230,7 +18225,6 @@ "xpack.securitySolution.timeline.properties.descriptionPlaceholder": "説明", "xpack.securitySolution.timeline.properties.descriptionTooltip": "このタイムラインのイベントのサマリーとメモ", "xpack.securitySolution.timeline.properties.existingCaseButtonLabel": "タイムラインを既存のケースに添付...", - "xpack.securitySolution.timeline.properties.favoriteTooltip": "お気に入り", "xpack.securitySolution.timeline.properties.historyLabel": "履歴", "xpack.securitySolution.timeline.properties.historyToolTip": "このタイムラインに関連したアクションの履歴", "xpack.securitySolution.timeline.properties.inspectTimelineTitle": "Timeline", @@ -18240,7 +18234,6 @@ "xpack.securitySolution.timeline.properties.newCaseButtonLabel": "タイムラインを新しいケースに接続する", "xpack.securitySolution.timeline.properties.newTemplateTimelineButtonLabel": "新規タイムラインテンプレートを作成", "xpack.securitySolution.timeline.properties.newTimelineButtonLabel": "新規タイムラインを作成", - "xpack.securitySolution.timeline.properties.notAFavoriteTooltip": "お気に入りではありません", "xpack.securitySolution.timeline.properties.notesButtonLabel": "メモ", "xpack.securitySolution.timeline.properties.notesToolTip": "このタイムラインに関するメモを追加して確認します。メモはイベントにも追加できます。", "xpack.securitySolution.timeline.properties.streamLiveButtonLabel": "ライブストリーム", @@ -20565,8 +20558,6 @@ "xpack.uptime.featureRegistry.uptimeFeatureName": "アップタイム", "xpack.uptime.filterBar.ariaLabel": "概要ページのインプットフィルター基準", "xpack.uptime.filterBar.filterAllLabel": "すべて", - "xpack.uptime.filterBar.filterDownLabel": "ダウン", - "xpack.uptime.filterBar.filterUpLabel": "アップ", "xpack.uptime.filterBar.options.location.name": "場所", "xpack.uptime.filterBar.options.portLabel": "ポート", "xpack.uptime.filterBar.options.schemeLabel": "スキーム", @@ -20667,8 +20658,6 @@ "xpack.uptime.monitorList.tlsColumnLabel": "TLS証明書", "xpack.uptime.monitorList.viewCertificateTitle": "証明書ステータス", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "ミリ秒単位の監視時間", - "xpack.uptime.monitorStatusBar.healthStatusMessage.downLabel": "ダウン", - "xpack.uptime.monitorStatusBar.healthStatusMessage.upLabel": "アップ", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "監視ステータス", "xpack.uptime.monitorStatusBar.loadingMessage": "読み込み中…", "xpack.uptime.monitorStatusBar.locations.oneLocStatus": "{loc}場所での{status}", @@ -20721,17 +20710,10 @@ "xpack.uptime.pingList.expandedRow.truncated": "初めの {contentBytes} バイトを表示中。", "xpack.uptime.pingList.expandRow": "拡張", "xpack.uptime.pingList.ipAddressColumnLabel": "IP", - "xpack.uptime.pingList.locationLabel": "場所", "xpack.uptime.pingList.locationNameColumnLabel": "場所", "xpack.uptime.pingList.recencyMessage": "最終確認 {fromNow}", "xpack.uptime.pingList.responseCodeColumnLabel": "応答コード", - "xpack.uptime.pingList.statusColumnHealthDownLabel": "ダウン", - "xpack.uptime.pingList.statusColumnHealthUpLabel": "アップ", "xpack.uptime.pingList.statusColumnLabel": "ステータス", - "xpack.uptime.pingList.statusLabel": "ステータス", - "xpack.uptime.pingList.statusOptions.allStatusOptionLabel": "すべて", - "xpack.uptime.pingList.statusOptions.downStatusOptionLabel": "ダウン", - "xpack.uptime.pingList.statusOptions.upStatusOptionLabel": "アップ", "xpack.uptime.pluginDescription": "アップタイム監視", "xpack.uptime.settings.blank.error": "空白にすることはできません。", "xpack.uptime.settings.blankNumberField.error": "数値でなければなりません。", @@ -20744,17 +20726,13 @@ "xpack.uptime.settings.saveSuccess": "設定が保存されました。", "xpack.uptime.settingsBreadcrumbText": "設定", "xpack.uptime.snapshot.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total}個中{down}個のモニターがダウンしています。", - "xpack.uptime.snapshot.donutChart.legend.downRowLabel": "ダウン", - "xpack.uptime.snapshot.donutChart.legend.upRowLabel": "アップ", "xpack.uptime.snapshot.monitor": "監視", "xpack.uptime.snapshot.monitors": "監視", "xpack.uptime.snapshot.noDataDescription": "選択した時間範囲に ping はありません。", "xpack.uptime.snapshot.noDataTitle": "利用可能な ping データがありません", "xpack.uptime.snapshot.pingsOverTimeTitle": "一定時間のピング", "xpack.uptime.snapshotHistogram.description": "{startTime} から {endTime} までの期間のアップタイムステータスを表示する棒グラフです。", - "xpack.uptime.snapshotHistogram.series.downLabel": "ダウン", "xpack.uptime.snapshotHistogram.series.pings": "モニター接続確認", - "xpack.uptime.snapshotHistogram.series.upLabel": "アップ", "xpack.uptime.snapshotHistogram.xAxisId": "ピングX軸", "xpack.uptime.snapshotHistogram.yAxis.title": "ピング", "xpack.uptime.snapshotHistogram.yAxisId": "ピングY軸", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f429d59d07fe0..dbfc45deb8dd5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1460,8 +1460,6 @@ "discover.docViews.table.filterForValueButtonTooltip": "筛留值", "discover.docViews.table.filterOutValueButtonAriaLabel": "筛除值", "discover.docViews.table.filterOutValueButtonTooltip": "筛除值", - "discover.docViews.table.noCachedMappingForThisFieldAriaLabel": "警告", - "discover.docViews.table.noCachedMappingForThisFieldTooltip": "此字段没有任何已缓存映射。从“管理”>“索引模式”页面刷新字段列表", "discover.docViews.table.tableTitle": "表", "discover.docViews.table.toggleColumnInTableButtonAriaLabel": "在表中切换列", "discover.docViews.table.toggleColumnInTableButtonTooltip": "在表中切换列", @@ -10803,7 +10801,6 @@ "xpack.lens.shared.nestedLegendLabel": "嵌套", "xpack.lens.sugegstion.refreshSuggestionLabel": "刷新", "xpack.lens.suggestion.refreshSuggestionTooltip": "基于选定可视化刷新建议。", - "xpack.lens.suggestions.currentVisLabel": "当前", "xpack.lens.visTypeAlias.title": "Lens 可视化", "xpack.lens.visTypeAlias.type": "Lens", "xpack.lens.xyChart.addLayer": "添加图层", @@ -16406,7 +16403,6 @@ "xpack.securitySolution.case.caseView.caseOpened": "案例已打开", "xpack.securitySolution.case.caseView.caseRefresh": "刷新案例", "xpack.securitySolution.case.caseView.closeCase": "关闭案例", - "xpack.securitySolution.case.caseView.closedCase": "已关闭案例", "xpack.securitySolution.case.caseView.closedOn": "关闭于", "xpack.securitySolution.case.caseView.cloudDeploymentLink": "云部署", "xpack.securitySolution.case.caseView.comment": "注释", @@ -16454,7 +16450,6 @@ "xpack.securitySolution.case.caseView.pushToServiceDisableByNoConfigTitle": "配置外部连接器", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoConnectors": "要在外部系统上打开和更新案例,必须配置{link}。", "xpack.securitySolution.case.caseView.reopenCase": "重新打开案例", - "xpack.securitySolution.case.caseView.reopenedCase": "重新打开的案例", "xpack.securitySolution.case.caseView.reporterLabel": "报告者", "xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "需要更新 { externalService } 事件", "xpack.securitySolution.case.caseView.sendEmalLinkAria": "单击可向 {user} 发送电子邮件", @@ -18249,7 +18244,6 @@ "xpack.securitySolution.timeline.properties.descriptionPlaceholder": "描述", "xpack.securitySolution.timeline.properties.descriptionTooltip": "此时间线中的事件和备注摘要", "xpack.securitySolution.timeline.properties.existingCaseButtonLabel": "将时间线附加到现有案例......", - "xpack.securitySolution.timeline.properties.favoriteTooltip": "收藏", "xpack.securitySolution.timeline.properties.historyLabel": "历史记录", "xpack.securitySolution.timeline.properties.historyToolTip": "按时间顺序排列的与此时间线相关的操作历史记录", "xpack.securitySolution.timeline.properties.inspectTimelineTitle": "时间线", @@ -18259,7 +18253,6 @@ "xpack.securitySolution.timeline.properties.newCaseButtonLabel": "将时间线附加到新案例", "xpack.securitySolution.timeline.properties.newTemplateTimelineButtonLabel": "创建新时间线模板", "xpack.securitySolution.timeline.properties.newTimelineButtonLabel": "创建新时间线", - "xpack.securitySolution.timeline.properties.notAFavoriteTooltip": "取消收藏", "xpack.securitySolution.timeline.properties.notesButtonLabel": "备注", "xpack.securitySolution.timeline.properties.notesToolTip": "添加并审核此时间线的备注。也可以向事件添加备注。", "xpack.securitySolution.timeline.properties.streamLiveButtonLabel": "实时流式传输", @@ -20585,8 +20578,6 @@ "xpack.uptime.featureRegistry.uptimeFeatureName": "运行时间", "xpack.uptime.filterBar.ariaLabel": "概览页面的输入筛选条件", "xpack.uptime.filterBar.filterAllLabel": "全部", - "xpack.uptime.filterBar.filterDownLabel": "关闭", - "xpack.uptime.filterBar.filterUpLabel": "运行", "xpack.uptime.filterBar.options.location.name": "位置", "xpack.uptime.filterBar.options.portLabel": "端口", "xpack.uptime.filterBar.options.schemeLabel": "方案", @@ -20687,8 +20678,6 @@ "xpack.uptime.monitorList.tlsColumnLabel": "TLS 证书", "xpack.uptime.monitorList.viewCertificateTitle": "证书状态", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "监测持续时间(毫秒)", - "xpack.uptime.monitorStatusBar.healthStatusMessage.downLabel": "关闭", - "xpack.uptime.monitorStatusBar.healthStatusMessage.upLabel": "运行", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "检测状态", "xpack.uptime.monitorStatusBar.loadingMessage": "正在加载……", "xpack.uptime.monitorStatusBar.locations.oneLocStatus": "在 {loc} 位置处于 {status}", @@ -20741,17 +20730,10 @@ "xpack.uptime.pingList.expandedRow.truncated": "显示前 {contentBytes} 字节。", "xpack.uptime.pingList.expandRow": "展开", "xpack.uptime.pingList.ipAddressColumnLabel": "IP", - "xpack.uptime.pingList.locationLabel": "位置", "xpack.uptime.pingList.locationNameColumnLabel": "位置", "xpack.uptime.pingList.recencyMessage": "{fromNow}已检查", "xpack.uptime.pingList.responseCodeColumnLabel": "响应代码", - "xpack.uptime.pingList.statusColumnHealthDownLabel": "关闭", - "xpack.uptime.pingList.statusColumnHealthUpLabel": "运行", "xpack.uptime.pingList.statusColumnLabel": "状态", - "xpack.uptime.pingList.statusLabel": "状态", - "xpack.uptime.pingList.statusOptions.allStatusOptionLabel": "全部", - "xpack.uptime.pingList.statusOptions.downStatusOptionLabel": "关闭", - "xpack.uptime.pingList.statusOptions.upStatusOptionLabel": "运行", "xpack.uptime.pluginDescription": "运行时间监测", "xpack.uptime.settings.blank.error": "不能为空。", "xpack.uptime.settings.blankNumberField.error": "必须为数字。", @@ -20764,17 +20746,13 @@ "xpack.uptime.settings.saveSuccess": "设置已保存!", "xpack.uptime.settingsBreadcrumbText": "设置", "xpack.uptime.snapshot.donutChart.ariaLabel": "显示当前状态的饼图。{total} 个监测中有 {down} 个已关闭。", - "xpack.uptime.snapshot.donutChart.legend.downRowLabel": "关闭", - "xpack.uptime.snapshot.donutChart.legend.upRowLabel": "运行", "xpack.uptime.snapshot.monitor": "监测", "xpack.uptime.snapshot.monitors": "监测", "xpack.uptime.snapshot.noDataDescription": "选定的时间范围中没有 ping。", "xpack.uptime.snapshot.noDataTitle": "没有可用的 ping 数据", "xpack.uptime.snapshot.pingsOverTimeTitle": "时移 Ping 数", "xpack.uptime.snapshotHistogram.description": "显示从 {startTime} 到 {endTime} 的运行时间时移状态的条形图。", - "xpack.uptime.snapshotHistogram.series.downLabel": "关闭", "xpack.uptime.snapshotHistogram.series.pings": "监测 Ping", - "xpack.uptime.snapshotHistogram.series.upLabel": "运行", "xpack.uptime.snapshotHistogram.xAxisId": "Ping X 轴", "xpack.uptime.snapshotHistogram.yAxis.title": "Ping", "xpack.uptime.snapshotHistogram.yAxisId": "Ping Y 轴", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.test.ts index 35470db23fb35..a0df9c95a9184 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.test.ts @@ -10,20 +10,16 @@ import { getDefaultsForActionParams } from './get_defaults_for_action_params'; describe('getDefaultsForActionParams', () => { test('pagerduty defaults', async () => { - expect(getDefaultsForActionParams(() => false)('.pagerduty', 'test')).toEqual({ + expect(getDefaultsForActionParams('.pagerduty', 'test', false)).toEqual({ dedupKey: `{{${AlertProvidedActionVariables.alertId}}}:{{${AlertProvidedActionVariables.alertInstanceId}}}`, eventAction: 'trigger', }); }); test('pagerduty defaults for recovered action group', async () => { - const isRecoveryActionGroup = jest.fn().mockReturnValue(true); - expect( - getDefaultsForActionParams(isRecoveryActionGroup)('.pagerduty', RecoveredActionGroup.id) - ).toEqual({ + expect(getDefaultsForActionParams('.pagerduty', RecoveredActionGroup.id, true)).toEqual({ dedupKey: `{{${AlertProvidedActionVariables.alertId}}}:{{${AlertProvidedActionVariables.alertInstanceId}}}`, eventAction: 'resolve', }); - expect(isRecoveryActionGroup).toHaveBeenCalledWith(RecoveredActionGroup.id); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.ts index 81b80cc7143d6..0cd3d9a9f6346 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.ts @@ -8,21 +8,23 @@ import { AlertActionParam } from '../../../../alerts/common'; import { EventActionOptions } from '../components/builtin_action_types/types'; import { AlertProvidedActionVariables } from './action_variables'; -export type DefaultActionParamsGetter = ReturnType; -export type DefaultActionParams = ReturnType; -export const getDefaultsForActionParams = ( - isRecoveryActionGroup: (actionGroupId: string) => boolean -) => ( +export type DefaultActionParams = Record | undefined; +export type DefaultActionParamsGetter = ( actionTypeId: string, actionGroupId: string -): Record | undefined => { +) => DefaultActionParams; +export const getDefaultsForActionParams = ( + actionTypeId: string, + actionGroupId: string, + isRecoveryActionGroup: boolean +): DefaultActionParams => { switch (actionTypeId) { case '.pagerduty': const pagerDutyDefaults = { dedupKey: `{{${AlertProvidedActionVariables.alertId}}}:{{${AlertProvidedActionVariables.alertInstanceId}}}`, eventAction: EventActionOptions.TRIGGER, }; - if (isRecoveryActionGroup(actionGroupId)) { + if (isRecoveryActionGroup) { pagerDutyDefaults.eventAction = EventActionOptions.RESOLVE; } return pagerDutyDefaults; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index a5b133d2a50b7..d68f66f373135 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -115,7 +115,7 @@ export const ActionTypeForm = ({ } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionItem.group, defaultParams]); + }, [actionItem.group]); const canSave = hasSaveActionsCapability(capabilities); const getSelectedOptions = (actionItemId: string) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 014398b200124..5b0585e2cc798 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -308,7 +308,19 @@ export const AlertForm = ({ ? !item.alertTypeModel.requiresAppContext : item.alertType!.producer === alert.consumer ); - const selectedAlertType = alert?.alertTypeId && alertTypesIndex?.get(alert?.alertTypeId); + const selectedAlertType = alert?.alertTypeId + ? alertTypesIndex?.get(alert?.alertTypeId) + : undefined; + const recoveryActionGroup = selectedAlertType?.recoveryActionGroup?.id; + const getDefaultActionParams = useCallback( + (actionTypeId: string, actionGroupId: string): Record | undefined => + getDefaultsForActionParams( + actionTypeId, + actionGroupId, + actionGroupId === recoveryActionGroup + ), + [recoveryActionGroup] + ); const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; @@ -501,9 +513,7 @@ export const AlertForm = ({ } : { ...actionGroup, defaultActionMessage: alertTypeModel?.defaultActionMessage } )} - getDefaultActionParams={getDefaultsForActionParams( - (actionGroupId) => actionGroupId === selectedAlertType.recoveryActionGroup.id - )} + getDefaultActionParams={getDefaultActionParams} setActionIdByIndex={(id: string, index: number) => setActionProperty('id', id, index)} setActionGroupIdByIndex={(group: string, index: number) => setActionProperty('group', group, index) diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx index eec3696a5a8cc..dab28fb03f4e0 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -21,6 +21,10 @@ import { coreMock } from '../../../../../../../src/core/public/mocks'; import { NotificationsStart } from 'kibana/public'; import { toastDrilldownsCRUDError } from '../../hooks/i18n'; +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + const storage = new Storage(new StubBrowserStorage()); const toasts = coreMock.createStart().notifications.toasts; const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx index a30c880c3d430..a6fcd77d75040 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx @@ -8,6 +8,10 @@ import { Demo } from './test_samples/demo'; import { fireEvent, render } from '@testing-library/react'; import React from 'react'; +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + test('configure valid URL template', () => { const screen = render(); diff --git a/x-pack/plugins/uptime/common/constants/client_defaults.ts b/x-pack/plugins/uptime/common/constants/client_defaults.ts index a5db67ae3b58f..5e58724b9abd9 100644 --- a/x-pack/plugins/uptime/common/constants/client_defaults.ts +++ b/x-pack/plugins/uptime/common/constants/client_defaults.ts @@ -38,6 +38,5 @@ export const CLIENT_DEFAULTS = { MONITOR_LIST_SORT_DIRECTION: 'asc', MONITOR_LIST_SORT_FIELD: 'monitor_id', SEARCH: '', - SELECTED_PING_LIST_STATUS: '', STATUS_FILTER: '', }; diff --git a/x-pack/plugins/uptime/common/constants/ui.ts b/x-pack/plugins/uptime/common/constants/ui.ts index 3bf3e3cc0a2cc..2fc7c33e71630 100644 --- a/x-pack/plugins/uptime/common/constants/ui.ts +++ b/x-pack/plugins/uptime/common/constants/ui.ts @@ -15,6 +15,16 @@ export const CERTIFICATES_ROUTE = '/certificates'; export enum STATUS { UP = 'up', DOWN = 'down', + COMPLETE = 'complete', + FAILED = 'failed', + SKIPPED = 'skipped', +} + +export enum MONITOR_TYPES { + HTTP = 'http', + TCP = 'tcp', + ICMP = 'icmp', + BROWSER = 'browser', } export const ML_JOB_ID = 'high_latency_by_geo'; diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index f9dde011b25fe..17b2d143eeab0 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -232,6 +232,7 @@ export const PingType = t.intersection([ full: t.string, port: t.number, scheme: t.string, + path: t.string, }), service: t.partial({ name: t.string, @@ -280,7 +281,6 @@ export const makePing = (f: { export const PingsResponseType = t.type({ total: t.number, - locations: t.array(t.string), pings: t.array(PingType), }); @@ -293,7 +293,7 @@ export const GetPingsParamsType = t.intersection([ t.partial({ index: t.number, size: t.number, - location: t.string, + locations: t.string, monitorId: t.string, sort: t.string, status: t.string, diff --git a/x-pack/plugins/uptime/public/components/common/__tests__/__snapshots__/location_link.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/__tests__/__snapshots__/location_link.test.tsx.snap index 877f1fc6d7c85..ba7a1c72a9595 100644 --- a/x-pack/plugins/uptime/public/components/common/__tests__/__snapshots__/location_link.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/__tests__/__snapshots__/location_link.test.tsx.snap @@ -6,11 +6,6 @@ exports[`LocationLink component renders a help link when location not present 1` target="_blank" > Add location -   - `; diff --git a/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap index cf00a8da35347..1a18cf5651bee 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap @@ -491,7 +491,7 @@ exports[`DonutChart component renders a donut chart 1`] = ` - Down + Up - Up + Down - Down + Up - Up + Down `; diff --git a/x-pack/plugins/uptime/public/components/common/charts/donut_chart_legend.tsx b/x-pack/plugins/uptime/public/components/common/charts/donut_chart_legend.tsx index cbbffdff745f8..f3b50895fff63 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/donut_chart_legend.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/donut_chart_legend.tsx @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import { EuiSpacer } from '@elastic/eui'; import React, { useContext } from 'react'; import styled from 'styled-components'; import { DonutChartLegendRow } from './donut_chart_legend_row'; import { UptimeThemeContext } from '../../../contexts'; +import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../translations'; const LegendContainer = styled.div` max-width: 150px; @@ -34,18 +34,14 @@ export const DonutChartLegend = ({ down, up }: Props) => { diff --git a/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx b/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx index 9e0b3a394ba7e..46971b2b6d34a 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx @@ -28,6 +28,7 @@ import { HistogramResult } from '../../../../common/runtime_types'; import { useUrlParams } from '../../../hooks'; import { ChartEmptyState } from './chart_empty_state'; import { getDateRangeFromChartElement } from './utils'; +import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../translations'; export interface PingHistogramComponentProps { /** @@ -84,14 +85,6 @@ export const PingHistogramComponent: React.FC = ({ } else { const { histogram, minInterval } = data; - const downSpecId = i18n.translate('xpack.uptime.snapshotHistogram.series.downLabel', { - defaultMessage: 'Down', - }); - - const upMonitorsId = i18n.translate('xpack.uptime.snapshotHistogram.series.upLabel', { - defaultMessage: 'Up', - }); - const onBrushEnd: BrushEndListener = ({ x }) => { if (!x) { return; @@ -113,8 +106,8 @@ export const PingHistogramComponent: React.FC = ({ histogram.forEach(({ x, upCount, downCount }) => { barData.push( - { x, y: downCount ?? 0, type: downSpecId }, - { x, y: upCount ?? 0, type: upMonitorsId } + { x, y: downCount ?? 0, type: STATUS_DOWN_LABEL }, + { x, y: upCount ?? 0, type: STATUS_UP_LABEL } ); }); @@ -168,7 +161,7 @@ export const PingHistogramComponent: React.FC = ({ { description: 'Text that instructs the user to navigate to our docs to add a geographic location to their data', })} -   - ); }; diff --git a/x-pack/plugins/uptime/public/components/common/translations.ts b/x-pack/plugins/uptime/public/components/common/translations.ts index d2c466ddf0c83..cbab5d1d4f210 100644 --- a/x-pack/plugins/uptime/public/components/common/translations.ts +++ b/x-pack/plugins/uptime/public/components/common/translations.ts @@ -9,3 +9,25 @@ import { i18n } from '@kbn/i18n'; export const URL_LABEL = i18n.translate('xpack.uptime.monitorList.table.url.name', { defaultMessage: 'Url', }); + +export const STATUS_UP_LABEL = i18n.translate('xpack.uptime.monitorList.statusColumn.upLabel', { + defaultMessage: 'Up', +}); + +export const STATUS_DOWN_LABEL = i18n.translate('xpack.uptime.monitorList.statusColumn.downLabel', { + defaultMessage: 'Down', +}); + +export const STATUS_COMPLETE_LABEL = i18n.translate( + 'xpack.uptime.monitorList.statusColumn.completeLabel', + { + defaultMessage: 'Complete', + } +); + +export const STATUS_FAILED_LABEL = i18n.translate( + 'xpack.uptime.monitorList.statusColumn.failedLabel', + { + defaultMessage: 'Failed', + } +); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap index a23879b72996d..7d7da0b7dd74c 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap @@ -2,92 +2,7 @@ exports[`PingList component renders sorted list without errors 1`] = ` - -

- -

-
- - - - - - - - - - - - - + @@ -112,24 +27,16 @@ exports[`PingList component renders sorted list without errors 1`] = ` "name": "IP", }, Object { - "align": "right", + "align": "center", "field": "monitor.duration.us", "name": "Duration", "render": [Function], }, Object { - "align": "right", "field": "error.type", - "name": "Error type", - "render": [Function], - }, - Object { - "align": "right", - "field": "http.response.status_code", - "name": - Response code - , + "name": "Error", "render": [Function], + "width": "30%", }, Object { "align": "right", @@ -181,148 +88,6 @@ exports[`PingList component renders sorted list without errors 1`] = ` }, "timestamp": "2019-01-28T17:47:09.075Z", }, - Object { - "docId": "fejjio21", - "monitor": Object { - "duration": Object { - "us": 1452, - }, - "id": "auto-tcp-0X81440A68E839814D", - "ip": "127.0.0.1", - "name": "", - "status": "up", - "type": "tcp", - }, - "timestamp": "2019-01-28T17:47:06.077Z", - }, - Object { - "docId": "fewzio21", - "error": Object { - "message": "dial tcp 127.0.0.1:9200: connect: connection refused", - "type": "io", - }, - "monitor": Object { - "duration": Object { - "us": 1094, - }, - "id": "auto-tcp-0X81440A68E839814E", - "ip": "127.0.0.1", - "name": "", - "status": "down", - "type": "tcp", - }, - "timestamp": "2019-01-28T17:47:07.075Z", - }, - Object { - "docId": "fewpi321", - "error": Object { - "message": "Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused", - "type": "io", - }, - "monitor": Object { - "duration": Object { - "us": 1597, - }, - "id": "auto-http-0X3675F89EF061209G", - "ip": "127.0.0.1", - "name": "", - "status": "down", - "type": "http", - }, - "timestamp": "2019-01-28T17:47:07.074Z", - }, - Object { - "docId": "0ewjio21", - "error": Object { - "message": "dial tcp 127.0.0.1:9200: connect: connection refused", - "type": "io", - }, - "monitor": Object { - "duration": Object { - "us": 1699, - }, - "id": "auto-tcp-0X81440A68E839814H", - "ip": "127.0.0.1", - "name": "", - "status": "down", - "type": "tcp", - }, - "timestamp": "2019-01-28T17:47:18.080Z", - }, - Object { - "docId": "3ewjio21", - "error": Object { - "message": "dial tcp 127.0.0.1:9200: connect: connection refused", - "type": "io", - }, - "monitor": Object { - "duration": Object { - "us": 5384, - }, - "id": "auto-tcp-0X81440A68E839814I", - "ip": "127.0.0.1", - "name": "", - "status": "down", - "type": "tcp", - }, - "timestamp": "2019-01-28T17:47:19.076Z", - }, - Object { - "docId": "fewjip21", - "error": Object { - "message": "Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused", - "type": "io", - }, - "monitor": Object { - "duration": Object { - "us": 5397, - }, - "id": "auto-http-0X3675F89EF061209J", - "ip": "127.0.0.1", - "name": "", - "status": "down", - "type": "http", - }, - "timestamp": "2019-01-28T17:47:19.076Z", - }, - Object { - "docId": "fewjio21", - "http": Object { - "response": Object { - "status_code": 200, - }, - }, - "monitor": Object { - "duration": Object { - "us": 127511, - }, - "id": "auto-tcp-0X81440A68E839814C", - "ip": "172.217.7.4", - "name": "", - "status": "up", - "type": "http", - }, - "timestamp": "2019-01-28T17:47:19.077Z", - }, - Object { - "docId": "fewjik81", - "http": Object { - "response": Object { - "status_code": 200, - }, - }, - "monitor": Object { - "duration": Object { - "us": 287543, - }, - "id": "auto-http-0X131221E73F825974", - "ip": "192.30.253.112", - "name": "", - "status": "up", - "type": "http", - }, - "timestamp": "2019-01-28T17:47:19.077Z", - }, ] } loading={false} @@ -339,11 +104,11 @@ exports[`PingList component renders sorted list without errors 1`] = ` 50, 100, ], - "totalItemCount": 10, + "totalItemCount": 9231, } } responsive={true} - tableLayout="fixed" + tableLayout="auto" />
`; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx index db8012dbf0675..fe101c04e9976 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx @@ -6,17 +6,21 @@ import React from 'react'; import { shallowWithIntl } from '@kbn/test/jest'; -import { PingListComponent, rowShouldExpand, toggleDetails } from '../ping_list'; +import { PingList } from '../ping_list'; import { Ping, PingsResponse } from '../../../../../common/runtime_types'; import { ExpandedRowMap } from '../../../overview/monitor_list/types'; +import { rowShouldExpand, toggleDetails } from '../columns/expand_row'; +import * as pingListHook from '../use_pings'; +import { mockReduxHooks } from '../../../../lib/helper/test_helpers'; + +mockReduxHooks(); describe('PingList component', () => { let response: PingsResponse; - beforeEach(() => { + beforeAll(() => { response = { total: 9231, - locations: ['nyc'], pings: [ { docId: 'fewjio21', @@ -50,147 +54,19 @@ describe('PingList component', () => { type: 'tcp', }, }, - { - docId: 'fejjio21', - timestamp: '2019-01-28T17:47:06.077Z', - monitor: { - duration: { us: 1452 }, - id: 'auto-tcp-0X81440A68E839814D', - ip: '127.0.0.1', - name: '', - status: 'up', - type: 'tcp', - }, - }, - { - docId: 'fewzio21', - timestamp: '2019-01-28T17:47:07.075Z', - error: { - message: 'dial tcp 127.0.0.1:9200: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 1094 }, - id: 'auto-tcp-0X81440A68E839814E', - ip: '127.0.0.1', - name: '', - status: 'down', - type: 'tcp', - }, - }, - { - docId: 'fewpi321', - timestamp: '2019-01-28T17:47:07.074Z', - error: { - message: - 'Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 1597 }, - id: 'auto-http-0X3675F89EF061209G', - ip: '127.0.0.1', - name: '', - status: 'down', - type: 'http', - }, - }, - { - docId: '0ewjio21', - timestamp: '2019-01-28T17:47:18.080Z', - error: { - message: 'dial tcp 127.0.0.1:9200: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 1699 }, - id: 'auto-tcp-0X81440A68E839814H', - ip: '127.0.0.1', - name: '', - status: 'down', - type: 'tcp', - }, - }, - { - docId: '3ewjio21', - timestamp: '2019-01-28T17:47:19.076Z', - error: { - message: 'dial tcp 127.0.0.1:9200: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 5384 }, - id: 'auto-tcp-0X81440A68E839814I', - ip: '127.0.0.1', - name: '', - status: 'down', - type: 'tcp', - }, - }, - { - docId: 'fewjip21', - timestamp: '2019-01-28T17:47:19.076Z', - error: { - message: - 'Get http://localhost:12349/: dial tcp 127.0.0.1:12349: connect: connection refused', - type: 'io', - }, - monitor: { - duration: { us: 5397 }, - id: 'auto-http-0X3675F89EF061209J', - ip: '127.0.0.1', - name: '', - status: 'down', - type: 'http', - }, - }, - { - docId: 'fewjio21', - timestamp: '2019-01-28T17:47:19.077Z', - http: { response: { status_code: 200 } }, - monitor: { - duration: { us: 127511 }, - id: 'auto-tcp-0X81440A68E839814C', - ip: '172.217.7.4', - name: '', - status: 'up', - type: 'http', - }, - }, - { - docId: 'fewjik81', - timestamp: '2019-01-28T17:47:19.077Z', - http: { response: { status_code: 200 } }, - monitor: { - duration: { us: 287543 }, - id: 'auto-http-0X131221E73F825974', - ip: '192.30.253.112', - name: '', - status: 'up', - type: 'http', - }, - }, ], }; + + jest.spyOn(pingListHook, 'usePingsList').mockReturnValue({ + ...response, + error: undefined, + loading: false, + failedSteps: { steps: [], checkGroup: '1-f-4d-4f' }, + }); }); it('renders sorted list without errors', () => { - const component = shallowWithIntl( - - ); + const component = shallowWithIntl(); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/__tests__/__snapshots__/ping_timestamp.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/__tests__/__snapshots__/ping_timestamp.test.tsx.snap new file mode 100644 index 0000000000000..64e245d1eddc9 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/__tests__/__snapshots__/ping_timestamp.test.tsx.snap @@ -0,0 +1,182 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Ping Timestamp component render without errors 1`] = ` +.c0 { + position: relative; +} + +.c0 figure.euiImage div.stepArrowsFullScreen { + display: none; +} + +.c0 figure.euiImage-isFullScreen div.stepArrowsFullScreen { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} + +.c0 div.stepArrows { + display: none; +} + +.c0:hover div.stepArrows { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} + +.c1 { + width: 120px; + text-align: center; + border: 1px solid #d3dae6; +} + +
+
+
+
+ + No image available + +
+
+
+ + +
+
+ +
+
+ +
+
+
+`; + +exports[`Ping Timestamp component shallow render without errors 1`] = ` + + + + + + + +
+ + Nov 26, 2020 10:28:56 AM + + + + + + + + + + + + + +`; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/__tests__/ping_timestamp.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/__tests__/ping_timestamp.test.tsx new file mode 100644 index 0000000000000..c9302685a2aa8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/__tests__/ping_timestamp.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallowWithIntl, renderWithIntl } from '@kbn/test/jest'; +import { PingTimestamp } from '../ping_timestamp'; +import { mockReduxHooks } from '../../../../../lib/helper/test_helpers'; +import { Ping } from '../../../../../../common/runtime_types/ping'; +import { EuiThemeProvider } from '../../../../../../../observability/public'; + +mockReduxHooks(); + +describe('Ping Timestamp component', () => { + let response: Ping; + + beforeAll(() => { + response = { + ecs: { version: '1.6.0' }, + agent: { + ephemeral_id: '52ce1110-464f-4d74-b94c-3c051bf12589', + id: '3ebcd3c2-f5c3-499e-8d86-80f98e5f4c08', + name: 'docker-desktop', + type: 'heartbeat', + version: '7.10.0', + hostname: 'docker-desktop', + }, + monitor: { + status: 'up', + check_group: 'f58a484f-2ffb-11eb-9b35-025000000001', + duration: { us: 1528598 }, + id: 'basic addition and completion of single task', + name: 'basic addition and completion of single task', + type: 'browser', + timespan: { lt: '2020-11-26T15:29:56.820Z', gte: '2020-11-26T15:28:56.820Z' }, + }, + url: { + full: 'file:///opt/elastic-synthetics/examples/todos/app/index.html', + scheme: 'file', + domain: '', + path: '/opt/elastic-synthetics/examples/todos/app/index.html', + }, + synthetics: { type: 'heartbeat/summary' }, + summary: { up: 1, down: 0 }, + timestamp: '2020-11-26T15:28:56.896Z', + docId: '0WErBXYB0mvWTKLO-yQm', + }; + }); + + it('shallow render without errors', () => { + const component = shallowWithIntl( + + ); + expect(component).toMatchSnapshot(); + }); + + it('render without errors', () => { + const component = renderWithIntl( + + + + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/expand_row.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/expand_row.tsx new file mode 100644 index 0000000000000..799a61c0d2b73 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/expand_row.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonIcon } from '@elastic/eui'; +import { Ping } from '../../../../../common/runtime_types/ping'; +import { PingListExpandedRowComponent } from '../expanded_row'; + +export const toggleDetails = ( + ping: Ping, + expandedRows: Record, + setExpandedRows: (update: Record) => any +) => { + // If already expanded, collapse + if (expandedRows[ping.docId]) { + delete expandedRows[ping.docId]; + setExpandedRows({ ...expandedRows }); + return; + } + + // Otherwise expand this row + setExpandedRows({ + ...expandedRows, + [ping.docId]: , + }); +}; + +export function rowShouldExpand(item: Ping) { + const errorPresent = !!item.error; + const httpBodyPresent = item.http?.response?.body?.bytes ?? 0 > 0; + const isBrowserMonitor = item.monitor.type === 'browser'; + return errorPresent || httpBodyPresent || isBrowserMonitor; +} + +interface Props { + item: Ping; + expandedRows: Record; + setExpandedRows: (val: Record) => void; +} +export const ExpandRowColumn = ({ item, expandedRows, setExpandedRows }: Props) => { + return ( + toggleDetails(item, expandedRows, setExpandedRows)} + disabled={!rowShouldExpand(item)} + aria-label={ + expandedRows[item.docId] + ? i18n.translate('xpack.uptime.pingList.collapseRow', { + defaultMessage: 'Collapse', + }) + : i18n.translate('xpack.uptime.pingList.expandRow', { defaultMessage: 'Expand' }) + } + iconType={expandedRows[item.docId] ? 'arrowUp' : 'arrowDown'} + /> + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/failed_step.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/failed_step.tsx new file mode 100644 index 0000000000000..1a9a9eb5b0065 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/failed_step.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Ping, SyntheticsJourneyApiResponse } from '../../../../../common/runtime_types/ping'; + +interface Props { + ping: Ping; + failedSteps?: SyntheticsJourneyApiResponse; +} + +export const FailedStep = ({ ping, failedSteps }: Props) => { + const thisFailedStep = failedSteps?.steps?.find( + (fs) => fs.monitor.check_group === ping.monitor.check_group + ); + + if (!thisFailedStep) { + return <>--; + } + return ( +
+ {thisFailedStep.synthetics?.step?.index}. {thisFailedStep.synthetics?.step?.name} +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_error.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_error.tsx new file mode 100644 index 0000000000000..928f86304f226 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_error.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { Ping } from '../../../../../common/runtime_types/ping'; + +const StyledSpan = styled.span` + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; +`; + +interface Props { + errorType: string; + ping: Ping; +} + +export const PingErrorCol = ({ errorType, ping }: Props) => { + if (!errorType) { + return <>--; + } + return ( + + {errorType}:{ping.error?.message} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_status.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_status.tsx new file mode 100644 index 0000000000000..7232ea9d6ba02 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_status.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { EuiBadge, EuiSpacer, EuiText } from '@elastic/eui'; +import { Ping } from '../../../../../common/runtime_types/ping'; +import { MONITOR_TYPES, STATUS } from '../../../../../common/constants'; +import { UptimeThemeContext } from '../../../../contexts'; +import { + STATUS_COMPLETE_LABEL, + STATUS_DOWN_LABEL, + STATUS_FAILED_LABEL, + STATUS_UP_LABEL, +} from '../../../common/translations'; + +interface Props { + pingStatus: string; + item: Ping; +} + +const getPingStatusLabel = (status: string, ping: Ping) => { + if (ping.monitor.type === MONITOR_TYPES.BROWSER) { + return status === 'up' ? STATUS_COMPLETE_LABEL : STATUS_FAILED_LABEL; + } + return status === 'up' ? STATUS_UP_LABEL : STATUS_DOWN_LABEL; +}; + +export const PingStatusColumn = ({ pingStatus, item }: Props) => { + const { + colors: { dangerBehindText }, + } = useContext(UptimeThemeContext); + + const timeStamp = moment(item.timestamp); + + let checkedTime = ''; + + if (moment().diff(timeStamp, 'd') > 1) { + checkedTime = timeStamp.format('ll LTS'); + } else { + checkedTime = timeStamp.format('LTS'); + } + + return ( +
+ + {getPingStatusLabel(pingStatus, item)} + + + + {i18n.translate('xpack.uptime.pingList.recencyMessage', { + values: { fromNow: checkedTime }, + defaultMessage: 'Checked {fromNow}', + description: + 'A string used to inform our users how long ago Heartbeat pinged the selected host.', + })} + +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp.tsx new file mode 100644 index 0000000000000..366110c0e9195 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp.tsx @@ -0,0 +1,213 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useEffect, useState } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import useIntersection from 'react-use/lib/useIntersection'; +import moment from 'moment'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Ping } from '../../../../../common/runtime_types/ping'; +import { getShortTimeStamp } from '../../../overview/monitor_list/columns/monitor_status_column'; +import { euiStyled, useFetcher } from '../../../../../../observability/public'; +import { getJourneyScreenshot } from '../../../../state/api/journey'; +import { UptimeSettingsContext } from '../../../../contexts'; + +const StepImage = styled(EuiImage)` + &&& { + display: flex; + figcaption { + white-space: nowrap; + align-self: center; + margin-left: 8px; + margin-top: 8px; + } + } +`; + +const StepDiv = styled.div` + figure.euiImage { + div.stepArrowsFullScreen { + display: none; + } + } + + figure.euiImage-isFullScreen { + div.stepArrowsFullScreen { + display: flex; + } + } + position: relative; + div.stepArrows { + display: none; + } + :hover { + div.stepArrows { + display: flex; + } + } +`; + +interface Props { + timestamp: string; + ping: Ping; +} + +export const PingTimestamp = ({ timestamp, ping }: Props) => { + const [stepNo, setStepNo] = useState(1); + + const [stepImages, setStepImages] = useState([]); + + const intersectionRef = React.useRef(null); + + const { basePath } = useContext(UptimeSettingsContext); + + const imgPath = basePath + `/api/uptime/journey/screenshot/${ping.monitor.check_group}/${stepNo}`; + + const intersection = useIntersection(intersectionRef, { + root: null, + rootMargin: '0px', + threshold: 1, + }); + + const { data } = useFetcher(() => { + if (intersection && intersection.intersectionRatio === 1 && !stepImages[stepNo - 1]) + return getJourneyScreenshot(imgPath); + }, [intersection?.intersectionRatio, stepNo]); + + useEffect(() => { + if (data) { + setStepImages((prevState) => [...prevState, data?.src]); + } + }, [data]); + + const imgSrc = stepImages[stepNo] || data?.src; + + const ImageCaption = ( + <> +
+ {imgSrc && ( + + + { + setStepNo(stepNo - 1); + }} + iconType="arrowLeft" + aria-label="Next" + /> + + + + Step:{stepNo} {data?.stepName} + + + + { + setStepNo(stepNo + 1); + }} + iconType="arrowRight" + aria-label="Next" + /> + + + )} +
+ + {getShortTimeStamp(moment(timestamp))} + + + + ); + + return ( + + {imgSrc ? ( + + ) : ( + + + + + {ImageCaption} + + )} + + + { + setStepNo(stepNo - 1); + }} + iconType="arrowLeft" + aria-label="Next" + /> + + + { + setStepNo(stepNo + 1); + }} + iconType="arrowRight" + aria-label="Next" + /> + + + + ); +}; + +const BorderedText = euiStyled(EuiText)` + width: 120px; + text-align: center; + border: 1px solid ${(props) => props.theme.eui.euiColorLightShade}; +`; + +export const NoImageAvailable = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/response_code.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/response_code.tsx new file mode 100644 index 0000000000000..da3200753bac1 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/response_code.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import { EuiBadge } from '@elastic/eui'; + +const SpanWithMargin = styled.span` + margin-right: 16px; +`; + +interface Props { + statusCode: string; +} +export const ResponseCodeColumn = ({ statusCode }: Props) => { + return ( + + {statusCode} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx index da82d025f478b..30d3783dd683d 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { PingListComponent } from './ping_list'; export { PingList } from './ping_list'; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/location_name.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/location_name.tsx index e9c5b243f7a09..a6a6773ab2254 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/location_name.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/location_name.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon, EuiLink, EuiText } from '@elastic/eui'; +import { EuiLink, EuiText } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; @@ -24,7 +24,5 @@ export const LocationName = ({ location }: LocationNameProps) => description: 'Text that instructs the user to navigate to our docs to add a geographic location to their data', })} -   - ); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx index 590b2f787bac4..75f261f1e42fa 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx @@ -4,192 +4,56 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBadge, - EuiBasicTable, - EuiFlexGroup, - EuiFlexItem, - EuiHealth, - EuiPanel, - EuiSelect, - EuiSpacer, - EuiText, - EuiTitle, - EuiFormRow, - EuiButtonIcon, -} from '@elastic/eui'; +import { EuiBasicTable, EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import moment from 'moment'; -import React, { useCallback, useContext, useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import styled from 'styled-components'; -import { useDispatch, useSelector } from 'react-redux'; -import { Ping, GetPingsParams, DateRange } from '../../../../common/runtime_types'; +import { useDispatch } from 'react-redux'; +import { Ping } from '../../../../common/runtime_types'; import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; import { LocationName } from './location_name'; import { Pagination } from '../../overview/monitor_list'; -import { PingListExpandedRowComponent } from './expanded_row'; -// import { PingListProps } from './ping_list_container'; import { pruneJourneyState } from '../../../state/actions/journey'; -import { selectPingList } from '../../../state/selectors'; -import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; -import { getPings as getPingsAction } from '../../../state/actions'; - -export interface PingListProps { - monitorId: string; -} - -export const PingList = (props: PingListProps) => { - const { - error, - loading, - pingList: { locations, pings, total }, - } = useSelector(selectPingList); +import { PingStatusColumn } from './columns/ping_status'; +import * as I18LABELS from './translations'; +import { MONITOR_TYPES } from '../../../../common/constants'; +import { ResponseCodeColumn } from './columns/response_code'; +import { ERROR_LABEL, LOCATION_LABEL, RES_CODE_LABEL, TIMESTAMP_LABEL } from './translations'; +import { ExpandRowColumn } from './columns/expand_row'; +import { PingErrorCol } from './columns/ping_error'; +import { PingTimestamp } from './columns/ping_timestamp'; +import { FailedStep } from './columns/failed_step'; +import { usePingsList } from './use_pings'; +import { PingListHeader } from './ping_list_header'; + +export const SpanWithMargin = styled.span` + margin-right: 16px; +`; - const { lastRefresh } = useContext(UptimeRefreshContext); +const DEFAULT_PAGE_SIZE = 10; - const { dateRangeStart: drs, dateRangeEnd: dre } = useContext(UptimeSettingsContext); +export const PingList = () => { + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [pageIndex, setPageIndex] = useState(0); const dispatch = useDispatch(); - const getPingsCallback = useCallback( - (params: GetPingsParams) => dispatch(getPingsAction(params)), - [dispatch] - ); + const pruneJourneysCallback = useCallback( (checkGroups: string[]) => dispatch(pruneJourneyState(checkGroups)), [dispatch] ); - return ( - - ); -}; - -export const AllLocationOption = { - 'data-test-subj': 'xpack.uptime.pingList.locationOptions.all', - text: 'All', - value: '', -}; - -export const toggleDetails = ( - ping: Ping, - expandedRows: Record, - setExpandedRows: (update: Record) => any -) => { - // If already expanded, collapse - if (expandedRows[ping.docId]) { - delete expandedRows[ping.docId]; - setExpandedRows({ ...expandedRows }); - return; - } - - // Otherwise expand this row - setExpandedRows({ - ...expandedRows, - [ping.docId]: , + const { error, loading, pings, total, failedSteps } = usePingsList({ + pageSize, + pageIndex, }); -}; - -const SpanWithMargin = styled.span` - margin-right: 16px; -`; - -interface Props extends PingListProps { - dateRange: DateRange; - error?: Error; - getPings: (props: GetPingsParams) => void; - pruneJourneysCallback: (checkGroups: string[]) => void; - lastRefresh: number; - loading: boolean; - locations: string[]; - pings: Ping[]; - total: number; -} - -const DEFAULT_PAGE_SIZE = 10; - -const statusOptions = [ - { - 'data-test-subj': 'xpack.uptime.pingList.statusOptions.all', - text: i18n.translate('xpack.uptime.pingList.statusOptions.allStatusOptionLabel', { - defaultMessage: 'All', - }), - value: '', - }, - { - 'data-test-subj': 'xpack.uptime.pingList.statusOptions.up', - text: i18n.translate('xpack.uptime.pingList.statusOptions.upStatusOptionLabel', { - defaultMessage: 'Up', - }), - value: 'up', - }, - { - 'data-test-subj': 'xpack.uptime.pingList.statusOptions.down', - text: i18n.translate('xpack.uptime.pingList.statusOptions.downStatusOptionLabel', { - defaultMessage: 'Down', - }), - value: 'down', - }, -]; - -export function rowShouldExpand(item: Ping) { - const errorPresent = !!item.error; - const httpBodyPresent = item.http?.response?.body?.bytes ?? 0 > 0; - const isBrowserMonitor = item.monitor.type === 'browser'; - return errorPresent || httpBodyPresent || isBrowserMonitor; -} - -export const PingListComponent = (props: Props) => { - const [selectedLocation, setSelectedLocation] = useState(''); - const [status, setStatus] = useState(''); - const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const [pageIndex, setPageIndex] = useState(0); - const { - dateRange: { from, to }, - error, - getPings, - pruneJourneysCallback, - lastRefresh, - loading, - locations, - monitorId, - pings, - total, - } = props; - - useEffect(() => { - getPings({ - dateRange: { - from, - to, - }, - location: selectedLocation, - monitorId, - index: pageIndex, - size: pageSize, - status: status !== 'all' ? status : '', - }); - }, [from, to, getPings, monitorId, lastRefresh, selectedLocation, pageIndex, pageSize, status]); const [expandedRows, setExpandedRows] = useState>({}); const expandedIdsToRemove = JSON.stringify( Object.keys(expandedRows).filter((e) => !pings.some(({ docId }) => docId === e)) ); + useEffect(() => { const parsed = JSON.parse(expandedIdsToRemove); if (parsed.length) { @@ -203,73 +67,62 @@ export const PingListComponent = (props: Props) => { const expandedCheckGroups = pings .filter((p: Ping) => Object.keys(expandedRows).some((f) => p.docId === f)) .map(({ monitor: { check_group: cg } }) => cg); + const expandedCheckGroupsStr = JSON.stringify(expandedCheckGroups); + useEffect(() => { pruneJourneysCallback(JSON.parse(expandedCheckGroupsStr)); }, [pruneJourneysCallback, expandedCheckGroupsStr]); - const locationOptions = !locations - ? [AllLocationOption] - : [AllLocationOption].concat( - locations.map((name) => ({ - text: name, - 'data-test-subj': `xpack.uptime.pingList.locationOptions.${name}`, - value: name, - })) - ); - const hasStatus = pings.reduce( (hasHttpStatus: boolean, currentPing) => hasHttpStatus || !!currentPing.http?.response?.status_code, false ); + const monitorType = pings?.[0]?.monitor.type; + const columns: any[] = [ { field: 'monitor.status', - name: i18n.translate('xpack.uptime.pingList.statusColumnLabel', { - defaultMessage: 'Status', - }), + name: I18LABELS.STATUS_LABEL, render: (pingStatus: string, item: Ping) => ( -
- - {pingStatus === 'up' - ? i18n.translate('xpack.uptime.pingList.statusColumnHealthUpLabel', { - defaultMessage: 'Up', - }) - : i18n.translate('xpack.uptime.pingList.statusColumnHealthDownLabel', { - defaultMessage: 'Down', - })} - - - {i18n.translate('xpack.uptime.pingList.recencyMessage', { - values: { fromNow: moment(item.timestamp).fromNow() }, - defaultMessage: 'Checked {fromNow}', - description: - 'A string used to inform our users how long ago Heartbeat pinged the selected host.', - })} - -
+ ), }, { align: 'left', field: 'observer.geo.name', - name: i18n.translate('xpack.uptime.pingList.locationNameColumnLabel', { - defaultMessage: 'Location', - }), + name: LOCATION_LABEL, render: (location: string) => , }, + ...(monitorType === MONITOR_TYPES.BROWSER + ? [ + { + align: 'left', + field: 'timestamp', + name: TIMESTAMP_LABEL, + render: (timestamp: string, item: Ping) => ( + + ), + }, + ] + : []), + // ip column not needed for browser type + ...(monitorType !== MONITOR_TYPES.BROWSER + ? [ + { + align: 'right', + dataType: 'number', + field: 'monitor.ip', + name: i18n.translate('xpack.uptime.pingList.ipAddressColumnLabel', { + defaultMessage: 'IP', + }), + }, + ] + : []), { - align: 'right', - dataType: 'number', - field: 'monitor.ip', - name: i18n.translate('xpack.uptime.pingList.ipAddressColumnLabel', { - defaultMessage: 'IP', - }), - }, - { - align: 'right', + align: 'center', field: 'monitor.duration.us', name: i18n.translate('xpack.uptime.pingList.durationMsColumnLabel', { defaultMessage: 'Duration', @@ -281,31 +134,33 @@ export const PingListComponent = (props: Props) => { }), }, { - align: hasStatus ? 'right' : 'center', field: 'error.type', - name: i18n.translate('xpack.uptime.pingList.errorTypeColumnLabel', { - defaultMessage: 'Error type', - }), - render: (errorType: string) => errorType ?? '-', + name: ERROR_LABEL, + width: '30%', + render: (errorType: string, item: Ping) => , }, + ...(monitorType === MONITOR_TYPES.BROWSER + ? [ + { + field: 'monitor.status', + align: 'left', + name: i18n.translate('xpack.uptime.pingList.columns.failedStep', { + defaultMessage: 'Failed step', + }), + render: (timestamp: string, item: Ping) => ( + + ), + }, + ] + : []), // Only add this column is there is any status present in list ...(hasStatus ? [ { field: 'http.response.status_code', align: 'right', - name: ( - - {i18n.translate('xpack.uptime.pingList.responseCodeColumnLabel', { - defaultMessage: 'Response code', - })} - - ), - render: (statusCode: string) => ( - - {statusCode} - - ), + name: {RES_CODE_LABEL}, + render: (statusCode: string) => , }, ] : []), @@ -313,23 +168,13 @@ export const PingListComponent = (props: Props) => { align: 'right', width: '24px', isExpander: true, - render: (item: Ping) => { - return ( - toggleDetails(item, expandedRows, setExpandedRows)} - disabled={!rowShouldExpand(item)} - aria-label={ - expandedRows[item.docId] - ? i18n.translate('xpack.uptime.pingList.collapseRow', { - defaultMessage: 'Collapse', - }) - : i18n.translate('xpack.uptime.pingList.expandRow', { defaultMessage: 'Expand' }) - } - iconType={expandedRows[item.docId] ? 'arrowUp' : 'arrowDown'} - /> - ); - }, + render: (item: Ping) => ( + + ), }, ]; @@ -338,63 +183,12 @@ export const PingListComponent = (props: Props) => { pageIndex, pageSize, pageSizeOptions: [10, 25, 50, 100], - /** - * we're not currently supporting pagination in this component - * so the first page is the only page - */ totalItemCount: total, }; return ( - -

- -

-
- - - - - { - setStatus(selected.target.value); - }} - /> - - - - - { - setSelectedLocation(selected.target.value); - }} - /> - - - + { setPageSize(criteria.page!.size); setPageIndex(criteria.page!.index); }} + tableLayout={'auto'} />
); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_header.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_header.tsx new file mode 100644 index 0000000000000..2912191c6eac8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_header.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { StatusFilter } from '../../overview/monitor_list/status_filter'; +import { FilterGroup } from '../../overview/filter_group'; + +export const PingListHeader = () => { + return ( + + + +

+ +

+
+
+ + + + + + +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/translations.ts b/x-pack/plugins/uptime/public/components/monitor/ping_list/translations.ts new file mode 100644 index 0000000000000..575d1f0d2590f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const STATUS_LABEL = i18n.translate('xpack.uptime.pingList.statusColumnLabel', { + defaultMessage: 'Status', +}); + +export const RES_CODE_LABEL = i18n.translate('xpack.uptime.pingList.responseCodeColumnLabel', { + defaultMessage: 'Response code', +}); +export const ERROR_TYPE_LABEL = i18n.translate('xpack.uptime.pingList.errorTypeColumnLabel', { + defaultMessage: 'Error type', +}); +export const ERROR_LABEL = i18n.translate('xpack.uptime.pingList.errorColumnLabel', { + defaultMessage: 'Error', +}); + +export const LOCATION_LABEL = i18n.translate('xpack.uptime.pingList.locationNameColumnLabel', { + defaultMessage: 'Location', +}); + +export const TIMESTAMP_LABEL = i18n.translate('xpack.uptime.pingList.timestampColumnLabel', { + defaultMessage: 'Timestamp', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/use_pings.ts b/x-pack/plugins/uptime/public/components/monitor/ping_list/use_pings.ts new file mode 100644 index 0000000000000..0f970b83be4cb --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/use_pings.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useDispatch, useSelector } from 'react-redux'; +import { useCallback, useContext, useEffect } from 'react'; +import { selectPingList } from '../../../state/selectors'; +import { GetPingsParams, Ping } from '../../../../common/runtime_types/ping'; +import { getPings as getPingsAction } from '../../../state/actions'; +import { useGetUrlParams, useMonitorId } from '../../../hooks'; +import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; +import { useFetcher } from '../../../../../observability/public'; +import { fetchJourneysFailedSteps } from '../../../state/api/journey'; +import { useSelectedFilters } from '../../../hooks/use_selected_filters'; +import { MONITOR_TYPES } from '../../../../common/constants'; + +interface Props { + pageSize: number; + pageIndex: number; +} + +export const usePingsList = ({ pageSize, pageIndex }: Props) => { + const { + error, + loading, + pingList: { pings, total }, + } = useSelector(selectPingList); + + const { lastRefresh } = useContext(UptimeRefreshContext); + + const { dateRangeStart: from, dateRangeEnd: to } = useContext(UptimeSettingsContext); + + const { statusFilter } = useGetUrlParams(); + + const { selectedLocations } = useSelectedFilters(); + + const dispatch = useDispatch(); + + const monitorId = useMonitorId(); + + const getPings = useCallback((params: GetPingsParams) => dispatch(getPingsAction(params)), [ + dispatch, + ]); + + useEffect(() => { + getPings({ + monitorId, + dateRange: { + from, + to, + }, + locations: JSON.stringify(selectedLocations), + index: pageIndex, + size: pageSize, + status: statusFilter !== 'all' ? statusFilter : '', + }); + }, [ + from, + to, + getPings, + monitorId, + lastRefresh, + pageIndex, + pageSize, + statusFilter, + selectedLocations, + ]); + + const { data } = useFetcher(() => { + if (pings?.length > 0 && pings.find((ping) => ping.monitor.type === MONITOR_TYPES.BROWSER)) + return fetchJourneysFailedSteps({ + checkGroups: pings.map((ping: Ping) => ping.monitor.check_group!), + }); + }, [pings]); + + return { error, loading, pings, total, failedSteps: data }; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap index 7cc96a42411d2..d722ed34388ed 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap @@ -13,17 +13,17 @@ Array [ class="euiSpacer euiSpacer--l" />, .c0.c0.c0 { - width: 35%; + width: 30%; + max-width: 250px; } .c1.c1.c1 { - width: 65%; + width: 70%; overflow-wrap: anywhere; }
- - , .c0.c0.c0 { - width: 65%; + width: 70%; overflow-wrap: anywhere; } @@ -57,7 +58,8 @@ Array [ exports[`SSL Certificate component renders null if invalid date 1`] = ` Array [ .c0.c0.c0 { - width: 35%; + width: 30%; + max-width: 250px; }
, .c0.c0.c0 { - width: 65%; + width: 70%; overflow-wrap: anywhere; } diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/availability_reporting.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/availability_reporting.test.tsx.snap index 316188eebf65b..f76f37a6e7fa7 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/availability_reporting.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/availability_reporting.test.tsx.snap @@ -105,7 +105,7 @@ Array [ > - -

- au-heartbeat -

-
+ au-heartbeat
@@ -182,7 +176,7 @@ Array [ > - -

- nyc-heartbeat -

-
+ nyc-heartbeat
@@ -259,7 +247,7 @@ Array [ > - -

- spa-heartbeat -

-
+ spa-heartbeat
diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/location_status_tags.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/location_status_tags.test.tsx.snap index 84290ec02a64f..6dde46fe18953 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/location_status_tags.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/location_status_tags.test.tsx.snap @@ -11,21 +11,21 @@ exports[`LocationStatusTags component renders properly against props 1`] = ` "color": "#d3dae6", "label": "Berlin", "status": "up", - "timestamp": "1 Mon ago", + "timestamp": "Sept 4, 2020 9:31:38 AM", }, Object { "availability": 100, "color": "#bd271e", "label": "Berlin", "status": "down", - "timestamp": "1 Mon ago", + "timestamp": "Sept 4, 2020 9:31:38 AM", }, Object { "availability": 100, "color": "#d3dae6", "label": "Islamabad", "status": "up", - "timestamp": "1 Mon ago", + "timestamp": "Sept 4, 2020 9:31:38 AM", }, ] } @@ -142,7 +142,7 @@ exports[`LocationStatusTags component renders when all locations are down 1`] = > - -

- Berlin -

-
+ Berlin
@@ -195,7 +189,7 @@ exports[`LocationStatusTags component renders when all locations are down 1`] = - 5m ago + Sept 4, 2020 9:31:38 AM
@@ -219,7 +213,7 @@ exports[`LocationStatusTags component renders when all locations are down 1`] = > - -

- Islamabad -

-
+ Islamabad
@@ -272,7 +260,7 @@ exports[`LocationStatusTags component renders when all locations are down 1`] = - 5s ago + Sept 4, 2020 9:31:38 AM
@@ -392,7 +380,7 @@ exports[`LocationStatusTags component renders when all locations are up 1`] = ` > - -

- Berlin -

-
+ Berlin
@@ -445,7 +427,7 @@ exports[`LocationStatusTags component renders when all locations are up 1`] = ` - 5d ago + Sept 4, 2020 9:31:38 AM
@@ -469,7 +451,7 @@ exports[`LocationStatusTags component renders when all locations are up 1`] = ` > - -

- Islamabad -

-
+ Islamabad
@@ -522,7 +498,7 @@ exports[`LocationStatusTags component renders when all locations are up 1`] = ` - 5s ago + Sept 4, 2020 9:31:38 AM
@@ -642,7 +618,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = > - -

- Berlin -

-
+ Berlin
@@ -695,7 +665,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = - 5m ago + Sept 4, 2020 9:31:38 AM
@@ -719,7 +689,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = > - -

- Islamabad -

-
+ Islamabad
@@ -772,7 +736,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = - 5s ago + Sept 4, 2020 9:31:38 AM
@@ -796,7 +760,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = > - -

- New York -

-
+ New York
@@ -849,7 +807,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = - 1 Mon ago + Sept 4, 2020 9:31:38 AM
@@ -873,7 +831,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = > - -

- Paris -

-
+ Paris
@@ -926,7 +878,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = - 5 Yr ago + Sept 4, 2020 9:31:38 AM
@@ -950,7 +902,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = > - -

- Sydney -

-
+ Sydney
@@ -1003,7 +949,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = - 5 Yr ago + Sept 4, 2020 9:31:38 AM
diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/tag_label.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/tag_label.test.tsx.snap index 28f1f433648c8..2e55e7024f444 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/tag_label.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/tag_label.test.tsx.snap @@ -18,7 +18,7 @@ exports[`TagLabel component renders correctly against snapshot 1`] = ` > - -

- US-East -

-
+ US-East
@@ -42,15 +36,9 @@ exports[`TagLabel component renders correctly against snapshot 1`] = ` exports[`TagLabel component shallow render correctly against snapshot 1`] = ` - -

- US-East -

-
+ US-East
`; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/location_status_tags.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/location_status_tags.test.tsx index 72919ff3c41bf..265b7f7459e22 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/location_status_tags.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/location_status_tags.test.tsx @@ -5,10 +5,12 @@ */ import React from 'react'; -import moment from 'moment'; import { renderWithIntl, shallowWithIntl } from '@kbn/test/jest'; import { MonitorLocation } from '../../../../../../common/runtime_types/monitor'; import { LocationStatusTags } from '../index'; +import { mockMoment } from '../../../../../lib/helper/test_helpers'; + +mockMoment(); jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { return { @@ -24,21 +26,21 @@ describe('LocationStatusTags component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'w').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'w').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 0, down: 2 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'w').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, @@ -52,56 +54,56 @@ describe('LocationStatusTags component', () => { { summary: { up: 0, down: 1 }, geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 's').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'm').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'h').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Tokyo', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'd').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'New York', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'w').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Toronto', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'M').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Sydney', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'y').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Paris', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'y').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, @@ -115,14 +117,14 @@ describe('LocationStatusTags component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 's').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'd').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, @@ -136,14 +138,14 @@ describe('LocationStatusTags component', () => { { summary: { up: 0, down: 2 }, geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 's').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, { summary: { up: 0, down: 2 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, - timestamp: moment().subtract('5', 'm').toISOString(), + timestamp: 'Oct 26, 2020 7:49:20 AM', up_history: 4, down_history: 0, }, diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/location_status_tags.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/location_status_tags.tsx index b48252d4208d2..c02251e0a8caa 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/location_status_tags.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/location_status_tags.tsx @@ -11,6 +11,7 @@ import { UptimeThemeContext } from '../../../../contexts'; import { MonitorLocation } from '../../../../../common/runtime_types'; import { SHORT_TIMESPAN_LOCALE, SHORT_TS_LOCALE } from '../../../../../common/constants'; import { AvailabilityReporting } from '../index'; +import { getShortTimeStamp } from '../../../overview/monitor_list/columns/monitor_status_column'; // Set height so that it remains within panel, enough height to display 7 locations tags const TagContainer = styled.div` @@ -46,7 +47,7 @@ export const LocationStatusTags = ({ locations }: Props) => { locations.forEach((item: MonitorLocation) => { allLocations.push({ label: item.geo.name!, - timestamp: moment(new Date(item.timestamp).valueOf()).fromNow(), + timestamp: getShortTimeStamp(moment(new Date(item.timestamp).valueOf())), color: item.summary.down === 0 ? gray : danger, availability: (item.up_history / (item.up_history + item.down_history)) * 100, status: item.summary.down === 0 ? 'up' : 'down', diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/tag_label.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/tag_label.tsx index ec5718415595d..67b025555afba 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/tag_label.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/tag_label.tsx @@ -6,8 +6,9 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiBadge, EuiTextColor } from '@elastic/eui'; +import { EuiBadge } from '@elastic/eui'; import { StatusTag } from './location_status_tags'; +import { STATUS } from '../../../../../common/constants'; const BadgeItem = styled.div` white-space: nowrap; @@ -21,11 +22,7 @@ const BadgeItem = styled.div` export const TagLabel: React.FC = ({ color, label, status }) => { return ( - - -

{label}

-
-
+ {label}
); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/status_bar.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/status_bar.tsx index 029ca98ae6fc8..704a79462efc3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/status_bar.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/status_bar.tsx @@ -8,7 +8,6 @@ import React from 'react'; import styled from 'styled-components'; import { EuiLink, - EuiIcon, EuiSpacer, EuiDescriptionList, EuiDescriptionListTitle, @@ -27,13 +26,14 @@ import { MonitorRedirects } from './monitor_redirects'; export const MonListTitle = styled(EuiDescriptionListTitle)` &&& { - width: 35%; + width: 30%; + max-width: 250px; } `; export const MonListDescription = styled(EuiDescriptionListDescription)` &&& { - width: 65%; + width: 70%; overflow-wrap: anywhere; } `; @@ -53,12 +53,7 @@ export const MonitorStatusBar: React.FC = () => {
- + {OverallAvailability} { {URL_LABEL} - - {full} + + {full} {MonitorIDLabel} diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/translations.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/translations.ts index 53c4a9eaeae49..618a88f2bf67a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/translations.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/translations.ts @@ -13,17 +13,6 @@ export const healthStatusMessageAriaLabel = i18n.translate( } ); -export const upLabel = i18n.translate('xpack.uptime.monitorStatusBar.healthStatusMessage.upLabel', { - defaultMessage: 'Up', -}); - -export const downLabel = i18n.translate( - 'xpack.uptime.monitorStatusBar.healthStatusMessage.downLabel', - { - defaultMessage: 'Down', - } -); - export const typeLabel = i18n.translate('xpack.uptime.monitorStatusBar.type.label', { defaultMessage: 'Type', }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx index 735e79f565797..3bec9116ef99f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx @@ -56,7 +56,6 @@ describe('StepScreenshotDisplayProps', () => { panelPaddingSize="m" >
{ panelPaddingSize="m" >
= ({ values: string[]; }>({ fieldName: '', values: [] }); - const { selectedLocations, selectedPorts, selectedSchemes, selectedTags } = useFilterUpdate( - updatedFieldValues.fieldName, - updatedFieldValues.values - ); + useFilterUpdate(updatedFieldValues.fieldName, updatedFieldValues.values); + + const { selectedLocations, selectedPorts, selectedSchemes, selectedTags } = useSelectedFilters(); const onFilterFieldChange = (fieldName: string, values: string[]) => { setUpdatedFieldValues({ fieldName, values }); }; + const isMonitorPage = useRouteMatch(MONITOR_ROUTE); + const filterPopoverProps: FilterPopoverProps[] = [ { loading, @@ -51,36 +55,41 @@ export const FilterGroupComponent: React.FC = ({ selectedItems: selectedLocations, title: filterLabels.LOCATION, }, - { - loading, - onFilterFieldChange, - fieldName: 'url.port', - id: 'port', - disabled: ports.length === 0, - items: ports.map((p: number) => p.toString()), - selectedItems: selectedPorts, - title: filterLabels.PORT, - }, - { - loading, - onFilterFieldChange, - fieldName: 'monitor.type', - id: 'scheme', - disabled: schemes.length === 0, - items: schemes, - selectedItems: selectedSchemes, - title: filterLabels.SCHEME, - }, - { - loading, - onFilterFieldChange, - fieldName: 'tags', - id: 'tags', - disabled: tags.length === 0, - items: tags, - selectedItems: selectedTags, - title: filterLabels.TAGS, - }, + // on monitor page we only display location filter in ping list + ...(!isMonitorPage + ? [ + { + loading, + onFilterFieldChange, + fieldName: 'url.port', + id: 'port', + disabled: ports.length === 0, + items: ports.map((p: number) => p.toString()), + selectedItems: selectedPorts, + title: filterLabels.PORT, + }, + { + loading, + onFilterFieldChange, + fieldName: 'monitor.type', + id: 'scheme', + disabled: schemes.length === 0, + items: schemes, + selectedItems: selectedSchemes, + title: filterLabels.SCHEME, + }, + { + loading, + onFilterFieldChange, + fieldName: 'tags', + id: 'tags', + disabled: tags.length === 0, + items: tags, + selectedItems: selectedTags, + title: filterLabels.TAGS, + }, + ] + : []), ]; return ( diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx index 5b76e6c5e371f..c758f7d2c1076 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx @@ -18,9 +18,9 @@ import { SHORT_TS_LOCALE, } from '../../../../../common/constants'; -import * as labels from '../translations'; import { UptimeThemeContext } from '../../../../contexts'; import { euiStyled } from '../../../../../../observability/public'; +import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../../../common/translations'; interface MonitorListStatusColumnProps { status: string; @@ -37,9 +37,9 @@ const StatusColumnFlexG = styled(EuiFlexGroup)` export const getHealthMessage = (status: string): string | null => { switch (status) { case STATUS.UP: - return labels.UP; + return STATUS_UP_LABEL; case STATUS.DOWN: - return labels.DOWN; + return STATUS_DOWN_LABEL; default: return null; } diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/most_recent_error.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/most_recent_error.test.tsx.snap index 0392e0dc879ec..ec980595abbff 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/most_recent_error.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/most_recent_error.test.tsx.snap @@ -18,7 +18,7 @@ exports[`MostRecentError component renders properly with mock data 1`] = ` > Get https://expired.badssl.com: x509: certificate has expired or is not yet valid diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/most_recent_error.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/most_recent_error.tsx index d611278d91033..7cf24d447316c 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/most_recent_error.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/most_recent_error.tsx @@ -35,7 +35,7 @@ interface MostRecentErrorProps { export const MostRecentError = ({ error, monitorId, timestamp }: MostRecentErrorProps) => { const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = useGetUrlParams(); - params.selectedPingStatus = 'down'; + params.statusFilter = 'down'; const linkParameters = stringifyUrlParams(params, true); const timestampStr = timestamp ? moment(new Date(timestamp).valueOf()).fromNow() : ''; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/status_filter.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/status_filter.tsx index 266735be77498..995ca13da0b50 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/status_filter.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/status_filter.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { EuiFilterGroup } from '@elastic/eui'; import { FilterStatusButton } from './filter_status_button'; import { useGetUrlParams } from '../../../hooks'; +import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../../common/translations'; export const StatusFilter: React.FC = () => { const { statusFilter } = useGetUrlParams(); @@ -28,18 +29,14 @@ export const StatusFilter: React.FC = () => { isActive={statusFilter === ''} />
- {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","pagination":"foo","focusConnectorField":false} + {"pagination":"foo","absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","statusFilter":"","focusConnectorField":false}