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 e86d7cbb36435a..a9dfd84cf0b427 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 00000000000000..f0951844849927 --- /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 00000000000000..7bfb1bcbd8cd79 --- /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 83831f65bd41a7..7b774e14b640f0 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 39c8b0a700c8a3..4934672d75f31b 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 bacd93f585adcf..4b3512ae3056b5 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 fc6182aaf2dca0..93a72553b45511 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" @@ -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 8b7475680ecf57..e03e88b1ded027 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 9236c83f9c921c..fae14529a4af3d 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 4ae8d7b7f9aa51..3b50be4b54bcb7 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 28347f379150f6..81b253a6ceae11 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 f75c86f5100952..739e4d89f9fb7b 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 56ad7de8588491..472fcb601118a0 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 f5d084da6a4e0e..7ff982acafbe42 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 9e7d7ffe45134f..679c2aba478558 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 c62b3f2afc14d6..eb9b7a4a35dc72 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 84f8da2a724567..1585627daa880e 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 50a8d4c7f7f012..2c222f4961859f 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 85d16a79a467c3..00000000000000 --- 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 53984fd12cbf17..00000000000000 --- 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 1d2986e742527d..00000000000000 --- 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 b0f7cded938ddc..00000000000000 --- 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 8196cad4a99c73..00000000000000 --- 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 e775f71442a771..00000000000000 --- 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 26b2a643e53737..00000000000000 --- 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 804898284491d0..00000000000000 --- 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 0b27fafcef84eb..00000000000000 --- 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 3a032d415e5f20..00000000000000 --- 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 61f880d80633da..a070ba09207ad8 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 30114f533aa30d..935bf09d93a042 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 9fbea8f1951225..d2a72a896c2d9e 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 e483385b5b9e8e..d2ee99d380827c 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/server/bootstrap.ts b/src/core/server/bootstrap.ts index 6711a8b8987e58..f7dd2a4ea24f5e 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 3c51fd6869a093..05a13bc55f97eb 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 fe19ef9d0a774e..98532d720c3100 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 4ae6c9d4375760..6da5d548698013 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 e5f0e8abd3b715..561f9bc001e30a 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 145dd286c1ca8d..82999eeceb8875 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 8b3eebceb2c5a8..e59b1a68e1ad1b 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 6a3defb9556f51..a19b4cc01db8ed 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 dae6a8d19dae20..587a0e51ef9b9c 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 ac06ca92757831..062a68e2dca28a 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 69abc370892184..b59829cb4978aa 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 d877fc36d114bf..770048d2cff138 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 3161420b94d22d..4ff845596f741d 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 b0ace3c63d82ed..710e504e588685 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/cli_dev_mode/README.md b/src/dev/cli_dev_mode/README.md new file mode 100644 index 00000000000000..397017027a52f1 --- /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 00000000000000..b86100d161bd31 --- /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 00000000000000..3cb97b08b75c22 --- /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 00000000000000..792125f4f85b15 --- /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 00000000000000..da64c680a3c2d9 --- /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 e3eabc8d91fa50..219c05647b2dc0 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 00000000000000..ec0d5d013a7822 --- /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 00000000000000..7fe05c649b7383 --- /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 28e14d28eecd37..92714c3740e9ad 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 af73059c0758e6..f349026ca9cabf 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 00000000000000..8a82012499b332 --- /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 00000000000000..9aac414f02b299 --- /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 55577e3a69e2b7..f51b3743e02105 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 1630a4547b7a14..ba13932e602313 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 00000000000000..1e320de83588bf --- /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 00000000000000..23423fcacb2fcb --- /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 00000000000000..59dbab52a0cf6e --- /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 00000000000000..95cf86d2c332da --- /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 85d75b4e18772c..14f083acd42c25 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/data/common/index_patterns/index_patterns/index_patterns.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts index 2e9c27735a8d1f..2a203b57d201b2 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts @@ -191,6 +191,20 @@ describe('IndexPatterns', () => { 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 d9eccb6685ded7..46dc49a95d2044 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 1c07b4b99e4c09..9eced777a8e369 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 9cd5e5a4736f10..6c39457599c743 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 339a014b9d731d..16d4471bcac147 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -78,7 +78,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 +1335,6 @@ export const indexPatterns: { isFilterable: typeof isFilterable; isNestedField: typeof isNestedField; validate: typeof validateIndexPattern; - getFromSavedObject: typeof getFromSavedObject; flattenHitWrapper: typeof flattenHitWrapper; formatHitProvider: typeof formatHitProvider; }; @@ -2436,27 +2434,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/query_string_input/fetch_index_patterns.ts b/src/plugins/data/public/ui/query_string_input/fetch_index_patterns.ts index 127dc0f1f41d34..7d6b4dd7acaf23 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/query_string_input.test.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx index c26f1898a40845..021873be076d01 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx @@ -279,20 +279,16 @@ describe('QueryStringInput', () => { }); 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 a6d22ce3eb4734..ad6c60550c01e2 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/discover/public/application/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx index 2874e2483275b7..59ab032e6098d5 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 d57447eab9e26e..9c136e94a3d2a6 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 3ebf3c435916b3..e7d663158acc03 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/vars/workers.groovy b/vars/workers.groovy index b6ff5b27667dd6..a1d569595ab4b9 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -9,6 +9,8 @@ def label(size) { return 'docker && linux && immutable' case 's-highmem': return 'docker && tests-s' + case 'm-highmem': + return 'docker && linux && immutable && gobld/machineType:n1-highmem-8' case 'l': return 'docker && tests-l' case 'xl': @@ -132,7 +134,7 @@ def ci(Map params, Closure closure) { // Worker for running the current intake jobs. Just runs a single script after bootstrap. def intake(jobName, String script) { return { - ci(name: jobName, size: 's-highmem', ramDisk: true) { + ci(name: jobName, size: 'm-highmem', ramDisk: true) { withEnv(["JOB=${jobName}"]) { kibanaPipeline.notifyOnError { runbld(script, "Execute ${jobName}") diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 171f8d4b0b1d43..8b6c25e1c3f241 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -15,6 +15,8 @@ import { actionsConfigMock } from './actions_config.mock'; import { getActionsConfigurationUtilities } from './actions_config'; import { licenseStateMock } from './lib/license_state.mock'; import { licensingMock } from '../../licensing/server/mocks'; +import { httpServerMock } from '../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../security/server/audit/index.mock'; import { elasticsearchServiceMock, @@ -22,17 +24,23 @@ import { } from '../../../../src/core/server/mocks'; import { actionExecutorMock } from './lib/action_executor.mock'; import uuid from 'uuid'; -import { KibanaRequest } from 'kibana/server'; import { ActionsAuthorization } from './authorization/actions_authorization'; import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; +jest.mock('../../../../src/core/server/saved_objects/service/lib/utils', () => ({ + 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 0d41b520501add..ab693dc340c927 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 00000000000000..6c2fd99c2eafdb --- /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 00000000000000..7d25b5c0cd479a --- /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 e61936321b8e0a..6e37d4bd7a92aa 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 c83e24c5a45f4f..d697817be734b7 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 00000000000000..9cd48248320c08 --- /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 00000000000000..f3e39598240847 --- /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 dcbb33d849405a..b943a21ba9bb63 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 e7b975aec8eb0b..a7ef008eaa2ee7 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 8c9ab9494a50af..ce0688a5ab2ff4 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 feec1d1b9334a1..daac6689a183b2 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 336cb536d702b5..232d48e258256a 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 3f0c783f424d13..32ac57459795eb 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 14ebca21355879..b3c3e1bdd2ede4 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 c2188f128cb4d5..ec69dbdeac55f9 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 d92304ab873be7..fd0157091e3a53 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 3486df98f2f057..c7d084a01a2a0e 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 b42ee096777fe8..15fb1e2ec0092c 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 ca5f44078f5138..bf21256bb8413e 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 069703be72f8a5..9d71b5f817b2c0 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/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index bebd5bdabbae37..309cde4dd9f650 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 d90fe393c94a45..a633341ba2bb45 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 6b02a44dcc2f46..e4260a2533d36f 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 c14c31afe0445e..bc73a3acf41356 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 9774538b2a7a7e..ade0a0563b0dc3 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 78883ec2cf0d39..0ca2867852f262 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/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 ff744d763ecaed..81840dc52c1eca 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 06a5e7baef79b9..4a388b13d7d224 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 f5105e38b985e2..406a1a46335770 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 74222e8ffe038c..b8968031e6922d 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 965cc28952b7ab..ff09855e63a8fc 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 fec59393726bf6..92f0abcfb77e77 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 7e1aad075fb161..00000000000000 --- 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 4f7f6320185bf9..0e066a1959c495 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 64864ec2258bae..c4bc70a92d9eed 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 4c5738ecef5815..a82f1b64d55379 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 58c1ce3451a29f..11d247ccab84ff 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/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 52e4a15a3f445f..9b99bf0e54cc2b 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -15,12 +15,24 @@ import { CaseConnectorRt, ESCaseConnector, ConnectorPartialFieldsRt } from '../c // eslint-disable-next-line @kbn/eslint/no-restricted-paths export { ActionTypeExecutorResult } from '../../../../actions/server/types'; -const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); +export enum CaseStatuses { + open = 'open', + 'in-progress' = 'in-progress', + closed = 'closed', +} + +const CaseStatusRt = rt.union([ + rt.literal(CaseStatuses.open), + rt.literal(CaseStatuses['in-progress']), + rt.literal(CaseStatuses.closed), +]); + +export const caseStatuses = Object.values(CaseStatuses); const CaseBasicRt = rt.type({ connector: CaseConnectorRt, description: rt.string, - status: StatusRt, + status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, }); @@ -68,7 +80,7 @@ export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; export const CasesFindRequestRt = rt.partial({ tags: rt.union([rt.array(rt.string), rt.string]), - status: StatusRt, + status: CaseStatusRt, reporters: rt.union([rt.array(rt.string), rt.string]), defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), fields: rt.array(rt.string), @@ -177,7 +189,6 @@ export type CasesResponse = rt.TypeOf; 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 984181da8cdeea..b812126dc1eab6 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 d82979de2cb446..e09ce226b3125d 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 10eebd1210a9e6..ae701f16b2bcb6 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 a754ce27c5e41b..406e43a74cccfc 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 90bb1d604e7330..adf94661216cbc 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 4c0b5887ca9988..95856dd75d0ae8 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 b2ba8b2fcb33ab..dca94589bf72ad 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 e70225456d5a83..b034e86b4f0d48 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 ea69ee77c58020..053f9ec18ab0f8 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 1e1b19baa1c479..508684b4228917 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 6ba2da111090f7..6a6b09dc3f87ab 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 8f86dbc91f315d..4379a6b56367c0 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 a67bae5ed74dc9..7654ae5ff0d1af 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 589d7c02a7be60..c8753772648c29 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 f1e06a0cec03df..f528843cf9ea36 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 398a64585411a7..849a2888b6e1a5 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 0138e929ca1caf..c692d8698771fe 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 6bc4a392064e43..88d57072697fe6 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 8d2ebb575c35e0..1f1093a179538c 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 3c722ccfabae24..85ec08fb7388d0 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 ddef9f477433ca..313e7c7da9eba2 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/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 0cad0b4d487d0e..f89b8b53a18786 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 a19f6658ef93fd..81195bdeaa9e26 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/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 be953ded70d799..b0e6ab751f02e1 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 ce800a7d737001..81ca7d1dcd27f6 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 63004072c08d05..ad4a48635d3765 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 789658c0604032..a295d8293632f4 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 c3e47b6084eb21..7f0ca2b6e262a3 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 7386fa64aca9cf..6ff31e86c9d5e1 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 53d94f24d616ca..7402a712793faa 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 007d833e97e9d0..b3e6f68b0a68c8 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 382178a14793bf..9a1d7b23fa3dd3 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 913b3966225181..e42d4daffbb66d 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/maps/common/descriptor_types/style_property_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts index d52afebcaa2549..9ab965c3eb8fe1 100644 --- a/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts @@ -254,3 +254,8 @@ export type DynamicStylePropertyOptions = | LabelDynamicOptions | OrientationDynamicOptions | SizeDynamicOptions; + +export type DynamicStyleProperties = { + type: STYLE_TYPE.DYNAMIC; + options: DynamicStylePropertyOptions; +}; diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index c8c9f6ba400410..f4cc1f3601f56c 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -45,6 +45,8 @@ import { IVectorLayer } from '../classes/layers/vector_layer/vector_layer'; import { LAYER_STYLE_TYPE, LAYER_TYPE } from '../../common/constants'; import { IVectorStyle } from '../classes/styles/vector/vector_style'; import { notifyLicensedFeatureUsage } from '../licensed_features'; +import { IESAggField } from '../classes/fields/agg'; +import { IField } from '../classes/fields/field'; export function trackCurrentLayerState(layerId: string) { return { @@ -274,6 +276,24 @@ export function updateLayerOrder(newLayerOrder: number[]) { }; } +function updateMetricsProp(layerId: string, value: unknown) { + return async ( + dispatch: ThunkDispatch, + 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 a4562c91e92a6c..ff6dbbce6f095a 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 e3d62afaca9218..cc8e3b46753080 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 658c2bba878479..9cb7debd320a15 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/style_fields_helper.test.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.test.ts new file mode 100644 index 00000000000000..9556862842e82f --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_fields_helper.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FIELD_ORIGIN, VECTOR_STYLES } from '../../../../common/constants'; +import { createStyleFieldsHelper, StyleFieldsHelper } from './style_fields_helper'; +import { AbstractField, IField } from '../../fields/field'; + +class MockField extends AbstractField { + private readonly _dataType: string; + private readonly _supportsAutoDomain: boolean; + constructor({ dataType, supportsAutoDomain }: { dataType: string; supportsAutoDomain: boolean }) { + super({ fieldName: 'foobar_' + dataType, origin: FIELD_ORIGIN.SOURCE }); + this._dataType = dataType; + this._supportsAutoDomain = supportsAutoDomain; + } + async getDataType() { + return this._dataType; + } + + supportsAutoDomain(): boolean { + return this._supportsAutoDomain; + } +} + +describe('StyleFieldHelper', () => { + 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 fbe643a401484e..d36cf575a9bd8f 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 1dbadc054c8a06..94090c8abfe4f8 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 2dc9ef612d8b24..1c36961aae1b1f 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/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 37e739d0066a00..fc103959381bcb 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 ea37ff7783ad70..4ca708e9d28322 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 2d59bfea72eb2b..f6df94f8de138b 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 5d6c38e19bef2a..57325673a131ae 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 a5d70511057976..73eea99467c592 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/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index 5a1cdfe867590c..27ea14e3e8ffe3 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -158,7 +158,7 @@ export class HeadlessChromiumDriver { ): Promise> { 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 efef323612322c..4b42e2cc594256 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 c22db895b451e0..61a268460bd1ba 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 caa25aab06287e..00000000000000 --- 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 798f926cd0a312..c945801dd49c24 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/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 6aba78c9360710..2e003b1d55eac5 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 04db65f88cda0a..d99fbc702a0782 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 c6f4ca6dd8afe6..15ca8bac89bd61 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 e6e34de4ac9abf..765274a839efab 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 c47ec703418453..cc7e8df757c1d2 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 b516f7c57a96dd..1b70a13935b7d4 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 364db54b4b5d9b..d934afec127c20 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 31d8e4666d91de..1cece57c2fea58 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 b32402851ac7c6..6716186cddd451 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 c19e51c3ada408..b84b668a28502a 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 f62db083172a40..8bfb6eba3e1fd6 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 9b3434b5521d4c..33e8cc40b12398 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 8dcb5e144c24ff..bf8a01f6cf072d 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 906fba28a7721c..3a941209de7366 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 dc0e764744f845..1b801f6a454591 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 02ec74aaed29c3..e9a258c70cb231 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 e49f5afa7bd0c4..967a56fc6f63de 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 d4eeeb036ee956..c6c1067825f16d 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 98e6502ffe94fe..ea0e132bf07b51 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 27d17f966d8fc8..c52ca0b968c372 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 9f385d9ccd2fcd..d927ac5cd9d2b3 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 dd01159e3029fa..eb03c56ef04e88 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 b101793385488b..10a2ff27666c05 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 6573457c5f39ad..3b64c1f7f1f656 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 9f7e2e73c5bbcf..96d118fea1f55d 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 42b97d5f6130f1..00873a497c9341 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 e301e80c9561d5..9ea39f5ca99b91 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 42a87de2aa07b9..05bc6d10d22a55 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 9b516f600e9e5b..0c9a725f918e54 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 63172bd6ad6bbd..f5ec0bf1441540 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 00000000000000..29c9e67c5b5695 --- /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 2d3a7850eb0b6b..945458e92bc8aa 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 00000000000000..bce738aa2a0295 --- /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 5cb6ede0d9d213..4dbfaa9669ecec 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 7ee2b856f8786a..a338f4af6cda3d 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 ac518a9cc2fb0c..c0e4d1ee1c3620 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 e7d5299842494f..00000000000000 --- 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 00000000000000..18aa683ed451b2 --- /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 00000000000000..50f2a17940edf5 --- /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 00000000000000..890091535ada14 --- /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 00000000000000..0d217dc87f6208 --- /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 00000000000000..c76f525ac09b12 --- /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 00000000000000..6cbc0d492f0209 --- /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 3b203e81cd0742..9b5a464bc22739 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 445ae675007cc1..f57009bccf956a 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 9bb79e88be138a..dc361d87bad0ae 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 9ac0507d52c0b2..15a01406c57248 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 6ac1ccb56f9606..975f9b76556c83 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 2abcb70d676ef9..533a55426831e8 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 228f3a4319c338..01709ae55f4831 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 b824619800035f..d498768a9f62aa 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 dcc31401564b1f..7d82bd98c2e431 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 0d2df7c2de3ea0..f60993fc9aa028 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 6046c3716b3b5b..5186dab6d62f55 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 c5b60041f5cacc..151d0953dcb8e1 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 b9db356498a01b..4458ee83f40d38 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 329fda10424a8f..777d1ef77bd6a5 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 c333ff4207833e..5a138f2a976677 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 7072363c1185d9..44166a14ad2927 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 4e274e074b036b..9b4bf966a14349 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 fdf526a1e4d887..e773a25237d0ac 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 bfbcbd2525e3b8..ac202c50cb2b7b 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 5260b6d5cc283b..896fda4f5e2552 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 313c71375111cc..6d0d9fa0f030dc 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 8ba4c4faf1876b..ad4fa4df645845 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 1d60310731d5e2..a79f7a3af18bf8 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 280b9111042d0e..93c4f957232890 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 175682aa43e76d..abbc1681288318 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 68032fb7dc512b..53e248fd41cf43 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 a300f253de08d6..ca8bb3d54f278b 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 d90a337bbeedf2..00000000000000 --- 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 45b75d0f33ac9c..7e0d5ac2a3a90b 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 d7e5a18dfb82e7..fb2bbffcad5603 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 9ca9cd6cce3893..8d807825c246a7 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 caa7853fd9ec0c..af9fc61b9585c6 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 35cb8f7b1c91f7..1a492eee4ae7a0 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 a2a7182a768ccc..92c3ff9b9fa97b 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 0acf461828bc37..e4365c4b7b2d85 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 79250ae9bec523..0dbdc98b6a8e9e 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 168fe6e65564d1..bf548d04e780bd 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 4730dc5c2264f1..00000000000000 --- 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 ad332b2759048c..b3a838ab088dfd 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 aac1f4f2687ebe..7132add229edb5 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 186083f1b05cdb..208d60ac73865c 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 58f81c9fb3c8bb..ec3cbbdef98ad8 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 c324b812a9ec23..0ec9926e7cf2ae 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 11623e13675747..7e8c93e86376a2 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 da5099f61e9b2d..36cdc807c4c0c9 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 102ed7851e57d2..158da3be3bbf78 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 b149488ff38a75..db3416866d89fd 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 5c69edbabdc665..f4ffc25146be5a 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 3eb66b5591b85e..509e3744f09eff 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/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index c0d540d01ee972..0dcd2b646b9e62 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 dbc194054d3a6d..22f3d067b1538a 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 7555f6e7342145..431a9b534fb911 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 acc01ac4f76aa8..0837614c7f82cd 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 34fb344eed3c4c..cd7fdefdfac6a4 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/super_date_picker/index.tsx b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx index 97e023176647f8..dae25d848fb5b0 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 fd1fa1c29a8075..b2fe8cc4e108a0 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 c49c7228e521a8..86769211d3ec16 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 f7ad35f2c5a371..f7703e166e7d89 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 4f0a71dcc3ebba..ac03e6c5c0018f 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 2be9d27b3fecb0..9932e52b6a1d14 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 7d081f357e1b6a..47b0b360f4b5d5 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 1e77ae77666301..fb1c6197e9708e 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 272d40a8cea2b6..bf5b6b17196050 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/wrapper_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx index 8eff52dae89f36..23f9a8a6bce012 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 00000000000000..9e1894e84bc491 --- /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 f2545c1642d493..00000000000000 --- 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 f245857f3d0db1..9bd375b897daf6 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 d9f2abeb3832e8..b7938a5f3d755f 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 e6c47c697c0b2e..cd08f8b256a1c7 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 b06a6ec10f48ef..cae05a61266bbb 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 ba375612b22a71..db414dfab5c095 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 0118004b48eb8e..d927fcb27e0995 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 d18cb73dbcfb98..59d783107e5870 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 5d6534f96bc7a9..b8bfa9ca554ff7 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 e6577f2461a9e9..c9b42931c5dcea 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 6ebc00133c0cdc..599cddb605148c 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 1a31e08fc3dbc2..00000000000000 --- 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 d1f5b40086ceaa..00000000000000 --- 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 92657df7f9bb5b..55258af7332e1a 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 e3defaea2ec673..54cdd636f7a33e 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 662f37b999fab4..fc7385f807cbe0 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 4cb2abe756cf35..8242b44acc2c9b 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 0982b5740b8936..9e629936db1e27 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 b39cd375216028..13be87846df809 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 afa4777e748567..88aff1455ab0e2 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 d04980d7648310..62f0d12fd67b15 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 242affbed29795..ed119568cdcb3a 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 88fd1ad5f98b0c..7d8a1a1eebdd03 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 b78d1a1f493bef..b8cf1bb3fbef6d 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 84003e5dea5e98..17794323cc4da9 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 7c51a503092afa..ead96f52a087fc 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 c7025bb489ae4e..f16ed8ceddf6f4 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 d964366dc5f3d2..87c0e6fd613f92 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 54381d1ffd8361..3f32d597b45f79 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 c1081d22e12a4b..f7899fe016571a 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 3564b9f4516d99..f0395a5064e2d9 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 ff4539fd379ed5..b810d4e724eecd 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 906a1d27165135..70cfd5fa957e74 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 821b2895ac3f94..12dc5ed3a267da 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 a8b46769b7363d..58474f05bb2b99 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 b341647afdfbc4..4a614cd0d1de5b 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 4835f7eff5b6fc..d54891ba573fd9 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 17dd20bac2d0d7..0a2513828a68a6 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 75cd36924dbba3..d0746bf78b249a 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/network/components/embeddables/embedded_map.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx index ac7c5078e4ba01..f2f6a01482ee0b 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 bf7cefd41463c9..c3147df4d989eb 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 0d5b379a62d386..1223926f35bbe7 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 6982388cafd9c2..2700ca711a4e6f 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 9b265aa002cccb..682d653db64cb2 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 b1789569bed757..e068540efff2ff 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 79590bdfa08702..0ae0259d24c374 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 7829449530829e..1df3cb31456539 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 8a80d073d4beb3..82a2c0257e550e 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 39868af2ae14dd..84aa128fd8e043 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 0cce4842809063..32abd5710c6b11 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 565504ca3ef09d..22120a56d2150b 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 6924f3202076bd..78ba96a140ac1d 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 0b14945bba9ff3..d2eae61a8212c4 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 aab90702de337a..6245b22d188b3b 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 8edb760429a7c8..a6ae4d73f6608c 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 fa9a6ac08e8129..d9ad4763177aa0 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 49ff6016900a51..d62fc7ce545c45 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 8abd91186465a8..ed7b3232809c68 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 75f28773b89f60..b4d671c4063347 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 bd563c2bd76177..4a97492312abaa 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 0a885193904863..47aeed99cde597 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 8a7d499a8ef5fa..65924e6b4be0f1 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 b8c53cdf10fee8..28a9aaf50dcff6 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 8d850a926f0937..4fc3b7bd01b2e1 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 01e5b6ae6cf12a..f9e30e30472d9f 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 4d3b2dbf3f11f6..4ab72afc3fb455 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 0ac136044c06dc..34722fd147a993 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 edf68750e2fdd9..dfa391e49913bb 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 c414276c1a6154..325d9a7965066e 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 a292ec3e1a1194..0f34734ebf861c 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 7addfaaf7c5fce..4a98630e31a735 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 14c17b7262724a..9b8207a5060bc2 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 9340ee8cf0c7f8..f65a884d95405d 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 3c9101878be8d2..563857e5a829f2 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 c2ddba6bd88c32..29debc52adb951 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 73ea7392168572..d47f1705b17222 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 916240ac411e5f..0bbf13aa074571 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 3bfeabc614ea95..381681898e27cf 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 f197d241cc4223..eb69310cae157b 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 2b9889ec13e794..345b0adfacd272 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 46c9fbb5240663..bbf09856936ca1 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 00000000000000..1bcae7f686333c --- /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 00000000000000..3b807ae296ca5c --- /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 00000000000000..f26c34fb5c073e --- /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 00000000000000..81fb42dd8d20b6 --- /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 00000000000000..1c0f2ba55de418 --- /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 1a1ee061799d26..00000000000000 --- 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 72fa20c9f152d9..00000000000000 --- 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 00000000000000..0b086610da82af --- /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 0737db7a007885..b22d071a97d125 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 f35193bfb8d6f2..ef9b88d65c551b 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 d0d7a1cd7f5d78..00000000000000 --- 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 cfdca8950d314f..00000000000000 --- 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 a4d9f0e8293dfc..00000000000000 --- 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 c163ab1ae448bd..5d118b357c8eff 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 f5ad6264f95e2d..a1e61b9fa4ae6d 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 4a314d76a51bf6..5c9123ed8810e5 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 fed6a39ae2ed54..46f3fc4a86413d 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 10eb1405158266..c112b40f908c1f 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 65210ab2fd60a5..f1021934750270 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 bddbd05aae999e..3d5e548e726e57 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 c3247c337ac3ab..74185c9a803ac9 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 53bc76bfeb8e86..00000000000000 --- 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 01dfd72a22db1b..98a10f2a1a0b5c 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 6ba62a115917f7..259cc2d0feb614 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 938bc0d222002f..a4622f58d34b4d 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 7d083735e6c718..1ba573c0ac6c3e 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_cards/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx index 731ff020457a23..8fd95feba60317 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 62d169b1169dd5..4ce4de18518635 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 20faf93616a8c2..5a1540b970300d 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 a0090baeb9923f..1ee529cc77a912 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 f6ac1ab4cec3e0..9ca5d0c7b438ae 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 e9ae66703f0178..ae5c7f39dbda69 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 ab07b4e7564769..adddb906572521 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 3c3ec1689b2447..00cd5453e96699 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 6081620a277749..00000000000000 --- 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 98faa84db851e6..4fbba4fca75d69 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 a82821675d9565..af8045bf624c37 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 13c2b14d26eca7..7772bcede76fc4 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 6e21446944573b..8bf9b6ceb346a7 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 3e5ce5a6b49995..517f537b9a01be 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 b211847d06a262..3ef9beb89309eb 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 1180eb8aed9675..15d75cc9a43849 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 6685ce7d7a018f..6919f7b123167f 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 f4d4cf29ba38bc..aeab6a774ca415 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 28a4bf6d8ac51e..f7efe758837ab3 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 0d37f25d66e3fe..32e2ae2141899b 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 ae552ade665cbc..693ea0502517c2 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 15d7d750257ac1..d37d5ec7be7e91 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 19d657b0537a5e..f6c178caa7fb8e 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 6c28c0ce16df1d..3d3c87be42824f 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 3ea7b8d471a44b..1d4cea700d003d 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 05a66c6853f6cc..a7e25a20e5e47e 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 3e03e9f37c0bca..00000000000000 --- 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 120b3ce165909d..00000000000000 --- 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 a8818517fb94b1..00000000000000 --- 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 ff3df357f73374..39a07e2c35504d 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 a7ae14dea510f7..4d6487feb98d23 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 b892ca089eb4c4..0a7b0e7ef4c298 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 fc06d37b9663fd..8f7138ff2f721b 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 4b6f3c6701794b..1f0b606c49da9a 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 00000000000000..87a870a5f933e0 --- /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 00000000000000..58729f69402e12 --- /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 4b595fad9be6f6..ed9b20f7a5e2db 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 8ab3a71604bf12..54755fbc84277b 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 77aee2c4bf0126..77a37d8b9a9298 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 a4c4679c820580..4acdab1b7c140c 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 cec889fe6ee34c..00000000000000 --- 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 35e7de29819731..00000000000000 --- 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 8c4858af9d61ff..6cfdeb9e0ced32 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 f56d7d90cf2df8..17d57b46d730cc 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 00000000000000..84ac74550ffd7a --- /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 66758268fb39eb..b6559114f6d2bb 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 329bcf24ba7ed6..13ac4ed782807c 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 22d28737e5d61e..248267fb2e052e 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 476ef8d1dd5a1a..f3bd4a88ca2368 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 3597b26e2663ac..eca889a788bcab 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 d2737de7e75dcf..085a9bf8cba3fa 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 baa62b629567da..6b27eea64aeb94 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 00000000000000..9855a0124b8f51 --- /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 dd0695e7953975..6eb9286871b68c 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 25039dbc9529ae..bc83d42d31c986 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 a6740a0cdb0f34..00000000000000 --- 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 9df2b585449a04..00000000000000 --- 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 b6e921ae9c0017..e7585c3ef06a86 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 b5aadaa6f1ef8e..e0c4aebb5d3969 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 6b181a5af7bf35..00000000000000 --- 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 3f02772b46bb39..00000000000000 --- 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 12eab4942128fc..00000000000000 --- 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 e4504d40bc0a77..7dc5b8601955ae 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 78d01b2d98ab3a..ad3aa4a4932e77 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 b4d168cc980b69..4043ceeb85b7e3 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 a07ea0273cd1ed..1226dabe485590 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 3b882c1e1bd14f..034c4c3ab3757a 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 00000000000000..c726e92455f251 --- /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 900699503a3bb7..4019f46b8c07bc 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 00000000000000..8186ee8b776285 --- /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 ? ( + + + + + +