From 0b876fe350ff6f875bb8b3eb770d4f06c32ac0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emilio=20Alvarez=20Pi=C3=B1eiro?= <95703246+emilioalvap@users.noreply.github.com> Date: Fri, 4 Mar 2022 17:51:07 +0100 Subject: [PATCH 01/20] Add Synthetics flaky test runner pipeline config (#126602) * Add flaky test runner pipeline config * Add default value to grep expression if not set * Add kibana build id override * Add concurreny option (limited) --- .buildkite/scripts/steps/functional/uptime.sh | 2 +- .../uptime/.buildkite/pipelines/flaky.js | 117 ++++++++++++++++++ .../uptime/.buildkite/pipelines/flaky.sh | 8 ++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/uptime/.buildkite/pipelines/flaky.js create mode 100755 x-pack/plugins/uptime/.buildkite/pipelines/flaky.sh diff --git a/.buildkite/scripts/steps/functional/uptime.sh b/.buildkite/scripts/steps/functional/uptime.sh index 5a59f4dfa48bd7..a1c8c2bf6c85b1 100755 --- a/.buildkite/scripts/steps/functional/uptime.sh +++ b/.buildkite/scripts/steps/functional/uptime.sh @@ -14,4 +14,4 @@ echo "--- Uptime @elastic/synthetics Tests" cd "$XPACK_DIR" checks-reporter-with-killswitch "Uptime @elastic/synthetics Tests" \ - node plugins/uptime/scripts/e2e.js --kibana-install-dir "$KIBANA_BUILD_LOCATION" + node plugins/uptime/scripts/e2e.js --kibana-install-dir "$KIBANA_BUILD_LOCATION" ${GREP:+--grep \"${GREP}\"} diff --git a/x-pack/plugins/uptime/.buildkite/pipelines/flaky.js b/x-pack/plugins/uptime/.buildkite/pipelines/flaky.js new file mode 100644 index 00000000000000..6e12f8ca3c921a --- /dev/null +++ b/x-pack/plugins/uptime/.buildkite/pipelines/flaky.js @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const { execSync } = require('child_process'); + +// List of steps generated dynamically from this jobs +const steps = []; +const pipeline = { + env: { + IGNORE_SHIP_CI_STATS_ERROR: 'true', + }, + steps: steps, +}; + +// Default config +const defaultCount = 25; +const maxCount = 500; +const defaultConcurrency = 25; +const maxConcurrency = 50; +const initialJobs = 2; + +const UUID = process.env.UUID; +const KIBANA_BUILD_ID = 'KIBANA_BUILD_ID'; +const BUILD_UUID = 'build'; + +// Metada keys, should match the ones specified in pipeline step configuration +const E2E_COUNT = 'e2e/count'; +const E2E_CONCURRENCY = 'e2e/concurrent'; +const E2E_GREP = 'e2e/grep'; +const E2E_ARTIFACTS_ID = 'e2e/build-id'; + +const env = getEnvFromMetadata(); + +const totalJobs = env[E2E_COUNT] + initialJobs; + +if (totalJobs > maxCount) { + console.error('+++ Too many steps'); + console.error( + `Buildkite builds can only contain 500 steps in total. Found ${totalJobs} in total. Make sure your test runs are less than ${ + maxCount - initialJobs + }` + ); + process.exit(1); +} + +// If build id is provided, export it so build step is skipped +pipeline.env[KIBANA_BUILD_ID] = env[E2E_ARTIFACTS_ID]; + +// Build job first +steps.push(getBuildJob()); +steps.push(getGroupRunnerJob(env)); + +console.log(JSON.stringify(pipeline, null, 2)); + +/*** + * Utils + */ + +function getBuildJob() { + return { + command: '.buildkite/scripts/steps/build_kibana.sh', + label: 'Build Kibana Distribution and Plugins', + agents: { queue: 'c2-8' }, + key: BUILD_UUID, + if: `build.env('${KIBANA_BUILD_ID}') == null || build.env('${KIBANA_BUILD_ID}') == ''`, + }; +} + +function getGroupRunnerJob(env) { + return { + command: `${ + env[E2E_GREP] ? `GREP="${env[E2E_GREP]}" ` : '' + }.buildkite/scripts/steps/functional/uptime.sh`, + label: `Uptime E2E - Synthetics runner`, + agents: { queue: 'n2-4' }, + depends_on: BUILD_UUID, + parallelism: env[E2E_COUNT], + concurrency: env[E2E_CONCURRENCY], + concurrency_group: UUID, + concurrency_method: 'eager', + }; +} + +function getEnvFromMetadata() { + const env = {}; + + env[E2E_COUNT] = getIntValue(E2E_COUNT, defaultCount); + env[E2E_CONCURRENCY] = getIntValue(E2E_CONCURRENCY, defaultConcurrency); + env[E2E_GREP] = getStringValue(E2E_GREP); + env[E2E_ARTIFACTS_ID] = getStringValue(E2E_ARTIFACTS_ID); + + env[E2E_CONCURRENCY] = + env[E2E_CONCURRENCY] > maxConcurrency ? maxConcurrency : env[E2E_CONCURRENCY]; + + return env; +} + +function getIntValue(key, defaultValue) { + let value = defaultValue; + const cli = execSync(`buildkite-agent meta-data get '${key}' --default ${defaultValue} `) + .toString() + .trim(); + + try { + value = parseInt(cli, 10); + } finally { + return value; + } +} + +function getStringValue(key) { + return execSync(`buildkite-agent meta-data get '${key}' --default ''`).toString().trim(); +} diff --git a/x-pack/plugins/uptime/.buildkite/pipelines/flaky.sh b/x-pack/plugins/uptime/.buildkite/pipelines/flaky.sh new file mode 100755 index 00000000000000..742435f6bec283 --- /dev/null +++ b/x-pack/plugins/uptime/.buildkite/pipelines/flaky.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +UUID="$(cat /proc/sys/kernel/random/uuid)" +export UUID + +node x-pack/plugins/uptime/.buildkite/pipelines/flaky.js | buildkite-agent pipeline upload From e397dabe62914cff2feb0d7722a6a645b1d36cab Mon Sep 17 00:00:00 2001 From: Paulo Henrique Date: Fri, 4 Mar 2022 13:54:37 -0300 Subject: [PATCH 02/20] [Security Solution] Session View Plugin (#124575) Co-authored-by: mitodrummer Co-authored-by: Jan Monschke Co-authored-by: Paulo Henrique Co-authored-by: Jack Co-authored-by: Karl Godard Co-authored-by: Jiawei Wu Co-authored-by: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Co-authored-by: Ricky Ang Co-authored-by: Rickyanto Ang Co-authored-by: Jack Co-authored-by: Maxwell Borden Co-authored-by: Maxwell Borden --- .github/CODEOWNERS | 3 + docs/developer/plugin-list.asciidoc | 4 + package.json | 2 +- packages/kbn-optimizer/limits.yml | 3 +- x-pack/.i18nrc.json | 1 + x-pack/plugins/session_view/.eslintrc.json | 5 + x-pack/plugins/session_view/README.md | 36 + .../plugins/session_view/common/constants.ts | 27 + .../constants/session_view_process.mock.ts | 951 +++++++++++++ .../session_view_process_events.mock.ts | 1236 +++++++++++++++++ .../common/types/process_tree/index.ts | 163 +++ .../common/utils/expand_dotted_object.test.ts | 41 + .../common/utils/expand_dotted_object.ts | 52 + .../common/utils/sort_processes.test.ts | 30 + .../common/utils/sort_processes.ts | 23 + x-pack/plugins/session_view/jest.config.js | 18 + x-pack/plugins/session_view/kibana.json | 19 + x-pack/plugins/session_view/package.json | 11 + .../detail_panel_accordion/index.test.tsx | 77 + .../detail_panel_accordion/index.tsx | 76 + .../detail_panel_accordion/styles.ts | 40 + .../detail_panel_copy/index.test.tsx | 33 + .../components/detail_panel_copy/index.tsx | 59 + .../components/detail_panel_copy/styles.ts | 30 + .../index.test.tsx | 51 + .../detail_panel_description_list/index.tsx | 33 + .../detail_panel_description_list/styles.ts | 40 + .../detail_panel_host_tab/index.test.tsx | 88 ++ .../detail_panel_host_tab/index.tsx | 161 +++ .../detail_panel_list_item/index.test.tsx | 61 + .../detail_panel_list_item/index.tsx | 51 + .../detail_panel_list_item/styles.ts | 46 + .../detail_panel_process_tab/helpers.test.ts | 36 + .../detail_panel_process_tab/helpers.ts | 28 + .../detail_panel_process_tab/index.test.tsx | 79 ++ .../detail_panel_process_tab/index.tsx | 255 ++++ .../detail_panel_process_tab/styles.ts | 41 + .../components/process_tree/helpers.test.ts | 76 + .../public/components/process_tree/helpers.ts | 170 +++ .../components/process_tree/hooks.test.tsx | 29 + .../public/components/process_tree/hooks.ts | 255 ++++ .../components/process_tree/index.test.tsx | 91 ++ .../public/components/process_tree/index.tsx | 179 +++ .../public/components/process_tree/styles.ts | 49 + .../process_tree_alerts/index.test.tsx | 54 + .../components/process_tree_alerts/index.tsx | 95 ++ .../components/process_tree_alerts/styles.ts | 45 + .../components/process_tree_node/buttons.tsx | 105 ++ .../process_tree_node/index.test.tsx | 200 +++ .../components/process_tree_node/index.tsx | 213 +++ .../components/process_tree_node/styles.ts | 118 ++ .../process_tree_node/use_button_styles.ts | 62 + .../public/components/session_view/hooks.ts | 91 ++ .../components/session_view/index.test.tsx | 104 ++ .../public/components/session_view/index.tsx | 205 +++ .../public/components/session_view/styles.ts | 36 + .../session_view_detail_panel/helpers.ts | 63 + .../session_view_detail_panel/index.test.tsx | 40 + .../session_view_detail_panel/index.tsx | 82 ++ .../session_view_search_bar/index.test.tsx | 95 ++ .../session_view_search_bar/index.tsx | 70 + .../session_view_search_bar/styles.ts | 28 + .../session_view/public/hooks/use_scroll.ts | 51 + x-pack/plugins/session_view/public/index.ts | 12 + .../session_view/public/methods/index.tsx | 25 + x-pack/plugins/session_view/public/plugin.ts | 22 + .../session_view/public/shared_imports.ts | 8 + .../session_view/public/test/index.tsx | 137 ++ x-pack/plugins/session_view/public/types.ts | 49 + .../public/utils/data_or_dash.test.ts | 30 + .../session_view/public/utils/data_or_dash.ts | 22 + x-pack/plugins/session_view/server/index.ts | 13 + x-pack/plugins/session_view/server/plugin.ts | 44 + .../session_view/server/routes/index.ts | 14 + .../routes/process_events_route.test.ts | 57 + .../server/routes/process_events_route.ts | 85 ++ .../routes/session_entry_leaders_route.ts | 37 + x-pack/plugins/session_view/server/types.ts | 11 + x-pack/plugins/session_view/tsconfig.json | 42 + yarn.lock | 8 +- 80 files changed, 7126 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/session_view/.eslintrc.json create mode 100644 x-pack/plugins/session_view/README.md create mode 100644 x-pack/plugins/session_view/common/constants.ts create mode 100644 x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts create mode 100644 x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts create mode 100644 x-pack/plugins/session_view/common/types/process_tree/index.ts create mode 100644 x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts create mode 100644 x-pack/plugins/session_view/common/utils/expand_dotted_object.ts create mode 100644 x-pack/plugins/session_view/common/utils/sort_processes.test.ts create mode 100644 x-pack/plugins/session_view/common/utils/sort_processes.ts create mode 100644 x-pack/plugins/session_view/jest.config.js create mode 100644 x-pack/plugins/session_view/kibana.json create mode 100644 x-pack/plugins/session_view/package.json create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree/helpers.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree/hooks.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts create mode 100644 x-pack/plugins/session_view/public/components/session_view/hooks.ts create mode 100644 x-pack/plugins/session_view/public/components/session_view/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts create mode 100644 x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts create mode 100644 x-pack/plugins/session_view/public/hooks/use_scroll.ts create mode 100644 x-pack/plugins/session_view/public/index.ts create mode 100644 x-pack/plugins/session_view/public/methods/index.tsx create mode 100644 x-pack/plugins/session_view/public/plugin.ts create mode 100644 x-pack/plugins/session_view/public/shared_imports.ts create mode 100644 x-pack/plugins/session_view/public/test/index.tsx create mode 100644 x-pack/plugins/session_view/public/types.ts create mode 100644 x-pack/plugins/session_view/public/utils/data_or_dash.test.ts create mode 100644 x-pack/plugins/session_view/public/utils/data_or_dash.ts create mode 100644 x-pack/plugins/session_view/server/index.ts create mode 100644 x-pack/plugins/session_view/server/plugin.ts create mode 100644 x-pack/plugins/session_view/server/routes/index.ts create mode 100644 x-pack/plugins/session_view/server/routes/process_events_route.test.ts create mode 100644 x-pack/plugins/session_view/server/routes/process_events_route.ts create mode 100644 x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts create mode 100644 x-pack/plugins/session_view/server/types.ts create mode 100644 x-pack/plugins/session_view/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 63e335067199d0..691daa042bba95 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -416,6 +416,9 @@ x-pack/plugins/security_solution/cypress/upgrade_integration @elastic/security-e x-pack/plugins/security_solution/cypress/README.md @elastic/security-engineering-productivity x-pack/test/security_solution_cypress @elastic/security-engineering-productivity +## Security Solution sub teams - adaptive-workload-protection +x-pack/plugins/session_view @elastic/awp-platform + # Security Intelligence And Analytics /x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 2de3fc3000ac56..c26a748839daf1 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -584,6 +584,10 @@ Kibana. |Welcome to the Kibana Security Solution plugin! This README will go over getting started with development and testing. +|{kib-repo}blob/{branch}/x-pack/plugins/session_view/README.md[sessionView] +|Session View is meant to provide a visualization into what is going on in a particular Linux environment where the agent is running. It looks likes a terminal emulator; however, it is a tool for introspecting process activity and understanding user and service behaviour in your Linux servers and infrastructure. It is a time-ordered series of process executions displayed in a tree over time. + + |{kib-repo}blob/{branch}/x-pack/plugins/snapshot_restore/README.md[snapshotRestore] |or diff --git a/package.json b/package.json index 6c313ac834af7b..baf1103a8ef5cc 100644 --- a/package.json +++ b/package.json @@ -347,7 +347,7 @@ "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", "react-popper-tooltip": "^2.10.1", - "react-query": "^3.34.0", + "react-query": "^3.34.7", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-resize-detector": "^4.2.0", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index f9f0bfc4fd29ec..afe7fcd9ddc867 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -121,5 +121,6 @@ pageLoadAssetSize: expressionPartitionVis: 26338 sharedUX: 16225 ux: 20784 - visTypeGauge: 24113 + sessionView: 77750 cloudSecurityPosture: 19109 + visTypeGauge: 24113 \ No newline at end of file diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index c48041c1e18839..dfe34988c4d270 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -52,6 +52,7 @@ "xpack.security": "plugins/security", "xpack.server": "legacy/server", "xpack.securitySolution": "plugins/security_solution", + "xpack.sessionView": "plugins/session_view", "xpack.snapshotRestore": "plugins/snapshot_restore", "xpack.spaces": "plugins/spaces", "xpack.savedObjectsTagging": ["plugins/saved_objects_tagging"], diff --git a/x-pack/plugins/session_view/.eslintrc.json b/x-pack/plugins/session_view/.eslintrc.json new file mode 100644 index 00000000000000..2aab6c2d9093b6 --- /dev/null +++ b/x-pack/plugins/session_view/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "@typescript-eslint/consistent-type-definitions": 0 + } +} diff --git a/x-pack/plugins/session_view/README.md b/x-pack/plugins/session_view/README.md new file mode 100644 index 00000000000000..384be8bcc292b5 --- /dev/null +++ b/x-pack/plugins/session_view/README.md @@ -0,0 +1,36 @@ +# Session View + +Session View is meant to provide a visualization into what is going on in a particular Linux environment where the agent is running. It looks likes a terminal emulator; however, it is a tool for introspecting process activity and understanding user and service behaviour in your Linux servers and infrastructure. It is a time-ordered series of process executions displayed in a tree over time. + +It provides an audit trail of: + +- Interactive processes being entered by a user into the terminal - User Input +- Processes and services which do not have a controlling tty (ie are not interactive) +- Output which is generated as a result of process activity - Output +- Nested sessions inside the entry session - Nested session (Note: For now nested sessions will display as they did at Cmd with no special handling for TMUX) +- Full telemetry about the process initiated event. This will include the information specified in the Linux logical event model +- Who executed the session or process, even if the user changes. + +## Development + +## Tests + +### Unit tests + +From kibana path in your terminal go to this plugin root: + +```bash +cd x-pack/plugins/session_view +``` + +Then run jest with: + +```bash +yarn test:jest +``` + +Or if running from kibana root, you can specify the `-i` to specify the path: + +```bash +yarn test:jest -i x-pack/plugins/session_view/ +``` diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts new file mode 100644 index 00000000000000..5baf690dc44a53 --- /dev/null +++ b/x-pack/plugins/session_view/common/constants.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events_route'; +export const SESSION_ENTRY_LEADERS_ROUTE = '/internal/session_view/session_entry_leaders_route'; +export const PROCESS_EVENTS_INDEX = 'logs-endpoint.events.process-default'; +export const ALERTS_INDEX = '.siem-signals-default'; +export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id'; + +// We fetch a large number of events per page to mitigate a few design caveats in session viewer +// 1. Due to the hierarchical nature of the data (e.g we are rendering a time ordered pid tree) there are common scenarios where there +// are few top level processes, but many nested children. For example, a build script is run on a remote host via ssh. If for example our page +// size is 10 and the build script has 500 nested children, the user would see a load more button that they could continously click without seeing +// anychange since the next 10 events would be for processes nested under a top level process that might not be expanded. That being said, it's quite +// possible there are build scripts with many thousands of events, in which case this initial large page will have the same issue. A technique used +// in previous incarnations of session view included auto expanding the node which is receiving the new page of events so as to not confuse the user. +// We may need to include this trick as part of this implementation as well. +// 2. The plain text search that comes with Session view is currently limited in that it only searches through data that has been loaded into the browser. +// The large page size allows the user to get a broader set of results per page. That being said, this feature is kind of flawed since sessions could be many thousands +// if not 100s of thousands of events, and to be required to page through these sessions to find more search matches is not a great experience. Future iterations of the +// search functionality will instead use a separate ES backend search to avoid this. +// 3. Fewer round trips to the backend! +export const PROCESS_EVENTS_PER_PAGE = 1000; diff --git a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts new file mode 100644 index 00000000000000..b7b0bbb91b5ec2 --- /dev/null +++ b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts @@ -0,0 +1,951 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + Process, + ProcessEvent, + ProcessEventsPage, + ProcessFields, + EventAction, + EventKind, + ProcessMap, +} from '../../types/process_tree'; + +export const mockEvents: ProcessEvent[] = [ + { + '@timestamp': '2021-11-23T15:25:04.210Z', + user: { + name: '', + id: '1000', + }, + process: { + pid: 3535, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: false, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 0, + args: [], + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + }, + event: { + action: EventAction.fork, + category: 'process', + kind: EventKind.event, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, + { + '@timestamp': '2021-11-23T15:25:04.218Z', + user: { + name: '', + id: '1000', + }, + process: { + pid: 3535, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 3535, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + }, + event: { + action: EventAction.exec, + category: 'process', + kind: EventKind.event, + }, + }, + { + '@timestamp': '2021-11-23T15:25:05.202Z', + user: { + name: '', + id: '1000', + }, + process: { + pid: 3535, + exit_code: 137, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3728', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.202Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.202Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.202Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 3535, + exit_code: 137, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3728', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + start: '2021-11-23T15:25:05.202Z', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + }, + event: { + action: EventAction.end, + category: 'process', + kind: EventKind.event, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, +] as ProcessEvent[]; + +export const mockAlerts: ProcessEvent[] = [ + { + kibana: { + alert: { + rule: { + category: 'Custom Query Rule', + consumer: 'siem', + name: 'cmd test alert', + uuid: '709d3890-4c71-11ec-8c67-01ccde9db9bf', + enabled: true, + description: 'cmd test alert', + risk_score: 21, + severity: 'low', + query: "process.executable: '/usr/bin/vi'", + }, + status: 'active', + workflow_status: 'open', + reason: 'process event created low alert cmd test alert.', + original_time: new Date('2021-11-23T15:25:04.218Z'), + original_event: { + action: 'exec', + }, + uuid: '6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38', + }, + }, + '@timestamp': '2021-11-23T15:26:34.859Z', + user: { + name: 'vagrant', + id: '1000', + }, + process: { + pid: 3535, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + event: { + action: EventAction.exec, + category: 'process', + kind: EventKind.signal, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, + { + kibana: { + alert: { + rule: { + category: 'Custom Query Rule', + consumer: 'siem', + name: 'cmd test alert', + uuid: '709d3890-4c71-11ec-8c67-01ccde9db9bf', + enabled: true, + description: 'cmd test alert', + risk_score: 21, + severity: 'low', + query: "process.executable: '/usr/bin/vi'", + }, + status: 'active', + workflow_status: 'open', + reason: 'process event created low alert cmd test alert.', + original_time: new Date('2021-11-23T15:25:05.202Z'), + original_event: { + action: 'exit', + }, + uuid: '2873463965b70d37ab9b2b3a90ac5a03b88e76e94ad33568285cadcefc38ed75', + }, + }, + '@timestamp': '2021-11-23T15:26:34.860Z', + user: { + name: 'vagrant', + id: '1000', + }, + process: { + pid: 3535, + exit_code: 137, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + event: { + action: EventAction.end, + category: 'process', + kind: EventKind.signal, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, +]; + +export const mockData: ProcessEventsPage[] = [ + { + events: mockEvents, + cursor: '2021-11-23T15:25:04.210Z', + }, +]; + +export const childProcessMock: Process = { + id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bd', + events: [], + children: [], + autoExpand: false, + searchMatched: null, + parent: undefined, + orphans: [], + addEvent: (_) => undefined, + clearSearch: () => undefined, + getChildren: () => [], + hasOutput: () => false, + hasAlerts: () => false, + getAlerts: () => [], + hasExec: () => false, + getOutput: () => '', + getDetails: () => + ({ + '@timestamp': '2021-11-23T15:25:05.210Z', + event: { + kind: EventKind.event, + category: 'process', + action: EventAction.exec, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '1', + name: 'vagrant', + }, + process: { + args: ['ls', '-l'], + args_count: 2, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bd', + executable: '/bin/ls', + interactive: true, + name: 'ls', + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.210Z', + pid: 2, + parent: { + args: ['bash'], + args_count: 1, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + executable: '/bin/bash', + interactive: false, + name: '', + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + pid: 1, + user: { + id: '1', + name: 'vagrant', + }, + }, + session_leader: {} as ProcessFields, + entry_leader: {} as ProcessFields, + group_leader: {} as ProcessFields, + }, + } as ProcessEvent), + isUserEntered: () => false, + getMaxAlertLevel: () => null, +}; + +export const processMock: Process = { + id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + events: [], + children: [], + autoExpand: false, + searchMatched: null, + parent: undefined, + orphans: [], + addEvent: (_) => undefined, + clearSearch: () => undefined, + getChildren: () => [], + hasOutput: () => false, + hasAlerts: () => false, + getAlerts: () => [], + hasExec: () => false, + getOutput: () => '', + getDetails: () => + ({ + '@timestamp': '2021-11-23T15:25:04.210Z', + event: { + kind: EventKind.event, + category: 'process', + action: EventAction.exec, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '1', + name: 'vagrant', + }, + process: { + args: ['bash'], + args_count: 1, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + executable: '/bin/bash', + exit_code: 137, + interactive: false, + name: '', + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + pid: 1, + parent: {} as ProcessFields, + session_leader: {} as ProcessFields, + entry_leader: {} as ProcessFields, + group_leader: {} as ProcessFields, + }, + } as ProcessEvent), + isUserEntered: () => false, + getMaxAlertLevel: () => null, +}; + +export const sessionViewBasicProcessMock: Process = { + ...processMock, + events: mockEvents, + hasExec: () => true, + isUserEntered: () => true, +}; + +export const sessionViewAlertProcessMock: Process = { + ...processMock, + events: [...mockEvents, ...mockAlerts], + hasAlerts: () => true, + getAlerts: () => mockEvents, + hasExec: () => true, + isUserEntered: () => true, +}; + +export const mockProcessMap = mockEvents.reduce( + (processMap, event) => { + processMap[event.process.entity_id] = { + id: event.process.entity_id, + events: [event], + children: [], + parent: undefined, + autoExpand: false, + searchMatched: null, + orphans: [], + addEvent: (_) => undefined, + clearSearch: () => undefined, + getChildren: () => [], + hasOutput: () => false, + hasAlerts: () => false, + getAlerts: () => [], + hasExec: () => false, + getOutput: () => '', + getDetails: () => event, + isUserEntered: () => false, + getMaxAlertLevel: () => null, + }; + return processMap; + }, + { + [sessionViewBasicProcessMock.id]: sessionViewBasicProcessMock, + } as ProcessMap +); diff --git a/x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts b/x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts new file mode 100644 index 00000000000000..47849f859ba9c1 --- /dev/null +++ b/x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts @@ -0,0 +1,1236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ProcessEventResults } from '../../types/process_tree'; + +export const sessionViewProcessEventsMock: ProcessEventResults = { + events: [ + { + _index: 'cmd', + _id: 'FMUGTX0BGGlsPv9flMF7', + _score: null, + _source: { + '@timestamp': '2021-11-23T13:40:16.528Z', + event: { + kind: 'event', + category: 'process', + action: 'fork', + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + Ext: { + variant: 'CentOS', + }, + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + // To keep backwards compat and avoid data duplication. We keep user/group info for top level process at the top level + id: '0', // the effective user aka euid + name: 'root', + real: { + // ruid + id: '2', + name: 'kg', + }, + saved: { + // suid + id: '2', + name: 'kg', + }, + }, + group: { + id: '1', // the effective group aka egid + name: 'groupA', + real: { + // rgid + id: '1', + name: 'groupA', + }, + saved: { + // sgid + id: '1', + name: 'groupA', + }, + }, + process: { + entity_id: '4321', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: false, + working_directory: '/', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + parent: { + entity_id: '4322', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: true, + working_directory: '/', + pid: 2, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '2', + name: 'kg', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + group_leader: { + entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + pid: 1234, // this directly replaces parent.pgid + start: '2021-10-14T08:05:34.853Z', + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the session_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the entry_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + entry_meta: { + type: 'sshd', + source: { + ip: '10.132.0.50', + geo: { + city_name: 'Vancouver', + continent_code: 'NA', + continent_name: 'North America', + country_iso_code: 'CA', + country_name: 'Canada', + location: { + lon: -73.61483, + lat: 45.505918, + }, + postal_code: 'V9J1E3', + region_iso_code: 'BC', + region_name: 'British Columbia', + timezone: 'America/Los_Angeles', + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + { + descriptor: 1, + type: 'pipe', + pipe: { + inode: '6183207', + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + }, + sort: [1637674816528], + }, + { + _index: 'cmd', + _id: 'FsUGTX0BGGlsPv9flMGF', + _score: null, + _source: { + '@timestamp': '2021-11-23T13:40:16.541Z', + event: { + kind: 'event', + category: 'process', + action: 'exec', + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + Ext: { + variant: 'CentOS', + }, + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '2', + name: 'kg', + real: { + id: '2', + name: 'kg', + }, + saved: { + id: '2', + name: 'kg', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + process: { + entity_id: '4321', + args: ['/bin/bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + previous: [{ args: ['/bin/sshd'], args_count: 1, executable: '/bin/sshd' }], + parent: { + entity_id: '4322', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: true, + working_directory: '/', + pid: 2, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + group_leader: { + entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + pid: 1234, // this directly replaces parent.pgid + start: '2021-10-14T08:05:34.853Z', + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the session_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the entry_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + entry_meta: { + type: 'sshd', + source: { + ip: '10.132.0.50', + geo: { + city_name: 'Vancouver', + continent_code: 'NA', + continent_name: 'North America', + country_iso_code: 'CA', + country_name: 'Canada', + location: { + lon: -73.61483, + lat: 45.505918, + }, + postal_code: 'V9J1E3', + region_iso_code: 'BC', + region_name: 'British Columbia', + timezone: 'America/Los_Angeles', + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + { + descriptor: 1, + type: 'pipe', + pipe: { + inode: '6183207', + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + }, + sort: [1637674816541], + }, + { + _index: 'cmd', + _id: 'H8UGTX0BGGlsPv9fp8F_', + _score: null, + _source: { + '@timestamp': '2021-11-23T13:40:21.392Z', + event: { + kind: 'event', + category: 'process', + action: 'end', + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + Ext: { + variant: 'CentOS', + }, + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '2', + name: 'kg', + real: { + id: '2', + name: 'kg', + }, + saved: { + id: '2', + name: 'kg', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + process: { + entity_id: '4321', + args: ['/bin/bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + end: '2021-10-14T10:05:34.853Z', + exit_code: 137, + previous: [{ args: ['/bin/sshd'], args_count: 1, executable: '/bin/sshd' }], + parent: { + entity_id: '4322', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: true, + working_directory: '/', + pid: 2, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + group_leader: { + entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + pid: 1234, // this directly replaces parent.pgid + start: '2021-10-14T08:05:34.853Z', + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the session_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the entry_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + entry_meta: { + type: 'sshd', + source: { + ip: '10.132.0.50', + geo: { + city_name: 'Vancouver', + continent_code: 'NA', + continent_name: 'North America', + country_iso_code: 'CA', + country_name: 'Canada', + location: { + lon: -73.61483, + lat: 45.505918, + }, + postal_code: 'V9J1E3', + region_iso_code: 'BC', + region_name: 'British Columbia', + timezone: 'America/Los_Angeles', + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + { + descriptor: 1, + type: 'pipe', + pipe: { + inode: '6183207', + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + }, + sort: [1637674821392], + }, + ], +}; diff --git a/x-pack/plugins/session_view/common/types/process_tree/index.ts b/x-pack/plugins/session_view/common/types/process_tree/index.ts new file mode 100644 index 00000000000000..746c1b2093661b --- /dev/null +++ b/x-pack/plugins/session_view/common/types/process_tree/index.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const enum EventKind { + event = 'event', + signal = 'signal', +} + +export const enum EventAction { + fork = 'fork', + exec = 'exec', + end = 'end', + output = 'output', +} + +export interface User { + id: string; + name: string; +} + +export interface ProcessEventResults { + events: any[]; +} + +export type EntryMetaType = + | 'init' + | 'sshd' + | 'ssm' + | 'kubelet' + | 'teleport' + | 'terminal' + | 'console'; + +export interface EntryMeta { + type: EntryMetaType; + source: { + ip: string; + }; +} + +export interface Teletype { + descriptor: number; + type: string; + char_device: { + major: number; + minor: number; + }; +} + +export interface ProcessFields { + entity_id: string; + args: string[]; + args_count: number; + command_line: string; + executable: string; + name: string; + interactive: boolean; + working_directory: string; + pid: number; + start: string; + end?: string; + user: User; + exit_code?: number; + entry_meta?: EntryMeta; + tty: Teletype; +} + +export interface ProcessSelf extends Omit { + parent: ProcessFields; + session_leader: ProcessFields; + entry_leader: ProcessFields; + group_leader: ProcessFields; +} + +export interface ProcessEventHost { + architecture: string; + hostname: string; + id: string; + ip: string; + mac: string; + name: string; + os: { + family: string; + full: string; + kernel: string; + name: string; + platform: string; + version: string; + }; +} + +export interface ProcessEventAlertRule { + category: string; + consumer: string; + description: string; + enabled: boolean; + name: string; + query: string; + risk_score: number; + severity: string; + uuid: string; +} + +export interface ProcessEventAlert { + uuid: string; + reason: string; + workflow_status: string; + status: string; + original_time: Date; + original_event: { + action: string; + }; + rule: ProcessEventAlertRule; +} + +export interface ProcessEvent { + '@timestamp': string; + event: { + kind: EventKind; + category: string; + action: EventAction; + }; + user: User; + host: ProcessEventHost; + process: ProcessSelf; + kibana?: { + alert: ProcessEventAlert; + }; +} + +export interface ProcessEventsPage { + events: ProcessEvent[]; + cursor: string; +} + +export interface Process { + id: string; // the process entity_id + events: ProcessEvent[]; + children: Process[]; + orphans: Process[]; // currently, orphans are rendered inline with the entry session leaders children + parent: Process | undefined; + autoExpand: boolean; + searchMatched: string | null; // either false, or set to searchQuery + addEvent(event: ProcessEvent): void; + clearSearch(): void; + hasOutput(): boolean; + hasAlerts(): boolean; + getAlerts(): ProcessEvent[]; + hasExec(): boolean; + getOutput(): string; + getDetails(): ProcessEvent; + isUserEntered(): boolean; + getMaxAlertLevel(): number | null; + getChildren(verboseMode: boolean): Process[]; +} + +export type ProcessMap = { + [key: string]: Process; +}; diff --git a/x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts b/x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts new file mode 100644 index 00000000000000..a4a4845e759e7a --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expandDottedObject } from './expand_dotted_object'; + +const testFlattenedObj = { + 'flattened.property.a': 'valueA', + 'flattened.property.b': 'valueB', + regularProp: { + nestedProp: 'nestedValue', + }, + 'nested.array': [ + { + arrayProp: 'arrayValue', + }, + ], + emptyArray: [], +}; +describe('expandDottedObject(obj)', () => { + it('retrieves values from flattened keys', () => { + const expanded: any = expandDottedObject(testFlattenedObj); + + expect(expanded.flattened.property.a).toEqual('valueA'); + expect(expanded.flattened.property.b).toEqual('valueB'); + }); + it('retrieves values from nested keys', () => { + const expanded: any = expandDottedObject(testFlattenedObj); + + expect(Array.isArray(expanded.nested.array)).toBeTruthy(); + expect(expanded.nested.array[0].arrayProp).toEqual('arrayValue'); + }); + it("doesn't break regular value access", () => { + const expanded: any = expandDottedObject(testFlattenedObj); + + expect(expanded.regularProp.nestedProp).toEqual('nestedValue'); + }); +}); diff --git a/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts b/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts new file mode 100644 index 00000000000000..69a9cb8236cbce --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { merge } from '@kbn/std'; + +const expandDottedField = (dottedFieldName: string, val: unknown): object => { + const parts = dottedFieldName.split('.'); + if (parts.length === 1) { + return { [parts[0]]: val }; + } else { + return { [parts[0]]: expandDottedField(parts.slice(1).join('.'), val) }; + } +}; + +/* + * Expands an object with "dotted" fields to a nested object with unflattened fields. + * + * Example: + * expandDottedObject({ + * "kibana.alert.depth": 1, + * "kibana.alert.ancestors": [{ + * id: "d5e8eb51-a6a0-456d-8a15-4b79bfec3d71", + * type: "event", + * index: "signal_index", + * depth: 0, + * }], + * }) + * + * => { + * kibana: { + * alert: { + * ancestors: [ + * id: "d5e8eb51-a6a0-456d-8a15-4b79bfec3d71", + * type: "event", + * index: "signal_index", + * depth: 0, + * ], + * depth: 1, + * }, + * }, + * } + */ +export const expandDottedObject = (dottedObj: object) => { + return Object.entries(dottedObj).reduce( + (acc, [key, val]) => merge(acc, expandDottedField(key, val)), + {} + ); +}; diff --git a/x-pack/plugins/session_view/common/utils/sort_processes.test.ts b/x-pack/plugins/session_view/common/utils/sort_processes.test.ts new file mode 100644 index 00000000000000..b1db5381954dcb --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/sort_processes.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { sortProcesses } from './sort_processes'; +import { mockProcessMap } from '../mocks/constants/session_view_process.mock'; + +describe('sortProcesses(a, b)', () => { + it('sorts processes in ascending order by start time', () => { + const processes = Object.values(mockProcessMap); + + // shuffle some things to ensure all sort lines are hit + const c = processes[0]; + processes[0] = processes[processes.length - 1]; + processes[processes.length - 1] = c; + + processes.sort(sortProcesses); + + for (let i = 0; i < processes.length - 1; i++) { + const current = processes[i]; + const next = processes[i + 1]; + expect( + new Date(next.getDetails().process.start) >= new Date(current.getDetails().process.start) + ).toBeTruthy(); + } + }); +}); diff --git a/x-pack/plugins/session_view/common/utils/sort_processes.ts b/x-pack/plugins/session_view/common/utils/sort_processes.ts new file mode 100644 index 00000000000000..a0a42590e457e6 --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/sort_processes.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Process } from '../types/process_tree'; + +export const sortProcesses = (a: Process, b: Process) => { + const eventAStartTime = new Date(a.getDetails().process.start); + const eventBStartTime = new Date(b.getDetails().process.start); + + if (eventAStartTime < eventBStartTime) { + return -1; + } + + if (eventAStartTime > eventBStartTime) { + return 1; + } + + return 0; +}; diff --git a/x-pack/plugins/session_view/jest.config.js b/x-pack/plugins/session_view/jest.config.js new file mode 100644 index 00000000000000..d35db0d369468d --- /dev/null +++ b/x-pack/plugins/session_view/jest.config.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/session_view'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/session_view', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/session_view/{common,public,server}/**/*.{ts,tsx}', + ], + setupFiles: ['jest-canvas-mock'], +}; diff --git a/x-pack/plugins/session_view/kibana.json b/x-pack/plugins/session_view/kibana.json new file mode 100644 index 00000000000000..ff9d849016c555 --- /dev/null +++ b/x-pack/plugins/session_view/kibana.json @@ -0,0 +1,19 @@ +{ + "id": "sessionView", + "version": "8.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Security Team", + "githubTeam": "security-team" + }, + "requiredPlugins": [ + "data", + "timelines" + ], + "requiredBundles": [ + "kibanaReact", + "esUiShared" + ], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/session_view/package.json b/x-pack/plugins/session_view/package.json new file mode 100644 index 00000000000000..2cb3dc882ed711 --- /dev/null +++ b/x-pack/plugins/session_view/package.json @@ -0,0 +1,11 @@ +{ + "author": "Elastic", + "name": "session_view", + "version": "8.0.0", + "private": true, + "license": "Elastic-License", + "scripts": { + "test:jest": "node ../../scripts/jest", + "test:coverage": "node ../../scripts/jest --coverage" + } +} diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx new file mode 100644 index 00000000000000..80ad3ce0c46302 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelAccordion } from './index'; + +const TEST_ID = 'test'; +const TEST_LIST_ITEM = [ + { + title: 'item title', + description: 'item description', + }, +]; +const TEST_TITLE = 'accordion title'; +const ACTION_TEXT = 'extra action'; + +describe('DetailPanelAccordion component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelAccordion is mounted', () => { + it('should render basic acoordion', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-accordion')).toBeVisible(); + }); + + it('should render acoordion with tooltip', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-accordion')).toBeVisible(); + expect( + renderResult.queryByTestId('sessionView:detail-panel-accordion-tooltip') + ).toBeVisible(); + }); + + it('should render acoordion with extra action', async () => { + const mockFn = jest.fn(); + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-accordion')).toBeVisible(); + const extraActionButton = renderResult.getByTestId( + 'sessionView:detail-panel-accordion-action' + ); + expect(extraActionButton).toHaveTextContent(ACTION_TEXT); + extraActionButton.click(); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx new file mode 100644 index 00000000000000..4e03931e4fcd97 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { ReactNode } from 'react'; +import { EuiAccordion, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; +import { useStyles } from './styles'; +import { DetailPanelDescriptionList } from '../detail_panel_description_list'; + +interface DetailPanelAccordionDeps { + id: string; + listItems: Array<{ + title: NonNullable; + description: NonNullable; + }>; + title: string; + tooltipContent?: string; + extraActionTitle?: string; + onExtraActionClick?: () => void; +} + +/** + * An accordion section in session view detail panel. + */ +export const DetailPanelAccordion = ({ + id, + listItems, + title, + tooltipContent, + extraActionTitle, + onExtraActionClick, +}: DetailPanelAccordionDeps) => { + const styles = useStyles(); + + return ( + + + {title} + + {tooltipContent && ( + + + + )} + + } + extraAction={ + extraActionTitle ? ( + + {extraActionTitle} + + ) : null + } + css={styles.accordion} + data-test-subj="sessionView:detail-panel-accordion" + > + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts new file mode 100644 index 00000000000000..c44e069c05c004 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const tabSection: CSSObject = { + padding: euiTheme.size.base, + }; + + const accordion: CSSObject = { + borderTop: euiTheme.border.thin, + '&:last-child': { + borderBottom: euiTheme.border.thin, + }, + }; + + const accordionButton: CSSObject = { + padding: euiTheme.size.base, + fontWeight: euiTheme.font.weight.bold, + }; + + return { + accordion, + accordionButton, + tabSection, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx new file mode 100644 index 00000000000000..bb1dd243621bd5 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_copy/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelCopy } from './index'; + +const TEST_TEXT_COPY = 'copy component test'; +const TEST_CHILD = {TEST_TEXT_COPY}; + +describe('DetailPanelCopy component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelCopy is mounted', () => { + it('renders DetailPanelCopy correctly', async () => { + renderResult = mockedContext.render( + {TEST_CHILD} + ); + + expect(renderResult.queryByText(TEST_TEXT_COPY)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx new file mode 100644 index 00000000000000..a5ce77894949b1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_copy/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { ReactNode } from 'react'; +import { EuiButtonIcon, EuiCopy } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { dataOrDash } from '../../utils/data_or_dash'; +import { useStyles } from './styles'; + +interface DetailPanelCopyDeps { + children: ReactNode; + textToCopy: string | number; + display?: 'inlineBlock' | 'block' | undefined; +} + +interface DetailPanelListItemProps { + copy: ReactNode; + display?: string; +} + +/** + * Copy to clipboard component in Session view detail panel. + */ +export const DetailPanelCopy = ({ + children, + textToCopy, + display = 'inlineBlock', +}: DetailPanelCopyDeps) => { + const styles = useStyles(); + + const props: DetailPanelListItemProps = { + copy: ( + + {(copy) => ( + + )} + + ), + }; + + if (display === 'block') { + props.display = display; + } + + return {children}; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts new file mode 100644 index 00000000000000..0bfc67dddb8859 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const copyButton: CSSObject = { + position: 'absolute', + right: euiTheme.size.s, + top: 0, + bottom: 0, + margin: 'auto', + }; + + return { + copyButton, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx new file mode 100644 index 00000000000000..aaf3086aabf5ee --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelDescriptionList } from './index'; + +const TEST_FIRST_TITLE = 'item title'; +const TEST_FIRST_DESCRIPTION = 'item description'; +const TEST_SECOND_TITLE = 'second title'; +const TEST_SECOND_DESCRIPTION = 'second description'; +const TEST_LIST_ITEM = [ + { + title: TEST_FIRST_TITLE, + description: TEST_FIRST_DESCRIPTION, + }, + { + title: TEST_SECOND_TITLE, + description: TEST_SECOND_DESCRIPTION, + }, +]; + +describe('DetailPanelDescriptionList component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelDescriptionList is mounted', () => { + it('renders DetailPanelDescriptionList correctly', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-description-list')).toBeVisible(); + + // check list items are rendered + expect(renderResult.queryByText(TEST_FIRST_TITLE)).toBeVisible(); + expect(renderResult.queryByText(TEST_FIRST_DESCRIPTION)).toBeVisible(); + expect(renderResult.queryByText(TEST_SECOND_TITLE)).toBeVisible(); + expect(renderResult.queryByText(TEST_SECOND_DESCRIPTION)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx new file mode 100644 index 00000000000000..3d942fc42326e5 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { ReactNode } from 'react'; +import { EuiDescriptionList } from '@elastic/eui'; +import { useStyles } from './styles'; + +interface DetailPanelDescriptionListDeps { + listItems: Array<{ + title: NonNullable; + description: NonNullable; + }>; +} + +/** + * Description list in session view detail panel. + */ +export const DetailPanelDescriptionList = ({ listItems }: DetailPanelDescriptionListDeps) => { + const styles = useStyles(); + return ( + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts new file mode 100644 index 00000000000000..d815cb2a48283b --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { CSSObject } from '@emotion/react'; +import { useEuiTheme } from '@elastic/eui'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const descriptionList: CSSObject = { + padding: euiTheme.size.s, + }; + + const tabListTitle = { + width: '40%', + display: 'flex', + alignItems: 'center', + }; + + const tabListDescription = { + width: '60%', + display: 'flex', + alignItems: 'center', + }; + + return { + descriptionList, + tabListTitle, + tabListDescription, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx new file mode 100644 index 00000000000000..2df9f47e5a4163 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessEventHost } from '../../../common/types/process_tree'; +import { DetailPanelHostTab } from './index'; + +const TEST_ARCHITECTURE = 'x86_64'; +const TEST_HOSTNAME = 'host-james-fleet-714-2'; +const TEST_ID = '48c1b3f1ac5da4e0057fc9f60f4d1d5d'; +const TEST_IP = '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809'; +const TEST_MAC = '42:01:0a:84:00:32'; +const TEST_NAME = 'name-james-fleet-714-2'; +const TEST_OS_FAMILY = 'family-centos'; +const TEST_OS_FULL = 'full-CentOS 7.9.2009'; +const TEST_OS_KERNEL = '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021'; +const TEST_OS_NAME = 'os-Linux'; +const TEST_OS_PLATFORM = 'platform-centos'; +const TEST_OS_VERSION = 'version-7.9.2009'; + +const TEST_HOST: ProcessEventHost = { + architecture: TEST_ARCHITECTURE, + hostname: TEST_HOSTNAME, + id: TEST_ID, + ip: TEST_IP, + mac: TEST_MAC, + name: TEST_NAME, + os: { + family: TEST_OS_FAMILY, + full: TEST_OS_FULL, + kernel: TEST_OS_KERNEL, + name: TEST_OS_NAME, + platform: TEST_OS_PLATFORM, + version: TEST_OS_VERSION, + }, +}; + +describe('DetailPanelHostTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelHostTab is mounted', () => { + it('renders DetailPanelHostTab correctly', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByText('architecture')).toBeVisible(); + expect(renderResult.queryByText('hostname')).toBeVisible(); + expect(renderResult.queryByText('id')).toBeVisible(); + expect(renderResult.queryByText('ip')).toBeVisible(); + expect(renderResult.queryByText('mac')).toBeVisible(); + expect(renderResult.queryByText('name')).toBeVisible(); + expect(renderResult.queryByText(TEST_ARCHITECTURE)).toBeVisible(); + expect(renderResult.queryByText(TEST_HOSTNAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_IP)).toBeVisible(); + expect(renderResult.queryByText(TEST_MAC)).toBeVisible(); + expect(renderResult.queryByText(TEST_NAME)).toBeVisible(); + + // expand host os accordion + renderResult + .queryByTestId('sessionView:detail-panel-accordion') + ?.querySelector('button') + ?.click(); + expect(renderResult.queryByText('os.family')).toBeVisible(); + expect(renderResult.queryByText('os.full')).toBeVisible(); + expect(renderResult.queryByText('os.kernel')).toBeVisible(); + expect(renderResult.queryByText('os.name')).toBeVisible(); + expect(renderResult.queryByText('os.platform')).toBeVisible(); + expect(renderResult.queryByText('os.version')).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FAMILY)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FULL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_KERNEL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_NAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_PLATFORM)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_VERSION)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx new file mode 100644 index 00000000000000..e46e0e2751872d --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { ProcessEventHost } from '../../../common/types/process_tree'; +import { DetailPanelAccordion } from '../detail_panel_accordion'; +import { DetailPanelCopy } from '../detail_panel_copy'; +import { DetailPanelDescriptionList } from '../detail_panel_description_list'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { dataOrDash } from '../../utils/data_or_dash'; +import { useStyles } from '../detail_panel_process_tab/styles'; + +interface DetailPanelHostTabDeps { + processHost: ProcessEventHost; +} + +/** + * Host Panel of session view detail panel. + */ +export const DetailPanelHostTab = ({ processHost }: DetailPanelHostTabDeps) => { + const styles = useStyles(); + + return ( + <> + hostname, + description: ( + + + {dataOrDash(processHost.hostname)} + + + ), + }, + { + title: id, + description: ( + + + {dataOrDash(processHost.id)} + + + ), + }, + { + title: ip, + description: ( + + + {dataOrDash(processHost.ip)} + + + ), + }, + { + title: mac, + description: ( + + + {dataOrDash(processHost.mac)} + + + ), + }, + { + title: name, + description: ( + + + {dataOrDash(processHost.name)} + + + ), + }, + ]} + /> + architecture, + description: ( + + + {dataOrDash(processHost.architecture)} + + + ), + }, + { + title: os.family, + description: ( + + + {dataOrDash(processHost.os.family)} + + + ), + }, + { + title: os.full, + description: ( + + + {dataOrDash(processHost.os.full)} + + + ), + }, + { + title: os.kernel, + description: ( + + + {dataOrDash(processHost.os.kernel)} + + + ), + }, + { + title: os.name, + description: ( + + + {dataOrDash(processHost.os.name)} + + + ), + }, + { + title: os.platform, + description: ( + + + {dataOrDash(processHost.os.platform)} + + + ), + }, + { + title: os.version, + description: ( + + + {dataOrDash(processHost.os.version)} + + + ), + }, + ]} + /> + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx new file mode 100644 index 00000000000000..e6572a097d85a3 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelListItem } from './index'; + +const TEST_STRING = 'item title'; +const TEST_CHILD = {TEST_STRING}; +const TEST_COPY_STRING = 'test copy button'; +const BUTTON_TEST_ID = 'sessionView:test-copy-button'; +const TEST_COPY = ; +const LIST_ITEM_TEST_ID = 'sessionView:detail-panel-list-item'; +const WAIT_TIMEOUT = 500; + +describe('DetailPanelListItem component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelListItem is mounted', () => { + it('renders DetailPanelListItem correctly', async () => { + renderResult = mockedContext.render({TEST_CHILD}); + + expect(renderResult.queryByTestId(LIST_ITEM_TEST_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_STRING)).toBeVisible(); + }); + + it('renders copy element correctly', async () => { + renderResult = mockedContext.render( + {TEST_CHILD} + ); + + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + fireEvent.mouseEnter(renderResult.getByTestId(LIST_ITEM_TEST_ID)); + await waitFor(() => screen.queryByTestId(BUTTON_TEST_ID)); + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeVisible(); + + fireEvent.mouseLeave(renderResult.getByTestId(LIST_ITEM_TEST_ID)); + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + }); + + it('does not have mouse events when copy prop is not present', async () => { + renderResult = mockedContext.render({TEST_CHILD}); + + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + fireEvent.mouseEnter(renderResult.getByTestId(LIST_ITEM_TEST_ID)); + await waitFor(() => screen.queryByTestId(BUTTON_TEST_ID), { timeout: WAIT_TIMEOUT }); + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx new file mode 100644 index 00000000000000..93a6554bbe54aa --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, ReactNode } from 'react'; +import { EuiText, EuiTextProps } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; +import { useStyles } from './styles'; + +interface DetailPanelListItemDeps { + children: ReactNode; + copy?: ReactNode; + display?: string; +} + +interface EuiTextPropsCss extends EuiTextProps { + css: CSSObject; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelListItem = ({ + children, + copy, + display = 'flex', +}: DetailPanelListItemDeps) => { + const [isHovered, setIsHovered] = useState(false); + const styles = useStyles({ display }); + + const props: EuiTextPropsCss = { + size: 's', + css: !!copy ? styles.copiableItem : styles.item, + }; + + if (!!copy) { + props.onMouseEnter = () => setIsHovered(true); + props.onMouseLeave = () => setIsHovered(false); + } + + return ( + + {children} + {isHovered && copy} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts new file mode 100644 index 00000000000000..c370bd8adb6e2d --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + display: string | undefined; +} + +export const useStyles = ({ display }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const item: CSSObject = { + display, + alignItems: 'center', + padding: euiTheme.size.s, + width: '100%', + fontSize: 'inherit', + fontWeight: 'inherit', + minHeight: '36px', + }; + + const copiableItem: CSSObject = { + ...item, + position: 'relative', + borderRadius: euiTheme.border.radius.medium, + '&:hover': { + background: transparentize(euiTheme.colors.primary, 0.1), + }, + }; + + return { + item, + copiableItem, + }; + }, [display, euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts new file mode 100644 index 00000000000000..d458ee3a1d6669 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getProcessExecutableCopyText } from './helpers'; + +describe('detail panel process tab helpers tests', () => { + it('getProcessExecutableCopyText works with empty array', () => { + const result = getProcessExecutableCopyText([]); + expect(result).toEqual(''); + }); + + it('getProcessExecutableCopyText works with array of tuples', () => { + const result = getProcessExecutableCopyText([ + ['echo', 'exec'], + ['echo', 'exit'], + ]); + expect(result).toEqual('echo exec, echo exit'); + }); + + it('getProcessExecutableCopyText returns empty string with an invalid array of tuples', () => { + // when some sub arrays only have 1 item + let result = getProcessExecutableCopyText([['echo', 'exec'], ['echo']]); + expect(result).toEqual(''); + + // when some sub arrays have more than two item + result = getProcessExecutableCopyText([ + ['echo', 'exec'], + ['echo', 'exec', 'random'], + ['echo', 'exit'], + ]); + expect(result).toEqual(''); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts new file mode 100644 index 00000000000000..632e0bc5fd2e3e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Serialize an array of executable tuples to a copyable text. + * + * @param {String[][]} executable + * @return {String} serialized string with data of each executable + */ +export const getProcessExecutableCopyText = (executable: string[][]) => { + try { + return executable + .map((execTuple) => { + const [execCommand, eventAction] = execTuple; + if (!execCommand || !eventAction || execTuple.length !== 2) { + throw new Error(); + } + return `${execCommand} ${eventAction}`; + }) + .join(', '); + } catch (_) { + return ''; + } +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx new file mode 100644 index 00000000000000..074c69de7e8992 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelProcess, DetailPanelProcessLeader } from '../../types'; +import { DetailPanelProcessTab } from './index'; + +const getLeaderDetail = (leader: string): DetailPanelProcessLeader => ({ + id: `${leader}-id`, + name: `${leader}-name`, + start: new Date('2022-02-24').toISOString(), + entryMetaType: 'sshd', + userName: `${leader}-jack`, + interactive: true, + pid: 1234, + entryMetaSourceIp: '10.132.0.50', + executable: '/usr/bin/bash', +}); + +const TEST_PROCESS_DETAIL: DetailPanelProcess = { + id: 'process-id', + start: new Date('2022-02-22').toISOString(), + end: new Date('2022-02-23').toISOString(), + exit_code: 137, + user: 'process-jack', + args: ['vi', 'test.txt'], + executable: [ + ['test-executable-cmd', '(fork)'], + ['test-executable-cmd', '(exec)'], + ['test-executable-cmd', '(end)'], + ], + pid: 1233, + entryLeader: getLeaderDetail('entryLeader'), + sessionLeader: getLeaderDetail('sessionLeader'), + groupLeader: getLeaderDetail('groupLeader'), + parent: getLeaderDetail('parent'), +}; + +describe('DetailPanelProcessTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelProcessTab is mounted', () => { + it('renders DetailPanelProcessTab correctly', async () => { + renderResult = mockedContext.render( + + ); + + // Process detail rendered correctly + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.id)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.start)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.end)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.exit_code)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.user)).toBeVisible(); + expect(renderResult.queryByText(`['vi','test.txt']`)).toBeVisible(); + expect(renderResult.queryAllByText('test-executable-cmd')).toHaveLength(3); + expect(renderResult.queryByText('(fork)')).toBeVisible(); + expect(renderResult.queryByText('(exec)')).toBeVisible(); + expect(renderResult.queryByText('(end)')).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.pid)).toBeVisible(); + + // Process tab accordions rendered correctly + expect(renderResult.queryByText('entryLeader-name')).toBeVisible(); + expect(renderResult.queryByText('sessionLeader-name')).toBeVisible(); + expect(renderResult.queryByText('groupLeader-name')).toBeVisible(); + expect(renderResult.queryByText('parent-name')).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx new file mode 100644 index 00000000000000..97e2cdc806c0f0 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx @@ -0,0 +1,255 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { ReactNode } from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DetailPanelProcess } from '../../types'; +import { DetailPanelAccordion } from '../detail_panel_accordion'; +import { DetailPanelCopy } from '../detail_panel_copy'; +import { DetailPanelDescriptionList } from '../detail_panel_description_list'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { dataOrDash } from '../../utils/data_or_dash'; +import { getProcessExecutableCopyText } from './helpers'; +import { useStyles } from './styles'; + +interface DetailPanelProcessTabDeps { + processDetail: DetailPanelProcess; +} + +type ListItems = Array<{ + title: NonNullable; + description: NonNullable; +}>; + +// TODO: Update placeholder descriptions for these tootips once UX Writer Team Defines them +const leaderDescriptionListInfo = [ + { + id: 'processEntryLeader', + title: 'Entry Leader', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.entryLeaderTooltip', { + defaultMessage: 'A entry leader placeholder description', + }), + }, + { + id: 'processSessionLeader', + title: 'Session Leader', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.sessionLeaderTooltip', { + defaultMessage: 'A session leader placeholder description', + }), + }, + { + id: 'processGroupLeader', + title: 'Group Leader', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.processGroupLeaderTooltip', { + defaultMessage: 'a group leader placeholder description', + }), + }, + { + id: 'processParent', + title: 'Parent', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.processParentTooltip', { + defaultMessage: 'a parent placeholder description', + }), + }, +]; + +/** + * Detail panel in the session view. + */ +export const DetailPanelProcessTab = ({ processDetail }: DetailPanelProcessTabDeps) => { + const styles = useStyles(); + const leaderListItems = [ + processDetail.entryLeader, + processDetail.sessionLeader, + processDetail.groupLeader, + processDetail.parent, + ].map((leader, idx) => { + const listItems: ListItems = [ + { + title: id, + description: ( + + + {dataOrDash(leader.id)} + + + ), + }, + { + title: start, + description: ( + + {leader.start} + + ), + }, + ]; + // Only include entry_meta.type for entry leader + if (idx === 0) { + listItems.push({ + title: entry_meta.type, + description: ( + + + {dataOrDash(leader.entryMetaType)} + + + ), + }); + } + listItems.push( + { + title: user.name, + description: ( + + {dataOrDash(leader.userName)} + + ), + }, + { + title: interactive, + description: ( + + {leader.interactive ? 'True' : 'False'} + + ), + }, + { + title: pid, + description: ( + + {dataOrDash(leader.pid)} + + ), + } + ); + // Only include entry_meta.source.ip for entry leader + if (idx === 0) { + listItems.push({ + title: entry_meta.source.ip, + description: ( + + {dataOrDash(leader.entryMetaSourceIp)} + + ), + }); + } + return { + ...leaderDescriptionListInfo[idx], + name: leader.name, + listItems, + }; + }); + + const processArgs = processDetail.args.length + ? `[${processDetail.args.map((arg) => `'${arg}'`)}]` + : '-'; + + return ( + <> + id, + description: ( + + + {dataOrDash(processDetail.id)} + + + ), + }, + { + title: start, + description: ( + + {processDetail.start} + + ), + }, + { + title: end, + description: ( + + {processDetail.end} + + ), + }, + { + title: exit_code, + description: ( + + + {dataOrDash(processDetail.exit_code)} + + + ), + }, + { + title: user, + description: ( + + {dataOrDash(processDetail.user)} + + ), + }, + { + title: args, + description: ( + + {processArgs} + + ), + }, + { + title: executable, + description: ( + + {processDetail.executable.map((execTuple, idx) => { + const [executable, eventAction] = execTuple; + return ( +
+ + {executable} + + + {eventAction} + +
+ ); + })} +
+ ), + }, + { + title: process.pid, + description: ( + + + {dataOrDash(processDetail.pid)} + + + ), + }, + ]} + /> + {leaderListItems.map((leader) => ( + + ))} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts new file mode 100644 index 00000000000000..8c1154f0c0076f --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const description: CSSObject = { + width: `calc(100% - ${euiTheme.size.xl})`, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }; + + const descriptionSemibold: CSSObject = { + ...description, + fontWeight: euiTheme.font.weight.medium, + }; + + const executableAction: CSSObject = { + fontWeight: euiTheme.font.weight.semiBold, + paddingLeft: euiTheme.size.xs, + }; + + return { + description, + descriptionSemibold, + executableAction, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts new file mode 100644 index 00000000000000..9092009a7d291c --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + mockData, + mockProcessMap, +} from '../../../common/mocks/constants/session_view_process.mock'; +import { Process, ProcessMap } from '../../../common/types/process_tree'; +import { + updateProcessMap, + buildProcessTree, + searchProcessTree, + autoExpandProcessTree, +} from './helpers'; + +const SESSION_ENTITY_ID = '3d0192c6-7c54-5ee6-a110-3539a7cf42bc'; +const SEARCH_QUERY = 'vi'; +const SEARCH_RESULT_PROCESS_ID = '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727'; + +const mockEvents = mockData[0].events; + +describe('process tree hook helpers tests', () => { + let processMap: ProcessMap; + + beforeEach(() => { + processMap = {}; + }); + + it('updateProcessMap works', () => { + processMap = updateProcessMap(processMap, mockEvents); + + // processes are added to processMap + mockEvents.forEach((event) => { + expect(processMap[event.process.entity_id]).toBeTruthy(); + }); + }); + + it('buildProcessTree works', () => { + const newOrphans = buildProcessTree(mockProcessMap, mockEvents, [], SESSION_ENTITY_ID); + + const sessionLeaderChildrenIds = new Set( + mockProcessMap[SESSION_ENTITY_ID].children.map((child: Process) => child.id) + ); + + // processes are added under their parent's childrean array in processMap + mockEvents.forEach((event) => { + expect(sessionLeaderChildrenIds.has(event.process.entity_id)); + }); + + expect(newOrphans.length).toBe(0); + }); + + it('searchProcessTree works', () => { + const searchResults = searchProcessTree(mockProcessMap, SEARCH_QUERY); + + // search returns the process with search query in its event args + expect(searchResults[0].id).toBe(SEARCH_RESULT_PROCESS_ID); + }); + + it('autoExpandProcessTree works', () => { + processMap = mockProcessMap; + // mock what buildProcessTree does + const childProcesses = Object.values(processMap).filter( + (process) => process.id !== SESSION_ENTITY_ID + ); + processMap[SESSION_ENTITY_ID].children = childProcesses; + + expect(processMap[SESSION_ENTITY_ID].autoExpand).toBeFalsy(); + processMap = autoExpandProcessTree(processMap); + // session leader should have autoExpand to be true + expect(processMap[SESSION_ENTITY_ID].autoExpand).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts new file mode 100644 index 00000000000000..d3d7af1c62eda9 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Process, ProcessEvent, ProcessMap } from '../../../common/types/process_tree'; +import { ProcessImpl } from './hooks'; + +// given a page of new events, add these events to the appropriate process class model +// create a new process if none are created and return the mutated processMap +export const updateProcessMap = (processMap: ProcessMap, events: ProcessEvent[]) => { + events.forEach((event) => { + const { entity_id: id } = event.process; + let process = processMap[id]; + + if (!process) { + process = new ProcessImpl(id); + processMap[id] = process; + } + + process.addEvent(event); + }); + + return processMap; +}; + +// given a page of events, update process model parent child relationships +// if we cannot find a parent for a process include said process +// in the array of orphans. We track orphans in their own array, so +// we can attempt to re-parent the orphans when new pages of events are +// processed. This is especially important when paginating backwards +// (e.g in the case where the SessionView jumpToEvent prop is used, potentially skipping over ancestor processes) +export const buildProcessTree = ( + processMap: ProcessMap, + events: ProcessEvent[], + orphans: Process[], + sessionEntityId: string, + backwardDirection: boolean = false +) => { + // we process events in reverse order when paginating backwards. + if (backwardDirection) { + events = events.slice().reverse(); + } + + events.forEach((event) => { + const process = processMap[event.process.entity_id]; + const parentProcess = processMap[event.process.parent?.entity_id]; + + // if session leader, or process already has a parent, return + if (process.id === sessionEntityId || process.parent) { + return; + } + + if (parentProcess) { + process.parent = parentProcess; // handy for recursive operations (like auto expand) + + if (backwardDirection) { + parentProcess.children.unshift(process); + } else { + parentProcess.children.push(process); + } + } else if (!orphans?.includes(process)) { + // if no parent process, process is probably orphaned + if (backwardDirection) { + orphans?.unshift(process); + } else { + orphans?.push(process); + } + } + }); + + const newOrphans: Process[] = []; + + // with this new page of events processed, lets try re-parent any orphans + orphans?.forEach((process) => { + const parentProcess = processMap[process.getDetails().process.parent.entity_id]; + + if (parentProcess) { + process.parent = parentProcess; // handy for recursive operations (like auto expand) + + parentProcess.children.push(process); + } else { + newOrphans.push(process); + } + }); + + return newOrphans; +}; + +// given a plain text searchQuery, iterates over all processes in processMap +// and marks ones which match the below text (currently what is rendered in the process line item) +// process.searchMatched is used by process_tree_node to highlight the text which matched the search +// this funtion also returns a list of process results which is used by session_view_search_bar to drive +// result navigation UX +// FYI: this function mutates properties of models contained in processMap +export const searchProcessTree = (processMap: ProcessMap, searchQuery: string | undefined) => { + const results = []; + + for (const processId of Object.keys(processMap)) { + const process = processMap[processId]; + + if (searchQuery) { + const event = process.getDetails(); + const { working_directory: workingDirectory, args } = event.process; + + // TODO: the text we search is the same as what we render. + // in future we may support KQL searches to match against any property + // for now plain text search is limited to searching process.working_directory + process.args + const text = `${workingDirectory} ${args?.join(' ')}`; + + process.searchMatched = text.includes(searchQuery) ? searchQuery : null; + + if (process.searchMatched) { + results.push(process); + } + } else { + process.clearSearch(); + } + } + + return results; +}; + +// Iterate over all processes in processMap, and mark each process (and it's ancestors) for auto expansion if: +// a) the process was "user entered" (aka an interactive group leader) +// b) matches the plain text search above +// Returns the processMap with it's processes autoExpand bool set to true or false +// process.autoExpand is read by process_tree_node to determine whether to auto expand it's child processes. +export const autoExpandProcessTree = (processMap: ProcessMap) => { + for (const processId of Object.keys(processMap)) { + const process = processMap[processId]; + + if (process.searchMatched || process.isUserEntered()) { + let { parent } = process; + const parentIdSet = new Set(); + + while (parent && !parentIdSet.has(parent.id)) { + parentIdSet.add(parent.id); + parent.autoExpand = true; + parent = parent.parent; + } + } + } + + return processMap; +}; + +export const processNewEvents = ( + eventsProcessMap: ProcessMap, + events: ProcessEvent[] | undefined, + orphans: Process[], + sessionEntityId: string, + backwardDirection: boolean = false +): [ProcessMap, Process[]] => { + if (!events || events.length === 0) { + return [eventsProcessMap, orphans]; + } + + const updatedProcessMap = updateProcessMap(eventsProcessMap, events); + const newOrphans = buildProcessTree( + updatedProcessMap, + events, + orphans, + sessionEntityId, + backwardDirection + ); + + return [autoExpandProcessTree(updatedProcessMap), newOrphans]; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx new file mode 100644 index 00000000000000..9cece96fe84670 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EventAction } from '../../../common/types/process_tree'; +import { mockEvents } from '../../../common/mocks/constants/session_view_process.mock'; +import { ProcessImpl } from './hooks'; + +describe('ProcessTree hooks', () => { + describe('ProcessImpl.getDetails memoize will cache bust on new events', () => { + it('should return the exec event details when this.events changes', () => { + const process = new ProcessImpl(mockEvents[0].process.entity_id); + + process.addEvent(mockEvents[0]); + + let result = process.getDetails(); + + // push exec event + process.addEvent(mockEvents[1]); + + result = process.getDetails(); + + expect(result.event.action).toEqual(EventAction.exec); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts new file mode 100644 index 00000000000000..a8c6ffe8e75d3a --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -0,0 +1,255 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import _ from 'lodash'; +import memoizeOne from 'memoize-one'; +import { useState, useEffect } from 'react'; +import { + EventAction, + EventKind, + Process, + ProcessEvent, + ProcessMap, + ProcessEventsPage, +} from '../../../common/types/process_tree'; +import { processNewEvents, searchProcessTree, autoExpandProcessTree } from './helpers'; +import { sortProcesses } from '../../../common/utils/sort_processes'; + +interface UseProcessTreeDeps { + sessionEntityId: string; + data: ProcessEventsPage[]; + searchQuery?: string; +} + +export class ProcessImpl implements Process { + id: string; + events: ProcessEvent[]; + children: Process[]; + parent: Process | undefined; + autoExpand: boolean; + searchMatched: string | null; + orphans: Process[]; + + constructor(id: string) { + this.id = id; + this.events = []; + this.children = []; + this.orphans = []; + this.autoExpand = false; + this.searchMatched = null; + } + + addEvent(event: ProcessEvent) { + // rather than push new events on the array, we return a new one + // this helps the below memoizeOne functions to behave correctly. + this.events = this.events.concat(event); + } + + clearSearch() { + this.searchMatched = null; + this.autoExpand = false; + } + + getChildren(verboseMode: boolean) { + let children = this.children; + + // if there are orphans, we just render them inline with the other child processes (currently only session leader does this) + if (this.orphans.length) { + children = [...children, ...this.orphans].sort(sortProcesses); + } + + // When verboseMode is false, we filter out noise via a few techniques. + // This option is driven by the "verbose mode" toggle in SessionView/index.tsx + if (!verboseMode) { + return children.filter((child) => { + const { group_leader: groupLeader, session_leader: sessionLeader } = + child.getDetails().process; + + // search matches will never be filtered out + if (child.searchMatched) { + return true; + } + + // Hide processes that have their session leader as their process group leader. + // This accounts for a lot of noise from bash and other shells forking, running auto completion processes and + // other shell startup activities (e.g bashrc .profile etc) + if (groupLeader.pid === sessionLeader.pid) { + return false; + } + + // If the process has no children and has not exec'd (fork only), we hide it. + if (child.children.length === 0 && !child.hasExec()) { + return false; + } + + return true; + }); + } + + return children; + } + + hasOutput() { + return !!this.findEventByAction(this.events, EventAction.output); + } + + hasAlerts() { + return !!this.findEventByKind(this.events, EventKind.signal); + } + + getAlerts() { + return this.filterEventsByKind(this.events, EventKind.signal); + } + + hasExec() { + return !!this.findEventByAction(this.events, EventAction.exec); + } + + hasExited() { + return !!this.findEventByAction(this.events, EventAction.end); + } + + getDetails() { + return this.getDetailsMemo(this.events); + } + + getOutput() { + // not implemented, output ECS schema not defined (for a future release) + return ''; + } + + // isUserEntered is a best guess at which processes were initiated by a real person + // In most situations a user entered command in a shell such as bash, will cause bash + // to fork, create a new process group, and exec the command (e.g ls). If the session + // has a controlling tty (aka an interactive session), we assume process group leaders + // with a session leader for a parent are "user entered". + // Because of the presence of false positives in this calculation, it is currently + // only used to auto expand parts of the tree that could be of interest. + isUserEntered() { + const event = this.getDetails(); + const { + pid, + tty, + parent, + session_leader: sessionLeader, + group_leader: groupLeader, + } = event.process; + + const parentIsASessionLeader = parent.pid === sessionLeader.pid; // possibly bash, zsh or some other shell + const processIsAGroupLeader = pid === groupLeader.pid; + const sessionIsInteractive = !!tty; + + return sessionIsInteractive && parentIsASessionLeader && processIsAGroupLeader; + } + + getMaxAlertLevel() { + // TODO: as part of alerts details work + tie in with the new alert flyout + return null; + } + + findEventByAction = memoizeOne((events: ProcessEvent[], action: EventAction) => { + return events.find(({ event }) => event.action === action); + }); + + findEventByKind = memoizeOne((events: ProcessEvent[], kind: EventKind) => { + return events.find(({ event }) => event.kind === kind); + }); + + filterEventsByAction = memoizeOne((events: ProcessEvent[], action: EventAction) => { + return events.filter(({ event }) => event.action === action); + }); + + filterEventsByKind = memoizeOne((events: ProcessEvent[], kind: EventKind) => { + return events.filter(({ event }) => event.kind === kind); + }); + + // returns the most recent fork, exec, or end event + // to be used as a source for the most up to date details + // on the processes lifecycle. + getDetailsMemo = memoizeOne((events: ProcessEvent[]) => { + const actionsToFind = [EventAction.fork, EventAction.exec, EventAction.end]; + const filtered = events.filter((processEvent) => { + return actionsToFind.includes(processEvent.event.action); + }); + + // because events is already ordered by @timestamp we take the last event + // which could be a fork (w no exec or exit), most recent exec event (there can be multiple), or end event. + // If a process has an 'end' event will always be returned (since it is last and includes details like exit_code and end time) + return filtered[filtered.length - 1] || ({} as ProcessEvent); + }); +} + +export const useProcessTree = ({ sessionEntityId, data, searchQuery }: UseProcessTreeDeps) => { + // initialize map, as well as a placeholder for session leader process + // we add a fake session leader event, sourced from wide event data. + // this is because we might not always have a session leader event + // especially if we are paging in reverse from deep within a large session + const fakeLeaderEvent = data[0].events.find((event) => event.event.kind === EventKind.event); + const sessionLeaderProcess = new ProcessImpl(sessionEntityId); + + if (fakeLeaderEvent) { + fakeLeaderEvent.process = { + ...fakeLeaderEvent.process, + ...fakeLeaderEvent.process.entry_leader, + parent: fakeLeaderEvent.process.parent, + }; + sessionLeaderProcess.events.push(fakeLeaderEvent); + } + + const initializedProcessMap: ProcessMap = { + [sessionEntityId]: sessionLeaderProcess, + }; + + const [processMap, setProcessMap] = useState(initializedProcessMap); + const [processedPages, setProcessedPages] = useState([]); + const [searchResults, setSearchResults] = useState([]); + const [orphans, setOrphans] = useState([]); + + useEffect(() => { + let updatedProcessMap: ProcessMap = processMap; + let newOrphans: Process[] = orphans; + const newProcessedPages: ProcessEventsPage[] = []; + + data.forEach((page, i) => { + const processed = processedPages.find((p) => p.cursor === page.cursor); + + if (!processed) { + const backwards = i < processedPages.length; + + const result = processNewEvents( + updatedProcessMap, + page.events, + orphans, + sessionEntityId, + backwards + ); + + updatedProcessMap = result[0]; + newOrphans = result[1]; + + newProcessedPages.push(page); + } + }); + + if (newProcessedPages.length > 0) { + setProcessMap({ ...updatedProcessMap }); + setProcessedPages([...processedPages, ...newProcessedPages]); + setOrphans(newOrphans); + } + }, [data, processMap, orphans, processedPages, sessionEntityId]); + + useEffect(() => { + setSearchResults(searchProcessTree(processMap, searchQuery)); + autoExpandProcessTree(processMap); + }, [searchQuery, processMap]); + + // set new orphans array on the session leader + const sessionLeader = processMap[sessionEntityId]; + + sessionLeader.orphans = orphans; + + return { sessionLeader: processMap[sessionEntityId], processMap, searchResults }; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx new file mode 100644 index 00000000000000..ac6807984ba831 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mockData } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessImpl } from './hooks'; +import { ProcessTree } from './index'; + +describe('ProcessTree component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTree is mounted', () => { + it('should render given a valid sessionEntityId and data', () => { + renderResult = mockedContext.render( + true} + hasNextPage={false} + fetchPreviousPage={() => true} + hasPreviousPage={false} + onProcessSelected={jest.fn()} + /> + ); + expect(renderResult.queryByTestId('sessionView:sessionViewProcessTree')).toBeTruthy(); + expect(renderResult.queryAllByTestId('sessionView:processTreeNode')).toBeTruthy(); + }); + + it('should insert a DOM element used to highlight a process when selectedProcess is set', () => { + const mockSelectedProcess = new ProcessImpl(mockData[0].events[0].process.entity_id); + + renderResult = mockedContext.render( + true} + hasNextPage={false} + fetchPreviousPage={() => true} + hasPreviousPage={false} + selectedProcess={mockSelectedProcess} + onProcessSelected={jest.fn()} + /> + ); + + // click on view more button + renderResult.getByTestId('sessionView:processTreeNodeChildProcessesButton').click(); + + expect( + renderResult + .queryByTestId('sessionView:processTreeSelectionArea') + ?.parentElement?.getAttribute('data-id') + ).toEqual(mockSelectedProcess.id); + + // change the selected process + const mockSelectedProcess2 = new ProcessImpl(mockData[0].events[1].process.entity_id); + + renderResult.rerender( + true} + hasNextPage={false} + fetchPreviousPage={() => true} + hasPreviousPage={false} + selectedProcess={mockSelectedProcess2} + onProcessSelected={jest.fn()} + /> + ); + + expect( + renderResult + .queryByTestId('sessionView:processTreeSelectionArea') + ?.parentElement?.getAttribute('data-id') + ).toEqual(mockSelectedProcess2.id); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx new file mode 100644 index 00000000000000..6b3061a0d77bb3 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useRef, useEffect, useLayoutEffect, useCallback } from 'react'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ProcessTreeNode } from '../process_tree_node'; +import { useProcessTree } from './hooks'; +import { Process, ProcessEventsPage, ProcessEvent } from '../../../common/types/process_tree'; +import { useScroll } from '../../hooks/use_scroll'; +import { useStyles } from './styles'; + +type FetchFunction = () => void; + +interface ProcessTreeDeps { + // process.entity_id to act as root node (typically a session (or entry session) leader). + sessionEntityId: string; + + data: ProcessEventsPage[]; + + jumpToEvent?: ProcessEvent; + isFetching: boolean; + hasNextPage: boolean | undefined; + hasPreviousPage: boolean | undefined; + fetchNextPage: FetchFunction; + fetchPreviousPage: FetchFunction; + + // plain text search query (only searches "process.working_directory process.args.join(' ')" + searchQuery?: string; + + // currently selected process + selectedProcess?: Process | null; + onProcessSelected: (process: Process) => void; + setSearchResults?: (results: Process[]) => void; +} + +export const ProcessTree = ({ + sessionEntityId, + data, + jumpToEvent, + isFetching, + hasNextPage, + hasPreviousPage, + fetchNextPage, + fetchPreviousPage, + searchQuery, + selectedProcess, + onProcessSelected, + setSearchResults, +}: ProcessTreeDeps) => { + const styles = useStyles(); + + const { sessionLeader, processMap, searchResults } = useProcessTree({ + sessionEntityId, + data, + searchQuery, + }); + + const scrollerRef = useRef(null); + const selectionAreaRef = useRef(null); + + useEffect(() => { + if (setSearchResults) { + setSearchResults(searchResults); + } + }, [searchResults, setSearchResults]); + + useScroll({ + div: scrollerRef.current, + handler: (pos: number, endReached: boolean) => { + if (!isFetching && endReached) { + fetchNextPage(); + } + }, + }); + + /** + * highlights a process in the tree + * we do it this way to avoid state changes on potentially thousands of components + */ + const selectProcess = useCallback( + (process: Process) => { + if (!selectionAreaRef?.current || !scrollerRef?.current) { + return; + } + + const selectionAreaEl = selectionAreaRef.current; + selectionAreaEl.style.display = 'block'; + + // TODO: concept of alert level unknown wrt to elastic security + const alertLevel = process.getMaxAlertLevel(); + + if (alertLevel && alertLevel >= 0) { + selectionAreaEl.style.backgroundColor = + alertLevel > 0 ? styles.alertSelected : styles.defaultSelected; + } else { + selectionAreaEl.style.backgroundColor = ''; + } + + // find the DOM element for the command which is selected by id + const processEl = scrollerRef.current.querySelector(`[data-id="${process.id}"]`); + + if (processEl) { + processEl.prepend(selectionAreaEl); + + const cTop = scrollerRef.current.scrollTop; + const cBottom = cTop + scrollerRef.current.clientHeight; + + const eTop = processEl.offsetTop; + const eBottom = eTop + processEl.clientHeight; + const isVisible = eTop >= cTop && eBottom <= cBottom; + + if (!isVisible) { + processEl.scrollIntoView({ block: 'center' }); + } + } + }, + [styles.alertSelected, styles.defaultSelected] + ); + + useLayoutEffect(() => { + if (selectedProcess) { + selectProcess(selectedProcess); + } + }, [selectedProcess, selectProcess]); + + useEffect(() => { + // after 2 pages are loaded (due to bi-directional jump to), auto select the process + // for the jumpToEvent + if (jumpToEvent && data.length === 2) { + const process = processMap[jumpToEvent.process.entity_id]; + + if (process) { + onProcessSelected(process); + } + } + }, [jumpToEvent, processMap, onProcessSelected, data]); + + // auto selects the session leader process if no selection is made yet + useEffect(() => { + if (!selectedProcess) { + onProcessSelected(sessionLeader); + } + }, [sessionLeader, onProcessSelected, selectedProcess]); + + return ( +
+ {hasPreviousPage && ( + + + + )} + {sessionLeader && ( + + )} +
+ {hasNextPage && ( + + + + )} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/styles.ts b/x-pack/plugins/session_view/public/components/process_tree/styles.ts new file mode 100644 index 00000000000000..65fb66ad90aa7c --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/styles.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { transparentize, useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const defaultSelectionColor = euiTheme.colors.accent; + + const scroller: CSSObject = { + position: 'relative', + fontFamily: euiTheme.font.familyCode, + overflow: 'auto', + height: '100%', + backgroundColor: euiTheme.colors.lightestShade, + }; + + const selectionArea: CSSObject = { + position: 'absolute', + display: 'none', + marginLeft: '-50%', + width: '150%', + height: '100%', + backgroundColor: defaultSelectionColor, + pointerEvents: 'none', + opacity: 0.1, + }; + + const defaultSelected = transparentize(euiTheme.colors.primary, 0.008); + const alertSelected = transparentize(euiTheme.colors.danger, 0.008); + + return { + scroller, + selectionArea, + defaultSelected, + alertSelected, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx new file mode 100644 index 00000000000000..618b36578d7dae --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessTreeAlerts } from './index'; + +describe('ProcessTreeAlerts component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTreeAlerts is mounted', () => { + it('should return null if no alerts', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeNull(); + }); + + it('should return an array of alert details', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); + mockAlerts.forEach((alert) => { + if (!alert.kibana) { + return; + } + const { uuid, rule, original_event: event, workflow_status: status } = alert.kibana.alert; + const { name, query, severity } = rule; + + expect( + renderResult.queryByTestId(`sessionView:sessionViewAlertDetail-${uuid}`) + ).toBeTruthy(); + expect( + renderResult.queryByTestId(`sessionView:sessionViewAlertDetailViewRule-${uuid}`) + ).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(event.action, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(status, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(name, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(query, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(severity, 'i')).length).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx new file mode 100644 index 00000000000000..5312c09867b96e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButton, EuiText, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useStyles } from './styles'; +import { ProcessEvent } from '../../../common/types/process_tree'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { CoreStart } from '../../../../../../src/core/public'; + +interface ProcessTreeAlertsDeps { + alerts: ProcessEvent[]; +} + +const getRuleUrl = (alert: ProcessEvent, http: CoreStart['http']) => { + return http.basePath.prepend(`/app/security/rules/id/${alert.kibana?.alert.rule.uuid}`); +}; + +const ProcessTreeAlert = ({ alert }: { alert: ProcessEvent }) => { + const { http } = useKibana().services; + + if (!alert.kibana) { + return null; + } + + const { uuid, rule, original_event: event, workflow_status: status } = alert.kibana.alert; + const { name, query, severity } = rule; + + return ( + + + +
+ +
+ {name} +
+ +
+ {query} +
+ +
+ +
+ {severity} +
+ +
+ {status} +
+ +
+ +
+ {event.action} + +
+ + + +
+
+
+
+ ); +}; + +export function ProcessTreeAlerts({ alerts }: ProcessTreeAlertsDeps) { + const styles = useStyles(); + + if (alerts.length === 0) { + return null; + } + + return ( +
+ {alerts.map((alert: ProcessEvent) => ( + + ))} +
+ ); +} diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts new file mode 100644 index 00000000000000..d601891591305b --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { size, colors, border } = euiTheme; + + const container: CSSObject = { + marginTop: size.s, + marginRight: size.s, + color: colors.text, + padding: size.m, + borderStyle: 'solid', + borderColor: colors.lightShade, + borderWidth: border.width.thin, + borderRadius: border.radius.medium, + maxWidth: 800, + backgroundColor: 'white', + '&>div': { + borderTop: border.thin, + marginTop: size.m, + paddingTop: size.m, + '&:first-child': { + borderTop: 'none', + }, + }, + }; + + return { + container, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx new file mode 100644 index 00000000000000..16cb9461746916 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiButton, EuiIcon, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Process } from '../../../common/types/process_tree'; +import { useButtonStyles } from './use_button_styles'; + +export const ChildrenProcessesButton = ({ + onToggle, + isExpanded, +}: { + onToggle: () => void; + isExpanded: boolean; +}) => { + const { button, buttonArrow, getExpandedIcon } = useButtonStyles(); + + return ( + + + + + ); +}; + +export const SessionLeaderButton = ({ + process, + onClick, + showGroupLeadersOnly, + childCount, +}: { + process: Process; + onClick: () => void; + showGroupLeadersOnly: boolean; + childCount: number; +}) => { + const groupLeaderCount = process.getChildren(false).length; + const sameGroupCount = childCount - groupLeaderCount; + const { button, buttonArrow, getExpandedIcon } = useButtonStyles(); + + if (sameGroupCount > 0) { + return ( + + +

+ } + > + + + + +
+ ); + } + return null; +}; + +export const AlertButton = ({ + isExpanded, + onToggle, +}: { + isExpanded: boolean; + onToggle: () => void; +}) => { + const { alertButton, buttonArrow, getExpandedIcon } = useButtonStyles(); + + return ( + + + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx new file mode 100644 index 00000000000000..2a3bf94086021b --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { + processMock, + childProcessMock, + sessionViewAlertProcessMock, +} from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessTreeNode } from './index'; + +describe('ProcessTreeNode component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTreeNode is mounted', () => { + it('should render given a valid process', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:processTreeNode')).toBeTruthy(); + }); + + it('should have an alternate rendering for a session leader', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.container.textContent).toEqual(' bash started by vagrant'); + }); + + // commented out until we get new UX for orphans treatment aka disjointed tree + // it('renders orphaned node', async () => { + // renderResult = mockedContext.render(); + // expect(renderResult.queryByText(/orphaned/i)).toBeTruthy(); + // }); + + it('renders Exec icon and exit code for executed process', async () => { + const executedProcessMock: typeof processMock = { + ...processMock, + hasExec: () => true, + }; + + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:processTreeNodeExecIcon')).toBeTruthy(); + expect(renderResult.queryByTestId('sessionView:processTreeNodeExitCode')).toBeTruthy(); + }); + + it('does not render exit code if it does not exist', async () => { + const processWithoutExitCode: typeof processMock = { + ...processMock, + hasExec: () => true, + getDetails: () => ({ + ...processMock.getDetails(), + process: { + ...processMock.getDetails().process, + exit_code: undefined, + }, + }), + }; + + renderResult = mockedContext.render(); + expect(renderResult.queryByTestId('sessionView:processTreeNodeExitCode')).toBeFalsy(); + }); + + it('renders Root Escalation flag properly', async () => { + const rootEscalationProcessMock: typeof processMock = { + ...processMock, + getDetails: () => ({ + ...processMock.getDetails(), + user: { + id: '-1', + name: 'root', + }, + process: { + ...processMock.getDetails().process, + parent: { + ...processMock.getDetails().process.parent, + user: { + name: 'test', + id: '1000', + }, + }, + }, + }), + }; + + renderResult = mockedContext.render(); + + expect( + renderResult.queryByTestId('sessionView:processTreeNodeRootEscalationFlag') + ).toBeTruthy(); + }); + + it('executes callback function when user Clicks', async () => { + const onProcessSelected = jest.fn(); + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId('sessionView:processTreeNodeRow')); + expect(onProcessSelected).toHaveBeenCalled(); + }); + + it('does not executes callback function when user is Clicking to copy text', async () => { + const windowGetSelectionSpy = jest.spyOn(window, 'getSelection'); + + const onProcessSelected = jest.fn(); + + renderResult = mockedContext.render( + + ); + + // @ts-ignore + windowGetSelectionSpy.mockImplementation(() => ({ type: 'Range' })); + + userEvent.click(renderResult.getByTestId('sessionView:processTreeNodeRow')); + expect(onProcessSelected).not.toHaveBeenCalled(); + + // cleanup + windowGetSelectionSpy.mockRestore(); + }); + describe('Alerts', () => { + it('renders Alert button when process has alerts', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('processTreeNodeAlertButton')).toBeTruthy(); + }); + it('toggle Alert Details button when Alert button is clicked', async () => { + renderResult = mockedContext.render( + + ); + userEvent.click(renderResult.getByTestId('processTreeNodeAlertButton')); + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); + userEvent.click(renderResult.getByTestId('processTreeNodeAlertButton')); + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeFalsy(); + }); + }); + describe('Child processes', () => { + it('renders Child processes button when process has Child processes', async () => { + const processMockWithChildren: typeof processMock = { + ...processMock, + getChildren: () => [childProcessMock], + }; + + renderResult = mockedContext.render(); + + expect( + renderResult.queryByTestId('sessionView:processTreeNodeChildProcessesButton') + ).toBeTruthy(); + }); + it('toggle Child processes nodes when Child processes button is clicked', async () => { + const processMockWithChildren: typeof processMock = { + ...processMock, + getChildren: () => [childProcessMock], + }; + + renderResult = mockedContext.render(); + + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toHaveLength(1); + + userEvent.click( + renderResult.getByTestId('sessionView:processTreeNodeChildProcessesButton') + ); + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toHaveLength(2); + + userEvent.click( + renderResult.getByTestId('sessionView:processTreeNodeChildProcessesButton') + ); + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toHaveLength(1); + }); + }); + describe('Search', () => { + it('highlights text within the process node line item if it matches the searchQuery', () => { + // set a mock search matched indicator for the process (typically done by ProcessTree/helpers.ts) + processMock.searchMatched = '/vagrant'; + + renderResult = mockedContext.render(); + + expect( + renderResult.getByTestId('sessionView:processNodeSearchHighlight').textContent + ).toEqual('/vagrant'); + }); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx new file mode 100644 index 00000000000000..9db83f58f77382 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -0,0 +1,213 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + *2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { + useRef, + useLayoutEffect, + useState, + useEffect, + MouseEvent, + useCallback, +} from 'react'; +import { EuiButton, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { ProcessTreeAlerts } from '../process_tree_alerts'; +import { SessionLeaderButton, AlertButton, ChildrenProcessesButton } from './buttons'; +import { useButtonStyles } from './use_button_styles'; +interface ProcessDeps { + process: Process; + isSessionLeader?: boolean; + depth?: number; + onProcessSelected?: (process: Process) => void; +} + +/** + * Renders a node on the process tree + */ +export function ProcessTreeNode({ + process, + isSessionLeader = false, + depth = 0, + onProcessSelected, +}: ProcessDeps) { + const textRef = useRef(null); + + const [childrenExpanded, setChildrenExpanded] = useState(isSessionLeader || process.autoExpand); + const [alertsExpanded, setAlertsExpanded] = useState(false); + const [showGroupLeadersOnly, setShowGroupLeadersOnly] = useState(isSessionLeader); + const { searchMatched } = process; + + useEffect(() => { + setChildrenExpanded(isSessionLeader || process.autoExpand); + }, [isSessionLeader, process.autoExpand]); + + const alerts = process.getAlerts(); + const styles = useStyles({ depth, hasAlerts: !!alerts.length }); + const buttonStyles = useButtonStyles(); + + useLayoutEffect(() => { + if (searchMatched !== null && textRef.current) { + const regex = new RegExp(searchMatched); + const text = textRef.current.textContent; + + if (text) { + const html = text.replace(regex, (match) => { + return `${match}`; + }); + + // eslint-disable-next-line no-unsanitized/property + textRef.current.innerHTML = html; + } + } + }, [searchMatched, styles.searchHighlight]); + + const onShowGroupLeaderOnlyClick = useCallback(() => { + setShowGroupLeadersOnly(!showGroupLeadersOnly); + }, [showGroupLeadersOnly]); + + const onChildrenToggle = useCallback(() => { + setChildrenExpanded(!childrenExpanded); + }, [childrenExpanded]); + + const onAlertsToggle = useCallback(() => { + setAlertsExpanded(!alertsExpanded); + }, [alertsExpanded]); + + const onProcessClicked = (e: MouseEvent) => { + e.stopPropagation(); + + const selection = window.getSelection(); + + // do not select the command if the user was just selecting text for copy. + if (selection && selection.type === 'Range') { + return; + } + + onProcessSelected?.(process); + }; + + const processDetails = process.getDetails(); + + if (!processDetails) { + return null; + } + + const id = process.id; + const { user } = processDetails; + const { + args, + name, + tty, + parent, + working_directory: workingDirectory, + exit_code: exitCode, + } = processDetails.process; + + const children = process.getChildren(!showGroupLeadersOnly); + const childCount = process.getChildren(true).length; + const shouldRenderChildren = childrenExpanded && children && children.length > 0; + const childrenTreeDepth = depth + 1; + + const showRootEscalation = user.name === 'root' && user.id !== parent.user.id; + const interactiveSession = !!tty; + const sessionIcon = interactiveSession ? 'consoleApp' : 'compute'; + const hasExec = process.hasExec(); + const iconTestSubj = hasExec + ? 'sessionView:processTreeNodeExecIcon' + : 'sessionView:processTreeNodeForkIcon'; + const processIcon = hasExec ? 'console' : 'branch'; + + return ( +
+
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} +
+ {isSessionLeader ? ( + <> + {name || args[0]}{' '} + {' '} + {user.name} + + + ) : ( + + + + {workingDirectory}  + {args[0]}  + {args.slice(1).join(' ')} + {exitCode !== undefined && ( + + {' '} + [exit_code: {exitCode}] + + )} + + + )} + + {showRootEscalation && ( + + + + )} + {!isSessionLeader && childCount > 0 && ( + + )} + {alerts.length > 0 && ( + + )} +
+
+ + {alertsExpanded && } + + {shouldRenderChildren && ( +
+ {children.map((child) => { + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts new file mode 100644 index 00000000000000..07092d6de28ead --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + depth: number; + hasAlerts: boolean; +} + +export const useStyles = ({ depth, hasAlerts }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, border, size } = euiTheme; + + const TREE_INDENT = euiTheme.base * 2; + + const darkText: CSSObject = { + color: colors.text, + }; + + const searchHighlight = ` + background-color: ${colors.highlight}; + color: ${colors.fullShade}; + border-radius: ${border.radius.medium}; + `; + + const children: CSSObject = { + position: 'relative', + color: colors.ghost, + marginLeft: size.base, + paddingLeft: size.s, + borderLeft: border.editable, + marginTop: size.s, + }; + + /** + * gets border, bg and hover colors for a process + */ + const getHighlightColors = () => { + let bgColor = 'none'; + const hoverColor = transparentize(colors.primary, 0.04); + let borderColor = 'transparent'; + + // TODO: alerts highlight colors + if (hasAlerts) { + bgColor = transparentize(colors.danger, 0.04); + borderColor = transparentize(colors.danger, 0.48); + } + + return { bgColor, borderColor, hoverColor }; + }; + + const { bgColor, borderColor, hoverColor } = getHighlightColors(); + + const processNode: CSSObject = { + display: 'block', + cursor: 'pointer', + position: 'relative', + margin: `${size.s} 0px`, + '&:not(:first-child)': { + marginTop: size.s, + }, + '&:hover:before': { + backgroundColor: hoverColor, + }, + '&:before': { + position: 'absolute', + height: '100%', + pointerEvents: 'none', + content: `''`, + marginLeft: `-${depth * TREE_INDENT}px`, + borderLeft: `${size.xs} solid ${borderColor}`, + backgroundColor: bgColor, + width: `calc(100% + ${depth * TREE_INDENT}px)`, + }, + }; + + const wrapper: CSSObject = { + paddingLeft: size.s, + position: 'relative', + verticalAlign: 'middle', + color: colors.mediumShade, + wordBreak: 'break-all', + minHeight: size.l, + lineHeight: size.l, + }; + + const workingDir: CSSObject = { + color: colors.successText, + }; + + const alertDetails: CSSObject = { + padding: size.s, + border: border.editable, + borderRadius: border.radius.medium, + }; + + return { + darkText, + searchHighlight, + children, + processNode, + wrapper, + workingDir, + alertDetails, + }; + }, [depth, euiTheme, hasAlerts]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts new file mode 100644 index 00000000000000..d208fa8f079af3 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { euiLightVars as theme } from '@kbn/ui-theme'; +import { CSSObject } from '@emotion/react'; + +export const useButtonStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, border, font, size } = euiTheme; + + const button: CSSObject = { + background: transparentize(theme.euiColorVis6, 0.04), + border: `${border.width.thin} solid ${transparentize(theme.euiColorVis6, 0.48)}`, + lineHeight: '18px', + height: '20px', + fontSize: '11px', + fontFamily: font.familyCode, + borderRadius: border.radius.medium, + color: colors.text, + marginLeft: size.s, + minWidth: 0, + }; + + const buttonArrow: CSSObject = { + marginLeft: size.s, + }; + + const alertButton: CSSObject = { + ...button, + background: transparentize(colors.dangerText, 0.04), + border: `${border.width.thin} solid ${transparentize(colors.dangerText, 0.48)}`, + }; + + const userChangedButton: CSSObject = { + ...button, + background: transparentize(theme.euiColorVis1, 0.04), + border: `${border.width.thin} solid ${transparentize(theme.euiColorVis1, 0.48)}`, + }; + + const getExpandedIcon = (expanded: boolean) => { + return expanded ? 'arrowUp' : 'arrowDown'; + }; + + return { + buttonArrow, + button, + alertButton, + userChangedButton, + getExpandedIcon, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts new file mode 100644 index 00000000000000..b93e5b43ddf884 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useState } from 'react'; +import { useInfiniteQuery } from 'react-query'; +import { EuiSearchBarOnChangeArgs } from '@elastic/eui'; +import { CoreStart } from 'kibana/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { ProcessEvent, ProcessEventResults } from '../../../common/types/process_tree'; +import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE } from '../../../common/constants'; + +export const useFetchSessionViewProcessEvents = ( + sessionEntityId: string, + jumpToEvent: ProcessEvent | undefined +) => { + const { http } = useKibana().services; + + const jumpToCursor = jumpToEvent && jumpToEvent['@timestamp']; + + const query = useInfiniteQuery( + 'sessionViewProcessEvents', + async ({ pageParam = {} }) => { + let { cursor } = pageParam; + const { forward } = pageParam; + + if (!cursor && jumpToCursor) { + cursor = jumpToCursor; + } + + const res = await http.get(PROCESS_EVENTS_ROUTE, { + query: { + sessionEntityId, + cursor, + forward, + }, + }); + + const events = res.events.map((event: any) => event._source as ProcessEvent); + + return { events, cursor }; + }, + { + getNextPageParam: (lastPage, pages) => { + if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { + return { + cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], + forward: true, + }; + } + }, + getPreviousPageParam: (firstPage, pages) => { + if (jumpToEvent && firstPage.events.length === PROCESS_EVENTS_PER_PAGE) { + return { + cursor: firstPage.events[0]['@timestamp'], + forward: false, + }; + } + }, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ); + + useEffect(() => { + if (jumpToEvent && query.data?.pages.length === 1) { + query.fetchPreviousPage(); + } + }, [jumpToEvent, query]); + + return query; +}; + +export const useSearchQuery = () => { + const [searchQuery, setSearchQuery] = useState(''); + const onSearch = ({ query }: EuiSearchBarOnChangeArgs) => { + if (query) { + setSearchQuery(query.text); + } else { + setSearchQuery(''); + } + }; + + return { + searchQuery, + onSearch, + }; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view/index.test.tsx new file mode 100644 index 00000000000000..41336977cf78a3 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/index.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import React from 'react'; +import { sessionViewProcessEventsMock } from '../../../common/mocks/responses/session_view_process_events.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { SessionView } from './index'; +import userEvent from '@testing-library/user-event'; + +describe('SessionView component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockedApi: AppContextTestRender['coreStart']['http']['get']; + + const waitForApiCall = () => waitFor(() => expect(mockedApi).toHaveBeenCalled()); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockedApi = mockedContext.coreStart.http.get; + render = () => + (renderResult = mockedContext.render()); + }); + + describe('When SessionView is mounted', () => { + describe('And no data exists', () => { + beforeEach(async () => { + mockedApi.mockResolvedValue({ + events: [], + }); + }); + + it('should show the Empty message', async () => { + render(); + await waitForApiCall(); + expect(renderResult.getByTestId('sessionView:sessionViewProcessEventsEmpty')).toBeTruthy(); + }); + + it('should not display the search bar', async () => { + render(); + await waitForApiCall(); + expect( + renderResult.queryByTestId('sessionView:sessionViewProcessEventsSearch') + ).toBeFalsy(); + }); + }); + + describe('And data exists', () => { + beforeEach(async () => { + mockedApi.mockResolvedValue(sessionViewProcessEventsMock); + }); + + it('should show loading indicator while retrieving data and hide it when it gets it', async () => { + let releaseApiResponse: (value?: unknown) => void; + + // make the request wait + mockedApi.mockReturnValue(new Promise((resolve) => (releaseApiResponse = resolve))); + render(); + await waitForApiCall(); + + // see if loader is present + expect(renderResult.getByText('Loading session…')).toBeTruthy(); + + // release the request + releaseApiResponse!(mockedApi); + + // check the loader is gone + await waitForElementToBeRemoved(renderResult.getByText('Loading session…')); + }); + + it('should display the search bar', async () => { + render(); + await waitForApiCall(); + expect(renderResult.getByTestId('sessionView:sessionViewProcessEventsSearch')).toBeTruthy(); + }); + + it('should show items on the list, and auto selects session leader', async () => { + render(); + await waitForApiCall(); + + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toBeTruthy(); + + const selectionArea = renderResult.queryByTestId('sessionView:processTreeSelectionArea'); + + expect(selectionArea?.parentElement?.getAttribute('data-id')).toEqual('test-entity-id'); + }); + + it('should toggle detail panel visibilty when detail button clicked', async () => { + render(); + await waitForApiCall(); + + userEvent.click(renderResult.getByTestId('sessionViewDetailPanelToggle')); + expect(renderResult.getByText('Process')).toBeTruthy(); + expect(renderResult.getByText('Host')).toBeTruthy(); + expect(renderResult.getByText('Alerts')).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx new file mode 100644 index 00000000000000..7a82edc94ff1b1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useCallback } from 'react'; +import { + EuiEmptyPrompt, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiResizableContainer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { SectionLoading } from '../../shared_imports'; +import { ProcessTree } from '../process_tree'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { SessionViewDetailPanel } from '../session_view_detail_panel'; +import { SessionViewSearchBar } from '../session_view_search_bar'; +import { useStyles } from './styles'; +import { useFetchSessionViewProcessEvents } from './hooks'; + +interface SessionViewDeps { + // the root node of the process tree to render. e.g process.entry.entity_id or process.session_leader.entity_id + sessionEntityId: string; + height?: number; + jumpToEvent?: ProcessEvent; +} + +/** + * The main wrapper component for the session view. + */ +export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionViewDeps) => { + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [selectedProcess, setSelectedProcess] = useState(null); + + const styles = useStyles({ height }); + + const onProcessSelected = useCallback((process: Process) => { + setSelectedProcess(process); + }, []); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + fetchPreviousPage, + hasPreviousPage, + } = useFetchSessionViewProcessEvents(sessionEntityId, jumpToEvent); + + const hasData = data && data.pages.length > 0 && data.pages[0].events.length > 0; + const renderIsLoading = isFetching && !data; + const renderDetails = isDetailOpen && selectedProcess; + const toggleDetailPanel = () => { + setIsDetailOpen(!isDetailOpen); + }; + + if (!isFetching && !hasData) { + return ( + + + + } + body={ +

+ +

+ } + /> + ); + } + + return ( + <> + + + + + + + + + + + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + {renderIsLoading && ( + + + + )} + + {error && ( + + + + } + body={ +

+ +

+ } + /> + )} + + {hasData && ( +
+ +
+ )} +
+ + {renderDetails ? ( + <> + + + + + + ) : ( + <> + {/* Returning an empty element here (instead of false) to avoid a bug in EuiResizableContainer */} + + )} + + )} +
+ + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SessionView as default }; diff --git a/x-pack/plugins/session_view/public/components/session_view/styles.ts b/x-pack/plugins/session_view/public/components/session_view/styles.ts new file mode 100644 index 00000000000000..d7159ec5b1b399 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/styles.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + height: number | undefined; +} + +export const useStyles = ({ height = 500 }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const processTree: CSSObject = { + height: `${height}px`, + paddingTop: euiTheme.size.s, + }; + + const detailPanel: CSSObject = { + height: `${height}px`, + }; + + return { + processTree, + detailPanel, + }; + }, [height, euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts b/x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts new file mode 100644 index 00000000000000..295371fbff96cf --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Process, ProcessFields } from '../../../common/types/process_tree'; +import { DetailPanelProcess, EuiTabProps } from '../../types'; + +const getDetailPanelProcessLeader = (leader: ProcessFields) => ({ + ...leader, + id: leader.entity_id, + entryMetaType: leader.entry_meta?.type || '', + userName: leader.user.name, + entryMetaSourceIp: leader.entry_meta?.source.ip || '', +}); + +export const getDetailPanelProcess = (process: Process) => { + const processData = {} as DetailPanelProcess; + + processData.id = process.id; + processData.start = process.events[0]['@timestamp']; + processData.end = process.events[process.events.length - 1]['@timestamp']; + const args = new Set(); + processData.executable = []; + + process.events.forEach((event) => { + if (!processData.user) { + processData.user = event.user.name; + } + if (!processData.pid) { + processData.pid = event.process.pid; + } + + if (event.process.args.length > 0) { + args.add(event.process.args.join(' ')); + } + if (event.process.executable) { + processData.executable.push([event.process.executable, `(${event.event.action})`]); + } + if (event.process.exit_code) { + processData.exit_code = event.process.exit_code; + } + }); + + processData.args = [...args]; + processData.entryLeader = getDetailPanelProcessLeader(process.events[0].process.entry_leader); + processData.sessionLeader = getDetailPanelProcessLeader(process.events[0].process.session_leader); + processData.groupLeader = getDetailPanelProcessLeader(process.events[0].process.group_leader); + processData.parent = getDetailPanelProcessLeader(process.events[0].process.parent); + + return processData; +}; + +export const getSelectedTabContent = (tabs: EuiTabProps[], selectedTabId: string) => { + const selectedTab = tabs.find((tab) => tab.id === selectedTabId); + + if (selectedTab) { + return selectedTab.content; + } + + return null; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx new file mode 100644 index 00000000000000..f754086fe5fab7 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { sessionViewBasicProcessMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { SessionViewDetailPanel } from './index'; + +describe('SessionView component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When SessionViewDetailPanel is mounted', () => { + it('shows process detail by default', async () => { + renderResult = mockedContext.render( + + ); + expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible(); + }); + + it('can switch tabs to show host details', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Host')?.click(); + expect(renderResult.queryByText('hostname')).toBeVisible(); + expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx new file mode 100644 index 00000000000000..a47ce1d91ac973 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useMemo, useCallback } from 'react'; +import { EuiTabs, EuiTab, EuiNotificationBadge } from '@elastic/eui'; +import { EuiTabProps } from '../../types'; +import { Process } from '../../../common/types/process_tree'; +import { getDetailPanelProcess, getSelectedTabContent } from './helpers'; +import { DetailPanelProcessTab } from '../detail_panel_process_tab'; +import { DetailPanelHostTab } from '../detail_panel_host_tab'; + +interface SessionViewDetailPanelDeps { + selectedProcess: Process; + onProcessSelected?: (process: Process) => void; +} + +/** + * Detail panel in the session view. + */ +export const SessionViewDetailPanel = ({ selectedProcess }: SessionViewDetailPanelDeps) => { + const [selectedTabId, setSelectedTabId] = useState('process'); + const processDetail = useMemo(() => getDetailPanelProcess(selectedProcess), [selectedProcess]); + + const tabs: EuiTabProps[] = useMemo( + () => [ + { + id: 'process', + name: 'Process', + content: , + }, + { + id: 'host', + name: 'Host', + content: , + }, + { + id: 'alerts', + disabled: true, + name: 'Alerts', + append: ( + + 10 + + ), + content: null, + }, + ], + [processDetail, selectedProcess.events] + ); + + const onSelectedTabChanged = useCallback((id: string) => { + setSelectedTabId(id); + }, []); + + const tabContent = useMemo( + () => getSelectedTabContent(tabs, selectedTabId), + [tabs, selectedTabId] + ); + + return ( + <> + + {tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + prepend={tab.prepend} + append={tab.append} + > + {tab.name} + + ))} + + {tabContent} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx new file mode 100644 index 00000000000000..b27260668af076 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { processMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { SessionViewSearchBar } from './index'; +import userEvent from '@testing-library/user-event'; +import { fireEvent } from '@testing-library/dom'; + +describe('SessionViewSearchBar component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + it('handles a typed search query', async () => { + const mockSetSearchQuery = jest.fn((query) => query); + const mockOnProcessSelected = jest.fn((process) => process); + + renderResult = mockedContext.render( + + ); + + const searchInput = renderResult.getByTestId('sessionView:searchInput').querySelector('input'); + + expect(searchInput?.value).toEqual('ls'); + + if (searchInput) { + userEvent.type(searchInput, ' -la'); + fireEvent.keyUp(searchInput, { key: 'Enter', code: 'Enter' }); + } + + expect(searchInput?.value).toEqual('ls -la'); + expect(mockSetSearchQuery.mock.calls.length).toBe(1); + expect(mockSetSearchQuery.mock.results[0].value).toBe('ls -la'); + }); + + it('shows a results navigator when searchResults provided', async () => { + const processMock2 = { ...processMock }; + const processMock3 = { ...processMock }; + const mockResults = [processMock, processMock2, processMock3]; + const mockSetSearchQuery = jest.fn((query) => query); + const mockOnProcessSelected = jest.fn((process) => process); + + renderResult = mockedContext.render( + + ); + + const searchPagination = renderResult.getByTestId('sessionView:searchPagination'); + expect(searchPagination).toBeTruthy(); + + const paginationTextClass = '.euiPagination__compressedText'; + expect(searchPagination.querySelector(paginationTextClass)?.textContent).toEqual('1 of 3'); + + userEvent.click(renderResult.getByTestId('pagination-button-next')); + expect(searchPagination.querySelector(paginationTextClass)?.textContent).toEqual('2 of 3'); + + const searchInput = renderResult.getByTestId('sessionView:searchInput').querySelector('input'); + + if (searchInput) { + userEvent.type(searchInput, ' -la'); + fireEvent.keyUp(searchInput, { key: 'Enter', code: 'Enter' }); + } + + // after search is changed, results index should reset to 1 + expect(searchPagination.querySelector(paginationTextClass)?.textContent).toEqual('1 of 3'); + + // setSelectedProcess should be called 3 times: + // 1. searchResults is set so auto select first item + // 2. next button hit, so call with 2nd item + // 3. search changed, so call with first result. + expect(mockOnProcessSelected.mock.calls.length).toBe(3); + expect(mockOnProcessSelected.mock.results[0].value).toEqual(processMock); + expect(mockOnProcessSelected.mock.results[1].value).toEqual(processMock2); + expect(mockOnProcessSelected.mock.results[1].value).toEqual(processMock); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx new file mode 100644 index 00000000000000..f4e4dac7a94c7e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useEffect } from 'react'; +import { EuiSearchBar, EuiPagination } from '@elastic/eui'; +import { EuiSearchBarOnChangeArgs } from '@elastic/eui'; +import { Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; + +interface SessionViewSearchBarDeps { + searchQuery: string; + setSearchQuery(val: string): void; + searchResults: Process[] | null; + onProcessSelected(process: Process): void; +} + +/** + * The main wrapper component for the session view. + */ +export const SessionViewSearchBar = ({ + searchQuery, + setSearchQuery, + onProcessSelected, + searchResults, +}: SessionViewSearchBarDeps) => { + const styles = useStyles(); + + const [selectedResult, setSelectedResult] = useState(0); + + const onSearch = ({ query }: EuiSearchBarOnChangeArgs) => { + setSelectedResult(0); + + if (query) { + setSearchQuery(query.text); + } else { + setSearchQuery(''); + } + }; + + useEffect(() => { + if (searchResults) { + const process = searchResults[selectedResult]; + + if (process) { + onProcessSelected(process); + } + } + }, [searchResults, onProcessSelected, selectedResult]); + + const showPagination = !!searchResults?.length; + + return ( +
+ + {showPagination && ( + + )} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts b/x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts new file mode 100644 index 00000000000000..97a49ca2aa8c1d --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const pagination: CSSObject = { + position: 'absolute', + top: euiTheme.size.s, + right: euiTheme.size.xxl, + }; + + return { + pagination, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/hooks/use_scroll.ts b/x-pack/plugins/session_view/public/hooks/use_scroll.ts new file mode 100644 index 00000000000000..716e35dbb09870 --- /dev/null +++ b/x-pack/plugins/session_view/public/hooks/use_scroll.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect } from 'react'; +import _ from 'lodash'; + +const SCROLL_END_BUFFER_HEIGHT = 20; +const DEBOUNCE_TIMEOUT = 500; + +function getScrollPosition(div: HTMLElement) { + if (div) { + return div.scrollTop; + } else { + return document.documentElement.scrollTop || document.body.scrollTop; + } +} + +interface IUseScrollDeps { + div: HTMLElement | null; + handler(pos: number, endReached: boolean): void; +} + +/** + * listens to scroll events on given div, if scroll reaches bottom, calls a callback + * @param {ref} ref to listen to scroll events on + * @param {function} handler function receives params (scrollTop, endReached) + */ +export function useScroll({ div, handler }: IUseScrollDeps) { + useEffect(() => { + if (div) { + const debounced = _.debounce(() => { + const pos = getScrollPosition(div); + const endReached = pos + div.offsetHeight > div.scrollHeight - SCROLL_END_BUFFER_HEIGHT; + + handler(pos, endReached); + }, DEBOUNCE_TIMEOUT); + + div.onscroll = debounced; + + return () => { + debounced.cancel(); + + div.onscroll = null; + }; + } + }, [div, handler]); +} diff --git a/x-pack/plugins/session_view/public/index.ts b/x-pack/plugins/session_view/public/index.ts new file mode 100644 index 00000000000000..90043e9a691dce --- /dev/null +++ b/x-pack/plugins/session_view/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SessionViewPlugin } from './plugin'; + +export function plugin() { + return new SessionViewPlugin(); +} diff --git a/x-pack/plugins/session_view/public/methods/index.tsx b/x-pack/plugins/session_view/public/methods/index.tsx new file mode 100644 index 00000000000000..560bb302ebabfb --- /dev/null +++ b/x-pack/plugins/session_view/public/methods/index.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +// Initializing react-query +const queryClient = new QueryClient(); + +const SessionViewLazy = lazy(() => import('../components/session_view')); + +export const getSessionViewLazy = (sessionEntityId: string) => { + return ( + + }> + + + + ); +}; diff --git a/x-pack/plugins/session_view/public/plugin.ts b/x-pack/plugins/session_view/public/plugin.ts new file mode 100644 index 00000000000000..d25c95b00b2c63 --- /dev/null +++ b/x-pack/plugins/session_view/public/plugin.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../../src/core/public'; +import { SessionViewServices } from './types'; +import { getSessionViewLazy } from './methods'; + +export class SessionViewPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) { + return { + getSessionView: (sessionEntityId: string) => getSessionViewLazy(sessionEntityId), + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/session_view/public/shared_imports.ts b/x-pack/plugins/session_view/public/shared_imports.ts new file mode 100644 index 00000000000000..0a087e1ac36ae3 --- /dev/null +++ b/x-pack/plugins/session_view/public/shared_imports.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SectionLoading } from '../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/session_view/public/test/index.tsx b/x-pack/plugins/session_view/public/test/index.tsx new file mode 100644 index 00000000000000..8570e142538de8 --- /dev/null +++ b/x-pack/plugins/session_view/public/test/index.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, ReactNode, useMemo } from 'react'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react'; +import { QueryClient, QueryClientProvider, setLogger } from 'react-query'; +import { Router } from 'react-router-dom'; +import { History } from 'history'; +import useObservable from 'react-use/lib/useObservable'; +import { I18nProvider } from '@kbn/i18n-react'; +import { CoreStart } from 'src/core/public'; +import { coreMock } from 'src/core/public/mocks'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; +import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; + +type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; + +// hide react-query output in console +setLogger({ + error: () => {}, + // eslint-disable-next-line no-console + log: console.log, + // eslint-disable-next-line no-console + warn: console.warn, +}); + +/** + * Mocked app root context renderer + */ +export interface AppContextTestRender { + history: ReturnType; + coreStart: ReturnType; + /** + * A wrapper around `AppRootContext` component. Uses the mocked modules as input to the + * `AppRootContext` + */ + AppWrapper: React.FC; + /** + * Renders the given UI within the created `AppWrapper` providing the given UI a mocked + * endpoint runtime context environment + */ + render: UiRender; +} + +const createCoreStartMock = ( + history: MemoryHistory +): ReturnType => { + const coreStart = coreMock.createStart({ basePath: '/mock' }); + + // Mock the certain APP Ids returned by `application.getUrlForApp()` + coreStart.application.getUrlForApp.mockImplementation((appId) => { + switch (appId) { + case 'sessionView': + return '/app/sessionView'; + default: + return `${appId} not mocked!`; + } + }); + + coreStart.application.navigateToUrl.mockImplementation((url) => { + history.push(url.replace('/app/sessionView', '')); + return Promise.resolve(); + }); + + return coreStart; +}; + +const AppRootProvider = memo<{ + history: History; + coreStart: CoreStart; + children: ReactNode | ReactNode[]; +}>(({ history, coreStart: { http, notifications, uiSettings, application }, children }) => { + const isDarkMode = useObservable(uiSettings.get$('theme:darkMode')); + const services = useMemo( + () => ({ http, notifications, application }), + [application, http, notifications] + ); + return ( + + + + {children} + + + + ); +}); + +AppRootProvider.displayName = 'AppRootProvider'; + +/** + * Creates a mocked app context custom renderer that can be used to render + * component that depend upon the application's surrounding context providers. + * Factory also returns the content that was used to create the custom renderer, allowing + * for further customization. + */ + +export const createAppRootMockRenderer = (): AppContextTestRender => { + const history = createMemoryHistory(); + const coreStart = createCoreStartMock(history); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // turns retries off + retry: false, + // prevent jest did not exit errors + cacheTime: Infinity, + }, + }, + }); + + const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( + + {children} + + ); + + const render: UiRender = (ui, options = {}) => { + return reactRender(ui, { + wrapper: AppWrapper as React.ComponentType, + ...options, + }); + }; + + return { + history, + coreStart, + AppWrapper, + render, + }; +}; diff --git a/x-pack/plugins/session_view/public/types.ts b/x-pack/plugins/session_view/public/types.ts new file mode 100644 index 00000000000000..2349b8423eb363 --- /dev/null +++ b/x-pack/plugins/session_view/public/types.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ReactNode } from 'react'; +import { CoreStart } from '../../../../src/core/public'; +import { TimelinesUIStart } from '../../timelines/public'; + +export type SessionViewServices = CoreStart & { + timelines: TimelinesUIStart; +}; + +export interface EuiTabProps { + id: string; + name: string; + content: ReactNode; + disabled?: boolean; + append?: ReactNode; + prepend?: ReactNode; +} + +export interface DetailPanelProcess { + id: string; + start: string; + end: string; + exit_code: number; + user: string; + args: string[]; + executable: string[][]; + pid: number; + entryLeader: DetailPanelProcessLeader; + sessionLeader: DetailPanelProcessLeader; + groupLeader: DetailPanelProcessLeader; + parent: DetailPanelProcessLeader; +} + +export interface DetailPanelProcessLeader { + id: string; + name: string; + start: string; + entryMetaType: string; + userName: string; + interactive: boolean; + pid: number; + entryMetaSourceIp: string; + executable: string; +} diff --git a/x-pack/plugins/session_view/public/utils/data_or_dash.test.ts b/x-pack/plugins/session_view/public/utils/data_or_dash.test.ts new file mode 100644 index 00000000000000..12ef44cf1d7083 --- /dev/null +++ b/x-pack/plugins/session_view/public/utils/data_or_dash.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { dataOrDash } from './data_or_dash'; + +const TEST_STRING = '123'; +const TEST_NUMBER = 123; +const DASH = '-'; + +describe('dataOrDash(data)', () => { + it('works for a valid string', () => { + expect(dataOrDash(TEST_STRING)).toEqual(TEST_STRING); + }); + it('works for a valid number', () => { + expect(dataOrDash(TEST_NUMBER)).toEqual(TEST_NUMBER); + }); + it('returns dash for undefined', () => { + expect(dataOrDash(undefined)).toEqual(DASH); + }); + it('returns dash for empty string', () => { + expect(dataOrDash('')).toEqual(DASH); + }); + it('returns dash for NaN', () => { + expect(dataOrDash(NaN)).toEqual(DASH); + }); +}); diff --git a/x-pack/plugins/session_view/public/utils/data_or_dash.ts b/x-pack/plugins/session_view/public/utils/data_or_dash.ts new file mode 100644 index 00000000000000..ff6c2fb9bc1ffb --- /dev/null +++ b/x-pack/plugins/session_view/public/utils/data_or_dash.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Returns a dash ('-') if data is undefined, and empty string, or a NaN. + * + * Used by frontend components + * + * @param {String | Number | undefined} data + * @return {String | Number} either data itself or if invalid, a dash ('-') + */ +export const dataOrDash = (data: string | number | undefined): string | number => { + if (data === undefined || data === '' || (typeof data === 'number' && isNaN(data))) { + return '-'; + } + + return data; +}; diff --git a/x-pack/plugins/session_view/server/index.ts b/x-pack/plugins/session_view/server/index.ts new file mode 100644 index 00000000000000..a86684094dfd70 --- /dev/null +++ b/x-pack/plugins/session_view/server/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { SessionViewPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new SessionViewPlugin(initializerContext); +} diff --git a/x-pack/plugins/session_view/server/plugin.ts b/x-pack/plugins/session_view/server/plugin.ts new file mode 100644 index 00000000000000..c7fd511b3de050 --- /dev/null +++ b/x-pack/plugins/session_view/server/plugin.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CoreSetup, + CoreStart, + Plugin, + Logger, + PluginInitializerContext, +} from '../../../../src/core/server'; +import { SessionViewSetupPlugins, SessionViewStartPlugins } from './types'; +import { registerRoutes } from './routes'; + +export class SessionViewPlugin implements Plugin { + private logger: Logger; + + /** + * Initialize SessionViewPlugin class properties (logger, etc) that is accessible + * through the initializerContext. + */ + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup, plugins: SessionViewSetupPlugins) { + this.logger.debug('session view: Setup'); + const router = core.http.createRouter(); + + // Register server routes + registerRoutes(router); + } + + public start(core: CoreStart, plugins: SessionViewStartPlugins) { + this.logger.debug('session view: Start'); + } + + public stop() { + this.logger.debug('session view: Stop'); + } +} diff --git a/x-pack/plugins/session_view/server/routes/index.ts b/x-pack/plugins/session_view/server/routes/index.ts new file mode 100644 index 00000000000000..7b9cfb45f580b7 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { IRouter } from '../../../../../src/core/server'; +import { registerProcessEventsRoute } from './process_events_route'; +import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; + +export const registerRoutes = (router: IRouter) => { + registerProcessEventsRoute(router); + sessionEntryLeadersRoute(router); +}; diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.test.ts b/x-pack/plugins/session_view/server/routes/process_events_route.test.ts new file mode 100644 index 00000000000000..76f54eb4b8ab65 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/process_events_route.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { doSearch } from './process_events_route'; +import { mockEvents } from '../../common/mocks/constants/session_view_process.mock'; + +const getEmptyResponse = async () => { + return { + hits: { + total: { value: 0, relation: 'eq' }, + hits: [], + }, + }; +}; + +const getResponse = async () => { + return { + hits: { + total: { value: mockEvents.length, relation: 'eq' }, + hits: mockEvents.map((event) => { + return { _source: event }; + }), + }, + }; +}; + +describe('process_events_route.ts', () => { + describe('doSearch(client, entityId, cursor, forward)', () => { + it('should return an empty events array for a non existant entity_id', async () => { + const client = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); + + const body = await doSearch(client, 'asdf', undefined); + + expect(body.events.length).toBe(0); + }); + + it('returns results for a particular session entity_id', async () => { + const client = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + + const body = await doSearch(client, 'mockId', undefined); + + expect(body.events.length).toBe(mockEvents.length); + }); + + it('returns hits in reverse order when paginating backwards', async () => { + const client = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + + const body = await doSearch(client, 'mockId', undefined, false); + + expect(body.events[0]._source).toEqual(mockEvents[mockEvents.length - 1]); + }); + }); +}); diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts new file mode 100644 index 00000000000000..47e2d917733d5b --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import type { ElasticsearchClient } from 'kibana/server'; +import { IRouter } from '../../../../../src/core/server'; +import { + PROCESS_EVENTS_ROUTE, + PROCESS_EVENTS_PER_PAGE, + PROCESS_EVENTS_INDEX, + ALERTS_INDEX, + ENTRY_SESSION_ENTITY_ID_PROPERTY, +} from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; + +export const registerProcessEventsRoute = (router: IRouter) => { + router.get( + { + path: PROCESS_EVENTS_ROUTE, + validate: { + query: schema.object({ + sessionEntityId: schema.string(), + cursor: schema.maybe(schema.string()), + forward: schema.maybe(schema.boolean()), + }), + }, + }, + async (context, request, response) => { + const client = context.core.elasticsearch.client.asCurrentUser; + const { sessionEntityId, cursor, forward = true } = request.query; + const body = await doSearch(client, sessionEntityId, cursor, forward); + + return response.ok({ body }); + } + ); +}; + +export const doSearch = async ( + client: ElasticsearchClient, + sessionEntityId: string, + cursor: string | undefined, + forward = true +) => { + const search = await client.search({ + // TODO: move alerts into it's own route with it's own pagination. + index: [PROCESS_EVENTS_INDEX, ALERTS_INDEX], + ignore_unavailable: true, + body: { + query: { + match: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, + }, + }, + // This runtime_mappings is a temporary fix, so we are able to Query these ECS fields while they are not available + // TODO: Remove the runtime_mappings once process.entry_leader.entity_id is implemented to ECS + runtime_mappings: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: { + type: 'keyword', + }, + }, + size: PROCESS_EVENTS_PER_PAGE, + sort: [{ '@timestamp': forward ? 'asc' : 'desc' }], + search_after: cursor ? [cursor] : undefined, + }, + }); + + const events = search.hits.hits.map((hit: any) => { + // TODO: re-eval if this is needed after moving alerts to it's own route. + // the .siem-signals-default index flattens many properties. this util unflattens them. + hit._source = expandDottedObject(hit._source); + + return hit; + }); + + if (!forward) { + events.reverse(); + } + + return { + events, + }; +}; diff --git a/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts b/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts new file mode 100644 index 00000000000000..98aee357fb91e0 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../../../../src/core/server'; +import { SESSION_ENTRY_LEADERS_ROUTE, PROCESS_EVENTS_INDEX } from '../../common/constants'; + +export const sessionEntryLeadersRoute = (router: IRouter) => { + router.get( + { + path: SESSION_ENTRY_LEADERS_ROUTE, + validate: { + query: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = context.core.elasticsearch.client.asCurrentUser; + const { id } = request.query; + + const result = await client.get({ + index: PROCESS_EVENTS_INDEX, + id, + }); + + return response.ok({ + body: { + session_entry_leader: result?._source, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/session_view/server/types.ts b/x-pack/plugins/session_view/server/types.ts new file mode 100644 index 00000000000000..0d1375081ca870 --- /dev/null +++ b/x-pack/plugins/session_view/server/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SessionViewSetupPlugins {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SessionViewStartPlugins {} diff --git a/x-pack/plugins/session_view/tsconfig.json b/x-pack/plugins/session_view/tsconfig.json new file mode 100644 index 00000000000000..a99e83976a31d4 --- /dev/null +++ b/x-pack/plugins/session_view/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + // add all the folders containg files to be compiled + "common/**/*", + "public/**/*", + "server/**/*", + "server/**/*.json", + "scripts/**/*", + "package.json", + "storybook/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + // add references to other TypeScript projects the plugin depends on + + // requiredPlugins from ./kibana.json + { "path": "../licensing/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../encrypted_saved_objects/tsconfig.json" }, + + // optionalPlugins from ./kibana.json + { "path": "../security/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + + // requiredBundles from ./kibana.json + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + ] +} diff --git a/yarn.lock b/yarn.lock index 753a9e5d9805c2..6867f4edc81910 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24737,10 +24737,10 @@ react-popper@^2.2.4: react-fast-compare "^3.0.1" warning "^4.0.2" -react-query@^3.34.0: - version "3.34.8" - resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.34.8.tgz#a3be8523fd95f766b04c32847a1b58d8231db03c" - integrity sha512-pl9e2VmVbgKf29Qn/WpmFVtB2g17JPqLLyOQg3GfSs/S2WABvip5xlT464vfXtilLPcJVg9bEHHlqmC38/nvDw== +react-query@^3.34.7: + version "3.34.7" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.34.7.tgz#e3d71318f510ea354794cd188b351bb57f577cb9" + integrity sha512-Q8+H2DgpoZdGUpwW2Z9WAbSrIE+yOdZiCUokHjlniOOmlcsfqNLgvHF5i7rtuCmlw3hv5OAhtpS7e97/DvgpWw== dependencies: "@babel/runtime" "^7.5.5" broadcast-channel "^3.4.1" From b51ae8bbddb8f8cc2bc83ca87918036c690b7659 Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Fri, 4 Mar 2022 12:01:59 -0500 Subject: [PATCH 03/20] Empty fleet_package.json on main (#126920) --- fleet_packages.json | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/fleet_packages.json b/fleet_packages.json index 69fd83f12037ca..3657057ad3431f 100644 --- a/fleet_packages.json +++ b/fleet_packages.json @@ -12,25 +12,4 @@ in order to verify package integrity. */ -[ - { - "name": "apm", - "version": "8.1.0" - }, - { - "name": "elastic_agent", - "version": "1.3.0" - }, - { - "name": "endpoint", - "version": "1.5.0" - }, - { - "name": "fleet_server", - "version": "1.1.0" - }, - { - "name": "synthetics", - "version": "0.9.2" - } -] +[] From f7f8d6da9a7188a767f92c19931da5c2f950305b Mon Sep 17 00:00:00 2001 From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Date: Fri, 4 Mar 2022 09:04:36 -0800 Subject: [PATCH 04/20] [ResponseOps] Mapped/searchable params (#126531) * Mapped params implementation with unit/integration/migration tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/alerting/common/alert.ts | 8 + .../lib/mapped_params_utils.test.ts | 131 +++++++++++++ .../rules_client/lib/mapped_params_utils.ts | 172 ++++++++++++++++++ .../lib/validate_attributes.test.ts | 26 ++- .../rules_client/lib/validate_attributes.ts | 77 ++++++-- .../server/rules_client/rules_client.ts | 46 ++++- .../server/rules_client/tests/create.test.ts | 161 ++++++++++++++++ .../server/rules_client/tests/find.test.ts | 31 ++++ .../server/rules_client/tests/update.test.ts | 8 + .../server/saved_objects/mappings.json | 10 + .../server/saved_objects/migrations.test.ts | 24 +++ .../server/saved_objects/migrations.ts | 30 +++ x-pack/plugins/alerting/server/types.ts | 2 + .../rules/all/use_columns.tsx | 4 +- .../spaces_only/tests/alerting/create.ts | 48 +++++ .../spaces_only/tests/alerting/find.ts | 74 ++++++++ .../spaces_only/tests/alerting/migrations.ts | 16 ++ .../spaces_only/tests/alerting/update.ts | 16 +- .../functional/es_archives/alerts/data.json | 42 +++++ 19 files changed, 907 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts create mode 100644 x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index 35058aa343b1a1..da916ee7ed98aa 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -63,6 +63,13 @@ export interface AlertAggregations { ruleMutedStatus: { muted: number; unmuted: number }; } +export interface MappedParamsProperties { + risk_score?: number; + severity?: string; +} + +export type MappedParams = SavedObjectAttributes & MappedParamsProperties; + export interface Alert { id: string; enabled: boolean; @@ -73,6 +80,7 @@ export interface Alert { schedule: IntervalSchedule; actions: AlertAction[]; params: Params; + mapped_params?: MappedParams; scheduledTaskId?: string; createdBy: string | null; updatedBy: string | null; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts new file mode 100644 index 00000000000000..d8618d0ed6c210 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fromKueryExpression } from '@kbn/es-query'; +import { + getMappedParams, + getModifiedFilter, + getModifiedField, + getModifiedSearchFields, + getModifiedSearch, + getModifiedValue, + modifyFilterKueryNode, +} from './mapped_params_utils'; + +describe('getModifiedParams', () => { + it('converts params to mapped params', () => { + const params = { + riskScore: 42, + severity: 'medium', + a: 'test', + b: 'test', + c: 'test,', + }; + + expect(getMappedParams(params)).toEqual({ + risk_score: 42, + severity: '40-medium', + }); + }); + + it('returns empty mapped params if nothing exists in the input params', () => { + const params = { + a: 'test', + b: 'test', + c: 'test', + }; + + expect(getMappedParams(params)).toEqual({}); + }); +}); + +describe('getModifiedFilter', () => { + it('converts params filters to mapped params filters', () => { + // Make sure it works for both camel and snake case params + const filter = 'alert.attributes.params.risk_score: 45'; + + expect(getModifiedFilter(filter)).toEqual('alert.attributes.mapped_params.risk_score: 45'); + }); +}); + +describe('getModifiedField', () => { + it('converts sort field to mapped params sort field', () => { + expect(getModifiedField('params.risk_score')).toEqual('mapped_params.risk_score'); + expect(getModifiedField('params.riskScore')).toEqual('mapped_params.risk_score'); + expect(getModifiedField('params.invalid')).toEqual('params.invalid'); + }); +}); + +describe('getModifiedSearchFields', () => { + it('converts a list of params search fields to mapped param search fields', () => { + const searchFields = [ + 'params.risk_score', + 'params.riskScore', + 'params.severity', + 'params.invalid', + 'invalid', + ]; + + expect(getModifiedSearchFields(searchFields)).toEqual([ + 'mapped_params.risk_score', + 'mapped_params.risk_score', + 'mapped_params.severity', + 'params.invalid', + 'invalid', + ]); + }); +}); + +describe('getModifiedSearch', () => { + it('converts the search value depending on the search field', () => { + const searchFields = ['params.severity', 'another']; + + expect(getModifiedSearch(searchFields, 'medium')).toEqual('40-medium'); + expect(getModifiedSearch(searchFields, 'something else')).toEqual('something else'); + expect(getModifiedSearch('params.risk_score', 'something else')).toEqual('something else'); + expect(getModifiedSearch('mapped_params.severity', 'medium')).toEqual('40-medium'); + }); +}); + +describe('getModifiedValue', () => { + it('converts severity strings to sortable strings', () => { + expect(getModifiedValue('severity', 'low')).toEqual('20-low'); + expect(getModifiedValue('severity', 'medium')).toEqual('40-medium'); + expect(getModifiedValue('severity', 'high')).toEqual('60-high'); + expect(getModifiedValue('severity', 'critical')).toEqual('80-critical'); + }); +}); + +describe('modifyFilterKueryNode', () => { + it('modifies the resulting kuery node AST filter for alert params', () => { + const astFilter = fromKueryExpression( + 'alert.attributes.name: "Rule I" and alert.attributes.tags: "fast" and alert.attributes.params.severity > medium' + ); + + expect(astFilter.arguments[2].arguments[0]).toEqual({ + type: 'literal', + value: 'alert.attributes.params.severity', + }); + + expect(astFilter.arguments[2].arguments[2]).toEqual({ + type: 'literal', + value: 'medium', + }); + + modifyFilterKueryNode({ astFilter }); + + expect(astFilter.arguments[2].arguments[0]).toEqual({ + type: 'literal', + value: 'alert.attributes.mapped_params.severity', + }); + + expect(astFilter.arguments[2].arguments[2]).toEqual({ + type: 'literal', + value: '40-medium', + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts new file mode 100644 index 00000000000000..b4d82990654c25 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { snakeCase } from 'lodash'; +import { AlertTypeParams, MappedParams, MappedParamsProperties } from '../../types'; +import { SavedObjectAttribute } from '../../../../../../src/core/server'; +import { + iterateFilterKureyNode, + IterateFilterKureyNodeParams, + IterateActionProps, + getFieldNameAttribute, +} from './validate_attributes'; + +export const MAPPED_PARAMS_PROPERTIES: Array = [ + 'risk_score', + 'severity', +]; + +const SEVERITY_MAP: Record = { + low: '20-low', + medium: '40-medium', + high: '60-high', + critical: '80-critical', +}; + +/** + * Returns the mapped_params object when given a params object. + * The function will match params present in MAPPED_PARAMS_PROPERTIES and + * return an empty object if nothing is matched. + */ +export const getMappedParams = (params: AlertTypeParams) => { + return Object.entries(params).reduce((result, [key, value]) => { + const snakeCaseKey = snakeCase(key); + + if (MAPPED_PARAMS_PROPERTIES.includes(snakeCaseKey as keyof MappedParamsProperties)) { + result[snakeCaseKey] = getModifiedValue( + snakeCaseKey, + value as string + ) as SavedObjectAttribute; + } + + return result; + }, {}); +}; + +/** + * Returns a string of the filter, but with params replaced with mapped_params. + * This function will check both camel and snake case to make sure we're consistent + * with the naming + * + * i.e.: 'alerts.attributes.params.riskScore' -> 'alerts.attributes.mapped_params.risk_score' + */ +export const getModifiedFilter = (filter: string) => { + return filter.replace('.params.', '.mapped_params.'); +}; + +/** + * Returns modified field with mapped_params instead of params. + * + * i.e.: 'params.riskScore' -> 'mapped_params.risk_score' + */ +export const getModifiedField = (field: string | undefined) => { + if (!field) { + return field; + } + + const sortFieldToReplace = `${snakeCase(field.replace('params.', ''))}`; + + if (MAPPED_PARAMS_PROPERTIES.includes(sortFieldToReplace as keyof MappedParamsProperties)) { + return `mapped_params.${sortFieldToReplace}`; + } + + return field; +}; + +/** + * Returns modified search fields with mapped_params instead of params. + * + * i.e.: + * [ + * 'params.riskScore', + * 'params.severity', + * ] + * -> + * [ + * 'mapped_params.riskScore', + * 'mapped_params.severity', + * ] + */ +export const getModifiedSearchFields = (searchFields: string[] | undefined) => { + if (!searchFields) { + return searchFields; + } + + return searchFields.reduce((result, field) => { + const modifiedField = getModifiedField(field); + if (modifiedField) { + return [...result, modifiedField]; + } + return result; + }, []); +}; + +export const getModifiedValue = (key: string, value: string) => { + if (key === 'severity') { + return SEVERITY_MAP[value] || ''; + } + return value; +}; + +export const getModifiedSearch = (searchFields: string | string[] | undefined, value: string) => { + if (!searchFields) { + return value; + } + + const fieldNames = Array.isArray(searchFields) ? searchFields : [searchFields]; + + const modifiedSearchValues = fieldNames.map((fieldName) => { + const firstAttribute = getFieldNameAttribute(fieldName, [ + 'alert', + 'attributes', + 'params', + 'mapped_params', + ]); + return getModifiedValue(firstAttribute, value); + }); + + return modifiedSearchValues.find((search) => search !== value) || value; +}; + +export const modifyFilterKueryNode = ({ + astFilter, + hasNestedKey = false, + nestedKeys, + storeValue, + path = 'arguments', +}: IterateFilterKureyNodeParams) => { + const action = ({ index, ast, fieldName, localFieldName }: IterateActionProps) => { + // First index, assuming ast value is the attribute name + if (index === 0) { + const firstAttribute = getFieldNameAttribute(fieldName, ['alert', 'attributes']); + // Replace the ast.value for params to mapped_params + if (firstAttribute === 'params') { + ast.value = getModifiedFilter(ast.value); + } + } + + // Subsequent indices, assuming ast value is the filtering value + else { + const firstAttribute = getFieldNameAttribute(localFieldName, ['alert', 'attributes']); + + // Replace the ast.value for params value to the modified mapped_params value + if (firstAttribute === 'params' && ast.value) { + const attribute = getFieldNameAttribute(localFieldName, ['alert', 'attributes', 'params']); + ast.value = getModifiedValue(attribute, ast.value); + } + } + }; + + iterateFilterKureyNode({ + astFilter, + hasNestedKey, + nestedKeys, + storeValue, + path, + action, + }); +}; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts index 652c30ff380c55..1777a36d80a2f1 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts @@ -13,7 +13,7 @@ import { } from './validate_attributes'; describe('Validate attributes', () => { - const excludedFieldNames = ['monitoring']; + const excludedFieldNames = ['monitoring', 'mapped_params']; describe('validateSortField', () => { test('should NOT throw an error, when sort field is not part of the field to exclude', () => { expect(() => validateSortField('name.keyword', excludedFieldNames)).not.toThrow(); @@ -86,6 +86,17 @@ describe('Validate attributes', () => { ).not.toThrow(); }); + test('should NOT throw an error, when filter contains params with validate properties', () => { + expect(() => + validateFilterKueryNode({ + astFilter: esKuery.fromKueryExpression( + 'alert.attributes.name: "Rule I" and alert.attributes.tags: "fast" and alert.attributes.params.risk_score > 50' + ), + excludedFieldNames, + }) + ).not.toThrow(); + }); + test('should throw an error, when filter contains the field to exclude', () => { expect(() => validateFilterKueryNode({ @@ -111,5 +122,18 @@ describe('Validate attributes', () => { `"Filter is not supported on this field alert.attributes.actions"` ); }); + + test('should throw an error, when filtering contains a property that is not valid', () => { + expect(() => + validateFilterKueryNode({ + astFilter: esKuery.fromKueryExpression( + 'alert.attributes.name: "Rule I" and alert.attributes.tags: "fast" and alert.attributes.mapped_params.risk_score > 50' + ), + excludedFieldNames, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Filter is not supported on this field alert.attributes.mapped_params.risk_score"` + ); + }); }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts index fa65f4c2f0999b..ad17ede1b99adc 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts @@ -7,11 +7,18 @@ import { KueryNode } from '@kbn/es-query'; import { get, isEmpty } from 'lodash'; - import mappings from '../../saved_objects/mappings.json'; const astFunctionType = ['is', 'range', 'nested']; +export const getFieldNameAttribute = (fieldName: string, attributesToIgnore: string[]) => { + const fieldNameSplit = (fieldName || '') + .split('.') + .filter((fn: string) => !attributesToIgnore.includes(fn)); + + return fieldNameSplit.length > 0 ? fieldNameSplit[0] : ''; +}; + export const validateOperationOnAttributes = ( astFilter: KueryNode | null, sortField: string | undefined, @@ -44,28 +51,41 @@ export const validateSearchFields = (searchFields: string[], excludedFieldNames: } }; -interface ValidateFilterKueryNodeParams { +export interface IterateActionProps { + ast: KueryNode; + index: number; + fieldName: string; + localFieldName: string; +} + +export interface IterateFilterKureyNodeParams { astFilter: KueryNode; - excludedFieldNames: string[]; hasNestedKey?: boolean; nestedKeys?: string; storeValue?: boolean; path?: string; + action?: (props: IterateActionProps) => void; } -export const validateFilterKueryNode = ({ +export interface ValidateFilterKueryNodeParams extends IterateFilterKureyNodeParams { + excludedFieldNames: string[]; +} + +export const iterateFilterKureyNode = ({ astFilter, - excludedFieldNames, hasNestedKey = false, nestedKeys, storeValue, path = 'arguments', -}: ValidateFilterKueryNodeParams) => { + action = () => {}, +}: IterateFilterKureyNodeParams) => { let localStoreValue = storeValue; let localNestedKeys: string | undefined; + let localFieldName: string = ''; if (localStoreValue === undefined) { localStoreValue = astFilter.type === 'function' && astFunctionType.includes(astFilter.function); } + astFilter.arguments.forEach((ast: KueryNode, index: number) => { if (hasNestedKey && ast.type === 'literal' && ast.value != null) { localNestedKeys = ast.value; @@ -80,25 +100,56 @@ export const validateFilterKueryNode = ({ if (ast.arguments) { const myPath = `${path}.${index}`; - validateFilterKueryNode({ + iterateFilterKureyNode({ astFilter: ast, - excludedFieldNames, storeValue: ast.type === 'function' && astFunctionType.includes(ast.function), path: `${myPath}.arguments`, hasNestedKey: ast.type === 'function' && ast.function === 'nested', nestedKeys: localNestedKeys || nestedKeys, + action, }); } - if (localStoreValue && index === 0) { + if (localStoreValue) { const fieldName = nestedKeys != null ? `${nestedKeys}.${ast.value}` : ast.value; - const fieldNameSplit = fieldName - .split('.') - .filter((fn: string) => !['alert', 'attributes'].includes(fn)); - const firstAttribute = fieldNameSplit.length > 0 ? fieldNameSplit[0] : ''; + + if (index === 0) { + localFieldName = fieldName; + } + + action({ + ast, + index, + fieldName, + localFieldName, + }); + } + }); +}; + +export const validateFilterKueryNode = ({ + astFilter, + excludedFieldNames, + hasNestedKey = false, + nestedKeys, + storeValue, + path = 'arguments', +}: ValidateFilterKueryNodeParams) => { + const action = ({ index, fieldName }: IterateActionProps) => { + if (index === 0) { + const firstAttribute = getFieldNameAttribute(fieldName, ['alert', 'attributes']); if (excludedFieldNames.includes(firstAttribute)) { throw new Error(`Filter is not supported on this field ${fieldName}`); } } + }; + + iterateFilterKureyNode({ + astFilter, + hasNestedKey, + nestedKeys, + storeValue, + path, + action, }); }; diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 6d3ffc822a626d..86f0d3becdce77 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -78,6 +78,13 @@ import { Alert } from '../alert'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; import { getDefaultRuleMonitoring } from '../task_runner/task_runner'; +import { + getMappedParams, + getModifiedField, + getModifiedSearchFields, + getModifiedSearch, + modifyFilterKueryNode, +} from './lib/mapped_params_utils'; export interface RegistryAlertTypeWithAuth extends RegistryRuleType { authorizedConsumers: string[]; @@ -251,7 +258,10 @@ export class RulesClient { private readonly kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; private readonly auditLogger?: AuditLogger; private readonly eventLogger?: IEventLogger; - private readonly fieldsToExcludeFromPublicApi: Array = ['monitoring']; + private readonly fieldsToExcludeFromPublicApi: Array = [ + 'monitoring', + 'mapped_params', + ]; constructor({ ruleTypeRegistry, @@ -371,6 +381,12 @@ export class RulesClient { monitoring: getDefaultRuleMonitoring(), }; + const mappedParams = getMappedParams(updatedParams); + + if (Object.keys(mappedParams).length) { + rawRule.mapped_params = mappedParams; + } + this.auditLogger?.log( ruleAuditEvent({ action: RuleAuditAction.CREATE, @@ -634,9 +650,10 @@ export class RulesClient { ); throw error; } + const { filter: authorizationFilter, ensureRuleTypeIsAuthorized } = authorizationTuple; const filterKueryNode = options.filter ? esKuery.fromKueryExpression(options.filter) : null; - const sortField = mapSortField(options.sortField); + let sortField = mapSortField(options.sortField); if (excludeFromPublicApi) { try { validateOperationOnAttributes( @@ -650,6 +667,24 @@ export class RulesClient { } } + sortField = mapSortField(getModifiedField(options.sortField)); + + // Generate new modified search and search fields, translating certain params properties + // to mapped_params. Thus, allowing for sort/search/filtering on params. + // We do the modifcation after the validate check to make sure the public API does not + // use the mapped_params in their queries. + options = { + ...options, + ...(options.searchFields && { searchFields: getModifiedSearchFields(options.searchFields) }), + ...(options.search && { search: getModifiedSearch(options.searchFields, options.search) }), + }; + + // Modifies kuery node AST to translate params filter and the filter value to mapped_params. + // This translation is done in place, and therefore is not a pure function. + if (filterKueryNode) { + modifyFilterKueryNode({ astFilter: filterKueryNode }); + } + const { page, per_page: perPage, @@ -1027,6 +1062,13 @@ export class RulesClient { updatedBy: username, updatedAt: new Date().toISOString(), }); + + const mappedParams = getMappedParams(updatedParams); + + if (Object.keys(mappedParams).length) { + createAttributes.mapped_params = mappedParams; + } + try { updatedObject = await this.unsecuredSavedObjectsClient.create( 'alert', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index 6ccc640dcc1351..8cecb47f23a886 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -1878,6 +1878,167 @@ describe('create()', () => { `); }); + test('should create alerts with mapped_params', async () => { + const data = getMockData({ + params: { + bar: true, + risk_score: 42, + severity: 'low', + }, + }); + + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + risk_score: 42, + severity: 'low', + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '123', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + + const result = await rulesClient.create({ data }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + enabled: true, + name: 'abc', + tags: ['foo'], + alertTypeId: '123', + consumer: 'bar', + schedule: { + interval: '1m', + }, + throttle: null, + notifyWhen: 'onActiveAlert', + params: { + bar: true, + risk_score: 42, + severity: 'low', + }, + actions: [ + { + group: 'default', + params: { + foo: true, + }, + actionRef: 'action_0', + actionTypeId: 'test', + }, + ], + apiKeyOwner: null, + apiKey: null, + legacyId: null, + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastExecutionDate: '2019-02-12T21:01:22.479Z', + error: null, + }, + monitoring: { + execution: { + history: [], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + mapped_params: { + risk_score: 42, + severity: '20-low', + }, + meta: { + versionApiKeyLastmodified: 'v8.0.0', + }, + }, + { + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + id: 'mock-saved-object-id', + } + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "123", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": null, + "params": Object { + "bar": true, + "risk_score": 42, + "severity": "low", + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": null, + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + test('should validate params', async () => { const data = getMockData(); ruleTypeRegistry.get.mockReturnValue({ diff --git a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts index 60aac3f266e785..bd382faa6d6cb0 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts @@ -290,6 +290,37 @@ describe('find()', () => { expect(jest.requireMock('../lib/map_sort_field').mapSortField).toHaveBeenCalledWith('name'); }); + test('should translate filter/sort/search on params to mapped_params', async () => { + const filter = esKuery.fromKueryExpression( + '((alert.attributes.alertTypeId:myType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myOtherApp))' + ); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + filter, + ensureRuleTypeIsAuthorized() {}, + }); + + const rulesClient = new RulesClient(rulesClientParams); + await rulesClient.find({ + options: { + sortField: 'params.risk_score', + searchFields: ['params.risk_score', 'params.severity'], + filter: 'alert.attributes.params.risk_score > 50', + }, + excludeFromPublicApi: true, + }); + + const findCallParams = unsecuredSavedObjectsClient.find.mock.calls[0][0]; + + expect(findCallParams.searchFields).toEqual([ + 'mapped_params.risk_score', + 'mapped_params.severity', + ]); + + expect(findCallParams.filter.arguments[0].arguments[0].value).toEqual( + 'alert.attributes.mapped_params.risk_score' + ); + }); + test('should call useSavedObjectReferences.injectReferences if defined for rule type', async () => { jest.resetAllMocks(); authorization.getFindAuthorizationFilter.mockResolvedValue({ diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 1def4b7d60f4e1..be2f859ac96b3f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -252,6 +252,8 @@ describe('update()', () => { tags: ['foo'], params: { bar: true, + risk_score: 40, + severity: 'low', }, throttle: null, notifyWhen: 'onActiveAlert', @@ -362,6 +364,10 @@ describe('update()', () => { "apiKeyOwner": null, "consumer": "myApp", "enabled": true, + "mapped_params": Object { + "risk_score": 40, + "severity": "20-low", + }, "meta": Object { "versionApiKeyLastmodified": "v7.10.0", }, @@ -369,6 +375,8 @@ describe('update()', () => { "notifyWhen": "onActiveAlert", "params": Object { "bar": true, + "risk_score": 40, + "severity": "low", }, "schedule": Object { "interval": "1m", diff --git a/x-pack/plugins/alerting/server/saved_objects/mappings.json b/x-pack/plugins/alerting/server/saved_objects/mappings.json index 806e72fa33d5d0..e6eedced78914a 100644 --- a/x-pack/plugins/alerting/server/saved_objects/mappings.json +++ b/x-pack/plugins/alerting/server/saved_objects/mappings.json @@ -53,6 +53,16 @@ "type": "flattened", "ignore_above": 4096 }, + "mapped_params": { + "properties": { + "risk_score": { + "type": "float" + }, + "severity": { + "type": "keyword" + } + } + }, "scheduledTaskId": { "type": "keyword" }, diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index 1d7d3d2a362a99..28b1f599f9575a 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -2229,6 +2229,30 @@ describe('successful migrations', () => { ); }); + describe('8.2.0', () => { + test('migrates params to mapped_params', () => { + const migration820 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.2.0']; + const alert = getMockData( + { + params: { + risk_score: 60, + severity: 'high', + foo: 'bar', + }, + alertTypeId: 'siem.signals', + }, + true + ); + + const migratedAlert820 = migration820(alert, migrationContext); + + expect(migratedAlert820.attributes.mapped_params).toEqual({ + risk_score: 60, + severity: '60-high', + }); + }); + }); + describe('Metrics Inventory Threshold rule', () => { test('Migrates incorrect action group spelling', () => { const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 6e6c886d91b531..09d505aec0f0c4 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -21,6 +21,7 @@ import { RawRule, RawAlertAction } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; import type { IsMigrationNeededPredicate } from '../../../encrypted_saved_objects/server'; import { extractRefsFromGeoContainmentAlert } from './geo_containment/migrations'; +import { getMappedParams } from '../../server/rules_client/lib/mapped_params_utils'; const SIEM_APP_ID = 'securitySolution'; const SIEM_SERVER_APP_ID = 'siem'; @@ -145,6 +146,12 @@ export function getMigrations( pipeMigrations(addSecuritySolutionAADRuleTypeTags) ); + const migrationRules820 = createEsoMigration( + encryptedSavedObjects, + (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, + pipeMigrations(addMappedParams) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), @@ -155,6 +162,7 @@ export function getMigrations( '7.16.0': executeMigrationWithErrorHandling(migrateRules716, '7.16.0'), '8.0.0': executeMigrationWithErrorHandling(migrationRules800, '8.0.0'), '8.0.1': executeMigrationWithErrorHandling(migrationRules801, '8.0.1'), + '8.2.0': executeMigrationWithErrorHandling(migrationRules820, '8.2.0'), }; } @@ -822,6 +830,28 @@ function fixInventoryThresholdGroupId( } } +function addMappedParams( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { + attributes: { params }, + } = doc; + + const mappedParams = getMappedParams(params); + + if (Object.keys(mappedParams).length) { + return { + ...doc, + attributes: { + ...doc.attributes, + mapped_params: mappedParams, + }, + }; + } + + return doc; +} + function getCorrespondingAction( actions: SavedObjectAttribute, connectorRef: string diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 8a0b61fed787a4..6b06f7efe30660 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -40,6 +40,7 @@ import { ActionVariable, SanitizedRuleConfig, RuleMonitoring, + MappedParams, } from '../common'; import { LicenseType } from '../../licensing/server'; import { IAbortableClusterClient } from './lib/create_abortable_es_client_factory'; @@ -236,6 +237,7 @@ export interface RawRule extends SavedObjectAttributes { schedule: SavedObjectAttributes; actions: RawAlertAction[]; params: SavedObjectAttributes; + mapped_params?: MappedParams; scheduledTaskId?: string | null; createdBy: string | null; updatedBy: string | null; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx index f241a3df873274..37882030082384 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx @@ -196,7 +196,7 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] {value} ), - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '85px', }, @@ -204,7 +204,7 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] field: 'severity', name: i18n.COLUMN_SEVERITY, render: (value: Rule['severity']) => , - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '12%', }, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts index 7eb7cf5efc7d35..b002e0668dc527 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts @@ -272,6 +272,54 @@ export default function createAlertTests({ getService }: FtrProviderContext) { }); }); + it('should create rules with mapped parameters', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const createResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + params: { + risk_score: 40, + severity: 'medium', + another_param: 'another', + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + + const response = await supertest.get( + `${getUrlPrefix( + Spaces.space1.id + )}/internal/alerting/rules/_find?filter=alert.attributes.params.risk_score:40` + ); + + expect(response.status).to.eql(200); + objectRemover.add(Spaces.space1.id, createResponse.body.id, 'rule', 'alerting'); + expect(response.body.total).to.equal(1); + expect(response.body.data[0].mapped_params).to.eql({ + risk_score: 40, + severity: '40-medium', + }); + }); + it('should allow providing custom saved object ids (uuid v1)', async () => { const customId = '09570bb0-6299-11eb-8fde-9fe5ce6ea450'; const response = await supertest diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts index 94198579d612de..7a4a91bd575bb1 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts @@ -106,6 +106,24 @@ const findTestUtils = ( createAlert(objectRemover, supertest, { params: { strValue: 'my a' } }), createAlert(objectRemover, supertest, { params: { strValue: 'my b' } }), createAlert(objectRemover, supertest, { params: { strValue: 'my c' } }), + createAlert(objectRemover, supertest, { + params: { + risk_score: 60, + severity: 'high', + }, + }), + createAlert(objectRemover, supertest, { + params: { + risk_score: 40, + severity: 'medium', + }, + }), + createAlert(objectRemover, supertest, { + params: { + risk_score: 20, + severity: 'low', + }, + }), ]); }); @@ -171,6 +189,62 @@ const findTestUtils = ( expect(response.body.total).to.equal(1); expect(response.body.data[0].params.strValue).to.eql('my b'); }); + + it('should sort by parameters', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/${ + describeType === 'public' ? 'api' : 'internal' + }/alerting/rules/_find?sort_field=params.severity&sort_order=asc` + ); + expect(response.body.data[0].params.severity).to.equal('low'); + expect(response.body.data[1].params.severity).to.equal('medium'); + expect(response.body.data[2].params.severity).to.equal('high'); + }); + + it('should search by parameters', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/${ + describeType === 'public' ? 'api' : 'internal' + }/alerting/rules/_find?search_fields=params.severity&search=medium` + ); + + expect(response.status).to.eql(200); + expect(response.body.total).to.equal(1); + expect(response.body.data[0].params.severity).to.eql('medium'); + }); + + it('should filter on parameters', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/${ + describeType === 'public' ? 'api' : 'internal' + }/alerting/rules/_find?filter=alert.attributes.params.risk_score:40` + ); + + expect(response.status).to.eql(200); + expect(response.body.total).to.equal(1); + expect(response.body.data[0].params.risk_score).to.eql(40); + + if (describeType === 'public') { + expect(response.body.data[0].mapped_params).to.eql(undefined); + } + }); + + it('should error if filtering on mapped parameters directly using the public API', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/${ + describeType === 'public' ? 'api' : 'internal' + }/alerting/rules/_find?filter=alert.attributes.mapped_params.risk_score:40` + ); + + if (describeType === 'public') { + expect(response.status).to.eql(400); + expect(response.body.message).to.eql( + 'Error find rules: Filter is not supported on this field alert.attributes.mapped_params.risk_score' + ); + } else { + expect(response.status).to.eql(200); + } + }); }); }); }; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts index 5077c8d720c246..9bcce86b57fe60 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts @@ -407,5 +407,21 @@ export default function createGetTests({ getService }: FtrProviderContext) { '__internal_immutable:false', ]); }); + + it('8.2.0 migrates params to mapped_params for specific params properties', async () => { + const response = await es.get<{ alert: RawRule }>( + { + index: '.kibana', + id: 'alert:66560b6f-5ca4-41e2-a1a1-dcfd7117e124', + }, + { meta: true } + ); + + expect(response.statusCode).to.equal(200); + expect(response.body._source?.alert?.mapped_params).to.eql({ + risk_score: 90, + severity: '80-critical', + }); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index 326fb0bfac4656..d97ca18c52d4a8 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -32,13 +32,15 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { tags: ['bar'], params: { foo: true, + risk_score: 40, + severity: 'medium', }, schedule: { interval: '12s' }, actions: [], throttle: '1m', notify_when: 'onThrottleInterval', }; - const response = await supertest + let response = await supertest .put(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${createdAlert.id}`) .set('kbn-xsrf', 'foo') .send(updatedData) @@ -68,6 +70,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { Date.parse(response.body.created_at) ); + response = await supertest.get( + `${getUrlPrefix( + Spaces.space1.id + )}/internal/alerting/rules/_find?filter=alert.attributes.params.risk_score:40` + ); + + expect(response.body.data[0].mapped_params).to.eql({ + risk_score: 40, + severity: '40-medium', + }); + // Ensure AAD isn't broken await checkAAD({ supertest, @@ -126,6 +139,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { throttle: '1m', notifyWhen: 'onThrottleInterval', }; + const response = await supertest .put(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}`) .set('kbn-xsrf', 'foo') diff --git a/x-pack/test/functional/es_archives/alerts/data.json b/x-pack/test/functional/es_archives/alerts/data.json index 96dad21732d0df..39ce6248c7ebbf 100644 --- a/x-pack/test/functional/es_archives/alerts/data.json +++ b/x-pack/test/functional/es_archives/alerts/data.json @@ -854,3 +854,45 @@ } } } + +{ + "type": "doc", + "value": { + "id": "alert:66560b6f-5ca4-41e2-a1a1-dcfd7117e124", + "index": ".kibana_1", + "source": { + "alert" : { + "name" : "Test mapped params migration", + "alertTypeId" : "siem.signals", + "consumer" : "alertsFixture", + "params" : { + "type": "eql", + "risk_score": 90, + "severity": "critical" + }, + "schedule" : { + "interval" : "1m" + }, + "enabled" : true, + "actions" : [ ], + "throttle" : null, + "apiKeyOwner" : null, + "apiKey" : null, + "createdBy" : "elastic", + "updatedBy" : "elastic", + "createdAt" : "2021-07-27T20:42:55.896Z", + "muteAll" : false, + "mutedInstanceIds" : [ ], + "scheduledTaskId" : null, + "tags": [] + }, + "type" : "alert", + "migrationVersion" : { + "alert" : "7.8.0" + }, + "updated_at" : "2021-08-13T23:00:11.985Z", + "references": [ + ] + } + } +} \ No newline at end of file From 646c15c1de96931108ce3d36cb64ccec61c1c40a Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Fri, 4 Mar 2022 10:01:56 -0800 Subject: [PATCH 05/20] [Github] Remove Security & Ops project assigner (#126939) --- .github/workflows/project-assigner.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 65808dffd801f5..8c381dd1ecdefa 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -18,8 +18,6 @@ jobs: {"label": "Feature:Canvas", "projectNumber": 38, "columnName": "Inbox"}, {"label": "Feature:Dashboard", "projectNumber": 68, "columnName": "Inbox"}, {"label": "Feature:Drilldowns", "projectNumber": 68, "columnName": "Inbox"}, - {"label": "Feature:Input Controls", "projectNumber": 72, "columnName": "Inbox"}, - {"label": "Team:Security", "projectNumber": 320, "columnName": "Awaiting triage", "projectScope": "org"}, - {"label": "Team:Operations", "projectNumber": 314, "columnName": "Triage", "projectScope": "org"} + {"label": "Feature:Input Controls", "projectNumber": 72, "columnName": "Inbox"} ] ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} From 377e2b4c3db1d90d7067ce2c6ab161c9554c90b7 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Fri, 4 Mar 2022 13:17:25 -0500 Subject: [PATCH 06/20] [Security Solution] Improve fields browser performance (#126114) * Probably better * Make backspace not slow * Type and prop cleanup * PR comments, fix failing cypress test * Update cypress tests to wait for debounced text filtering * Update cypress test * Update failing cypress tests by waiting when needed * Reload entire page for field browser tests * Skip failing local storage test * Remove unused import, cleanKibana back to before * Skip failing tests * Clear applied filter onHide, undo some cypress changes * Remove unnecessary wait Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../integration/cases/creation.spec.ts | 3 +- .../detection_rules/export_rule.spec.ts | 3 +- .../integration/hosts/events_viewer.spec.ts | 1 - .../timelines/fields_browser.spec.ts | 23 +++- .../timelines/local_storage.spec.ts | 2 +- .../cypress/tasks/alerts_detection_rules.ts | 2 +- .../cypress/tasks/fields_browser.ts | 13 ++- .../components/fields_browser/index.tsx | 5 +- .../fields_browser/field_browser.test.tsx | 2 + .../toolbar/fields_browser/field_browser.tsx | 5 +- .../toolbar/fields_browser/fields_pane.tsx | 25 +++-- .../t_grid/toolbar/fields_browser/index.tsx | 106 ++++++++++-------- .../es_archives/auditbeat/mappings.json | 5 + 13 files changed, 126 insertions(+), 69 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts index 90ab1d098aef50..d08f11a95b1945 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts @@ -50,7 +50,8 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { CASES_URL } from '../../urls/navigation'; -describe('Cases', () => { +// Flaky: https://github.com/elastic/kibana/issues/69847 +describe.skip('Cases', () => { beforeEach(() => { cleanKibana(); createTimeline(getCase1().timeline).then((response) => diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts index 0314c0c3a66b62..1e1abaa326bd47 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts @@ -13,7 +13,8 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; -describe('Export rules', () => { +// Flaky https://github.com/elastic/kibana/issues/69849 +describe.skip('Export rules', () => { beforeEach(() => { cleanKibana(); cy.intercept( diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts index 048efd00d276b3..c28c55e0eb3f7f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts @@ -108,7 +108,6 @@ describe('Events Viewer', () => { it('resets all fields in the events viewer when `Reset Fields` is clicked', () => { const filterInput = 'host.geo.c'; - filterFieldsBrowser(filterInput); cy.get(HOST_GEO_COUNTRY_NAME_HEADER).should('not.exist'); addsHostGeoCountryNameToHeader(); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts index be726f0323d48c..07ea4078ce7c4b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts @@ -32,6 +32,7 @@ import { import { loginAndWaitForPage } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; import { openTimelineFieldsBrowser, populateTimeline } from '../../tasks/timeline'; +import { ecsFieldMap } from '../../../../rule_registry/common/assets/field_maps/ecs_field_map'; import { HOSTS_URL } from '../../urls/navigation'; @@ -109,7 +110,27 @@ describe('Fields Browser', () => { filterFieldsBrowser(filterInput); - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should('have.text', '5'); + const fieldsThatMatchFilterInput = Object.keys(ecsFieldMap).filter((fieldName) => { + const dotDelimitedFieldParts = fieldName.split('.'); + const fieldPartMatch = dotDelimitedFieldParts.filter((fieldPart) => { + const camelCasedStringsMatching = fieldPart + .split('_') + .some((part) => part.startsWith(filterInput)); + if (fieldPart.startsWith(filterInput)) { + return true; + } else if (camelCasedStringsMatching) { + return true; + } else { + return false; + } + }); + return fieldName.startsWith(filterInput) || fieldPartMatch.length > 0; + }).length; + + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should( + 'have.text', + fieldsThatMatchFilterInput + ); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts index 617f04697c9513..b3139d94aa6258 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts @@ -14,7 +14,7 @@ import { waitsForEventsToBeLoaded } from '../../tasks/hosts/events'; import { removeColumn } from '../../tasks/timeline'; // TODO: Fix bug in persisting the columns of timeline -describe('persistent timeline', () => { +describe.skip('persistent timeline', () => { beforeEach(() => { cleanKibana(); loginAndWaitForPage(HOSTS_URL); diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 8475ef7247c2c6..ab09aca83f575a 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -233,7 +233,7 @@ export const changeRowsPerPageTo = (rowsCount: number) => { cy.get(PAGINATION_POPOVER_BTN).click({ force: true }); cy.get(rowsPerPageSelector(rowsCount)) .pipe(($el) => $el.trigger('click')) - .should('not.be.visible'); + .should('not.exist'); }; export const changeRowsPerPageTo100 = () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts index ee8bdb3b023dde..941a19669f2efb 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts @@ -34,17 +34,24 @@ export const addsHostGeoContinentNameToTimeline = () => { }; export const clearFieldsBrowser = () => { + cy.clock(); cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{selectall}{backspace}'); + cy.wait(0); + cy.tick(1000); }; export const closeFieldsBrowser = () => { cy.get(CLOSE_BTN).click({ force: true }); + cy.get(FIELDS_BROWSER_FILTER_INPUT).should('not.exist'); }; export const filterFieldsBrowser = (fieldName: string) => { - cy.get(FIELDS_BROWSER_FILTER_INPUT) - .type(fieldName) - .should('not.have.class', 'euiFieldSearch-isLoading'); + cy.clock(); + cy.get(FIELDS_BROWSER_FILTER_INPUT).type(fieldName, { delay: 50 }); + cy.wait(0); + cy.tick(1000); + // the text filter is debounced by 250 ms, wait 1s for changes to be applied + cy.get(FIELDS_BROWSER_FILTER_INPUT).should('not.have.class', 'euiFieldSearch-isLoading'); }; export const removesMessageField = () => { diff --git a/x-pack/plugins/timelines/public/components/fields_browser/index.tsx b/x-pack/plugins/timelines/public/components/fields_browser/index.tsx index 02fd0553f4016c..31b8e9f62803ec 100644 --- a/x-pack/plugins/timelines/public/components/fields_browser/index.tsx +++ b/x-pack/plugins/timelines/public/components/fields_browser/index.tsx @@ -28,10 +28,7 @@ export const FieldBrowserWrappedComponent = (props: FieldBrowserWrappedComponent return ( - + ); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx index dc9837007e1538..d435d7a280840b 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx @@ -32,6 +32,7 @@ const testProps = { browserFields: mockBrowserFields, filteredBrowserFields: mockBrowserFields, searchInput: '', + appliedFilterInput: '', isSearching: false, onCategorySelected: jest.fn(), onHide, @@ -84,6 +85,7 @@ describe('FieldsBrowser', () => { browserFields={mockBrowserFields} filteredBrowserFields={mockBrowserFields} searchInput={''} + appliedFilterInput={''} isSearching={false} onCategorySelected={jest.fn()} onHide={jest.fn()} diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx index fea22e4efe77c1..e55f54e946ad13 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx @@ -75,6 +75,8 @@ type Props = Pick & isSearching: boolean; /** The text displayed in the search input */ searchInput: string; + /** The text actually being applied to the result set, a debounced version of searchInput */ + appliedFilterInput: string; /** * The category selected on the left-hand side of the field browser */ @@ -115,6 +117,7 @@ const FieldsBrowserComponent: React.FC = ({ onHide, restoreFocusTo, searchInput, + appliedFilterInput, selectedCategoryId, timelineId, width = FIELD_BROWSER_WIDTH, @@ -237,7 +240,7 @@ const FieldsBrowserComponent: React.FC = ({ filteredBrowserFields={filteredBrowserFields} onCategorySelected={onCategorySelected} onUpdateColumns={onUpdateColumns} - searchInput={searchInput} + searchInput={appliedFilterInput} selectedCategoryId={selectedCategoryId} timelineId={timelineId} width={FIELDS_PANE_WIDTH} diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx index 5345475a025018..d1d0254d0c917d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx @@ -98,19 +98,30 @@ export const FieldsPane = React.memo( [filteredBrowserFields] ); + const fieldItems = useMemo(() => { + return getFieldItems({ + category: filteredBrowserFields[selectedCategoryId], + columnHeaders, + highlight: searchInput, + timelineId, + toggleColumn, + }); + }, [ + columnHeaders, + filteredBrowserFields, + searchInput, + selectedCategoryId, + timelineId, + toggleColumn, + ]); + if (filteredBrowserFieldsExists) { return ( = ({ /** all field names shown in the field browser must contain this string (when specified) */ const [filterInput, setFilterInput] = useState(''); + + const [appliedFilterInput, setAppliedFilterInput] = useState(''); /** all fields in this collection have field names that match the filterInput */ const [filteredBrowserFields, setFilteredBrowserFields] = useState(null); /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ @@ -51,15 +53,6 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ const [selectedCategoryId, setSelectedCategoryId] = useState(DEFAULT_CATEGORY_NAME); /** show the field browser */ const [show, setShow] = useState(false); - useEffect(() => { - return () => { - if (inputTimeoutId.current !== 0) { - // ⚠️ mutation: cancel any remaining timers and zero-out the timer id: - clearTimeout(inputTimeoutId.current); - inputTimeoutId.current = 0; - } - }; - }, []); /** Shows / hides the field browser */ const onShow = useCallback(() => { @@ -69,52 +62,68 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ /** Invoked when the field browser should be hidden */ const onHide = useCallback(() => { setFilterInput(''); + setAppliedFilterInput(''); setFilteredBrowserFields(null); setIsSearching(false); setSelectedCategoryId(DEFAULT_CATEGORY_NAME); setShow(false); }, []); + const newFilteredBrowserFields = useMemo(() => { + return filterBrowserFieldsByFieldName({ + browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), + substring: appliedFilterInput, + }); + }, [appliedFilterInput, browserFields]); + + const newSelectedCategoryId = useMemo(() => { + if (appliedFilterInput === '' || Object.keys(newFilteredBrowserFields).length === 0) { + return DEFAULT_CATEGORY_NAME; + } else { + return Object.keys(newFilteredBrowserFields) + .sort() + .reduce((selected, category) => { + const filteredBrowserFieldsByCategory = + (newFilteredBrowserFields[category] && newFilteredBrowserFields[category].fields) || []; + const filteredBrowserFieldsBySelected = + (newFilteredBrowserFields[selected] && newFilteredBrowserFields[selected].fields) || []; + return newFilteredBrowserFields[category].fields != null && + newFilteredBrowserFields[selected].fields != null && + Object.keys(filteredBrowserFieldsByCategory).length > + Object.keys(filteredBrowserFieldsBySelected).length + ? category + : selected; + }, Object.keys(newFilteredBrowserFields)[0]); + } + }, [appliedFilterInput, newFilteredBrowserFields]); + /** Invoked when the user types in the filter input */ - const updateFilter = useCallback( - (newFilterInput: string) => { - setFilterInput(newFilterInput); - setIsSearching(true); - if (inputTimeoutId.current !== 0) { - clearTimeout(inputTimeoutId.current); // ⚠️ mutation: cancel any previous timers - } - // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: - inputTimeoutId.current = window.setTimeout(() => { - const newFilteredBrowserFields = filterBrowserFieldsByFieldName({ - browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), - substring: newFilterInput, - }); - setFilteredBrowserFields(newFilteredBrowserFields); - setIsSearching(false); - - const newSelectedCategoryId = - newFilterInput === '' || Object.keys(newFilteredBrowserFields).length === 0 - ? DEFAULT_CATEGORY_NAME - : Object.keys(newFilteredBrowserFields) - .sort() - .reduce( - (selected, category) => - newFilteredBrowserFields[category].fields != null && - newFilteredBrowserFields[selected].fields != null && - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Object.keys(newFilteredBrowserFields[category].fields!).length > - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Object.keys(newFilteredBrowserFields[selected].fields!).length - ? category - : selected, - Object.keys(newFilteredBrowserFields)[0] - ); - setSelectedCategoryId(newSelectedCategoryId); - }, INPUT_TIMEOUT); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [browserFields, filterInput, inputTimeoutId.current] - ); + const updateFilter = useCallback((newFilterInput: string) => { + setFilterInput(newFilterInput); + setIsSearching(true); + }, []); + + useEffect(() => { + if (inputTimeoutId.current !== 0) { + clearTimeout(inputTimeoutId.current); // ⚠️ mutation: cancel any previous timers + } + // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: + inputTimeoutId.current = window.setTimeout(() => { + setIsSearching(false); + setAppliedFilterInput(filterInput); + }, INPUT_TIMEOUT); + return () => { + clearTimeout(inputTimeoutId.current); + }; + }, [filterInput]); + + useEffect(() => { + setFilteredBrowserFields(newFilteredBrowserFields); + }, [newFilteredBrowserFields]); + + useEffect(() => { + setSelectedCategoryId(newSelectedCategoryId); + }, [newSelectedCategoryId]); // only merge in the default category if the field browser is visible const browserFieldsWithDefaultCategory = useMemo(() => { @@ -152,6 +161,7 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ onSearchInputChange={updateFilter} restoreFocusTo={customizeColumnsButtonRef} searchInput={filterInput} + appliedFilterInput={appliedFilterInput} selectedCategoryId={selectedCategoryId} timelineId={timelineId} width={width} diff --git a/x-pack/test/security_solution_cypress/es_archives/auditbeat/mappings.json b/x-pack/test/security_solution_cypress/es_archives/auditbeat/mappings.json index 3196232e59643f..061748d72b77b0 100644 --- a/x-pack/test/security_solution_cypress/es_archives/auditbeat/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/auditbeat/mappings.json @@ -1735,6 +1735,10 @@ "ignore_above": 1024, "type": "keyword" }, + "continent_code": { + "ignore_above": 1024, + "type": "keyword" + }, "continent_name": { "ignore_above": 1024, "type": "keyword" @@ -3110,6 +3114,7 @@ "group.name", "host.architecture", "host.geo.city_name", + "host.geo.continent_code", "host.geo.continent_name", "host.geo.country_iso_code", "host.geo.country_name", From eed64fda745de983c7a21f7b291952fcb396ddd8 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Fri, 4 Mar 2022 14:17:01 -0500 Subject: [PATCH 07/20] Testing project_assigner action for beta projects (#126950) We suspect this action will not work with GH beta projects, so let's confirm. --- .github/workflows/assign-infra-monitoring.yml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/assign-infra-monitoring.yml diff --git a/.github/workflows/assign-infra-monitoring.yml b/.github/workflows/assign-infra-monitoring.yml new file mode 100644 index 00000000000000..96376ecebf8544 --- /dev/null +++ b/.github/workflows/assign-infra-monitoring.yml @@ -0,0 +1,22 @@ +name: Assign to Infra Monitoring UI (beta) project +on: + issues: + types: [labeled] + +jobs: + assign_to_project: + runs-on: ubuntu-latest + name: Assign issue or PR to project based on label + steps: + - name: Assign to project + uses: elastic/github-actions/project-assigner@v2.1.1 + id: project_assigner_infra_monitoring + with: + issue-mappings: | + [ + {"label": "Team:Infra Monitoring UI", "projectNumber": 664, "projectScope": "org"}, + {"label": "Feature:Stack Monitoring", "projectNumber": 664, "projectScope": "org"}, + {"label": "Feature:Logs UI", "projectNumber": 664, "projectScope": "org"}, + {"label": "Feature:Metrics UI", "projectNumber": 664, "projectScope": "org"}, + ] + ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} From 70a4f7930f007af741f0328c88c9a6a714542135 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Fri, 4 Mar 2022 11:19:58 -0800 Subject: [PATCH 08/20] Revert "Testing project_assigner action for beta projects" (#126952) This reverts commit eed64fda745de983c7a21f7b291952fcb396ddd8. --- .github/workflows/assign-infra-monitoring.yml | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/assign-infra-monitoring.yml diff --git a/.github/workflows/assign-infra-monitoring.yml b/.github/workflows/assign-infra-monitoring.yml deleted file mode 100644 index 96376ecebf8544..00000000000000 --- a/.github/workflows/assign-infra-monitoring.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Assign to Infra Monitoring UI (beta) project -on: - issues: - types: [labeled] - -jobs: - assign_to_project: - runs-on: ubuntu-latest - name: Assign issue or PR to project based on label - steps: - - name: Assign to project - uses: elastic/github-actions/project-assigner@v2.1.1 - id: project_assigner_infra_monitoring - with: - issue-mappings: | - [ - {"label": "Team:Infra Monitoring UI", "projectNumber": 664, "projectScope": "org"}, - {"label": "Feature:Stack Monitoring", "projectNumber": 664, "projectScope": "org"}, - {"label": "Feature:Logs UI", "projectNumber": 664, "projectScope": "org"}, - {"label": "Feature:Metrics UI", "projectNumber": 664, "projectScope": "org"}, - ] - ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} From 1ed4aea9e4be3a50d1635552af005b4ec76b8ebc Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Fri, 4 Mar 2022 14:23:35 -0500 Subject: [PATCH 09/20] Adds workflow for infra monitoring ui team (#126921) * Adds workflow for infra monitoring ui team * Adds other labels for our team * Updates token to general use one @tylersmalley mentioned this one exists, so it seems like a safer choice for now. Ultimately we may want a single one from the Elastic org that is enabled for every repo that needs it. --- .../workflows/project-infra-monitoring-ui.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/project-infra-monitoring-ui.yml diff --git a/.github/workflows/project-infra-monitoring-ui.yml b/.github/workflows/project-infra-monitoring-ui.yml new file mode 100644 index 00000000000000..b9fd04b164a8d5 --- /dev/null +++ b/.github/workflows/project-infra-monitoring-ui.yml @@ -0,0 +1,25 @@ +name: Add issues to Infra Monitoring UI project +on: + issues: + types: [labeled] + +jobs: + sync_issues_with_table: + runs-on: ubuntu-latest + name: Add issues to project + steps: + - name: Add + uses: richkuz/projectnext-label-assigner@1.0.2 + id: add_to_projects + with: + config: | + [ + {"label": "Team:Infra Monitoring UI", "projectNumber": 664}, + {"label": "Feature:Stack Monitoring", "projectNumber": 664}, + {"label": "Feature:Logs UI", "projectNumber": 664}, + {"label": "Feature:Metrics UI", "projectNumber": 664}, + ] + env: + GRAPHQL_API_BASE: 'https://api.github.com' + PAT_TOKEN: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From f974150c7be332ed9e016f1134400c268ffe1cde Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 4 Mar 2022 13:46:45 -0700 Subject: [PATCH 10/20] [Dashboard] Close toolbar popover for log stream visualizations (#126840) * Fix close popover on click * Fix close popover on click - second attempt * Add functional test to ensure menu closes --- .../application/top_nav/editor_menu.tsx | 85 ++++++++++--------- .../dashboard/create_and_add_embeddables.ts | 16 +++- .../services/dashboard/add_panel.ts | 5 ++ 3 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx index 44b1aec226fd66..5fece7ff959ced 100644 --- a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx +++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx @@ -153,7 +153,8 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { }; const getEmbeddableFactoryMenuItem = ( - factory: EmbeddableFactoryDefinition + factory: EmbeddableFactoryDefinition, + closePopover: () => void ): EuiContextMenuPanelItemDescriptor => { const icon = factory?.getIconType ? factory.getIconType() : 'empty'; @@ -164,6 +165,7 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { icon, toolTipContent, onClick: async () => { + closePopover(); if (trackUiMetric) { trackUiMetric(METRIC_TYPE.CLICK, factory.type); } @@ -192,42 +194,47 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { defaultMessage: 'Aggregation based', }); - const editorMenuPanels = [ - { - id: 0, - items: [ - ...visTypeAliases.map(getVisTypeAliasMenuItem), - ...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({ - name: appName, - icon, - panel: panelId, - 'data-test-subj': `dashboardEditorMenu-${id}Group`, - })), - ...ungroupedFactories.map(getEmbeddableFactoryMenuItem), - ...promotedVisTypes.map(getVisTypeMenuItem), - { - name: aggsPanelTitle, - icon: 'visualizeApp', - panel: aggBasedPanelID, - 'data-test-subj': `dashboardEditorAggBasedMenuItem`, - }, - ...toolVisTypes.map(getVisTypeMenuItem), - ], - }, - { - id: aggBasedPanelID, - title: aggsPanelTitle, - items: aggsBasedVisTypes.map(getVisTypeMenuItem), - }, - ...Object.values(factoryGroupMap).map( - ({ appName, panelId, factories: groupFactories }: FactoryGroup) => ({ - id: panelId, - title: appName, - items: groupFactories.map(getEmbeddableFactoryMenuItem), - }) - ), - ]; - + const getEditorMenuPanels = (closePopover: () => void) => { + return [ + { + id: 0, + items: [ + ...visTypeAliases.map(getVisTypeAliasMenuItem), + ...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({ + name: appName, + icon, + panel: panelId, + 'data-test-subj': `dashboardEditorMenu-${id}Group`, + })), + ...ungroupedFactories.map((factory) => { + return getEmbeddableFactoryMenuItem(factory, closePopover); + }), + ...promotedVisTypes.map(getVisTypeMenuItem), + { + name: aggsPanelTitle, + icon: 'visualizeApp', + panel: aggBasedPanelID, + 'data-test-subj': `dashboardEditorAggBasedMenuItem`, + }, + ...toolVisTypes.map(getVisTypeMenuItem), + ], + }, + { + id: aggBasedPanelID, + title: aggsPanelTitle, + items: aggsBasedVisTypes.map(getVisTypeMenuItem), + }, + ...Object.values(factoryGroupMap).map( + ({ appName, panelId, factories: groupFactories }: FactoryGroup) => ({ + id: panelId, + title: appName, + items: groupFactories.map((factory) => { + return getEmbeddableFactoryMenuItem(factory, closePopover); + }), + }) + ), + ]; + }; return ( { panelPaddingSize="none" data-test-subj="dashboardEditorMenuButton" > - {() => ( + {({ closePopover }: { closePopover: () => void }) => ( { await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.preserveCrossAppState(); - await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('LOG_STREAM_EMBEDDABLE'); + await dashboardAddPanel.expectEditorMenuClosed(); }); describe('add new visualization link', () => { + before(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + }); + it('adds new visualization via the top nav link', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await PageObjects.dashboard.switchToEditMode(); diff --git a/test/functional/services/dashboard/add_panel.ts b/test/functional/services/dashboard/add_panel.ts index 43ab1f966bc9a0..e42c221a494759 100644 --- a/test/functional/services/dashboard/add_panel.ts +++ b/test/functional/services/dashboard/add_panel.ts @@ -46,6 +46,11 @@ export class DashboardAddPanelService extends FtrService { async clickEditorMenuButton() { this.log.debug('DashboardAddPanel.clickEditorMenuButton'); await this.testSubjects.click('dashboardEditorMenuButton'); + await this.testSubjects.existOrFail('dashboardEditorContextMenu'); + } + + async expectEditorMenuClosed() { + await this.testSubjects.missingOrFail('dashboardEditorContextMenu'); } async clickAggBasedVisualizations() { From 5f8f4d7c4f9151d4baaa5cb579e1c732b57b078a Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 4 Mar 2022 12:56:30 -0800 Subject: [PATCH 11/20] Edits dateFormat settings (#126858) --- .../server/ui_settings/settings/date_formats.ts | 14 +++++--------- .../plugins/translations/translations/fr-FR.json | 5 ----- .../plugins/translations/translations/ja-JP.json | 5 ----- .../plugins/translations/translations/zh-CN.json | 5 ----- 4 files changed, 5 insertions(+), 24 deletions(-) diff --git a/src/core/server/ui_settings/settings/date_formats.ts b/src/core/server/ui_settings/settings/date_formats.ts index c626c4a83cc4cc..039ead326a2361 100644 --- a/src/core/server/ui_settings/settings/date_formats.ts +++ b/src/core/server/ui_settings/settings/date_formats.ts @@ -31,7 +31,7 @@ export const getDateFormatSettings = (): Record => { }), value: 'MMM D, YYYY @ HH:mm:ss.SSS', description: i18n.translate('core.ui_settings.params.dateFormatText', { - defaultMessage: 'When displaying a pretty formatted date, use this {formatLink}', + defaultMessage: 'The {formatLink} for pretty formatted dates.', description: 'Part of composite text: core.ui_settings.params.dateFormatText + ' + 'core.ui_settings.params.dateFormat.optionsLinkText', @@ -48,15 +48,11 @@ export const getDateFormatSettings = (): Record => { }, 'dateFormat:tz': { name: i18n.translate('core.ui_settings.params.dateFormat.timezoneTitle', { - defaultMessage: 'Timezone for date formatting', + defaultMessage: 'Time zone', }), value: 'Browser', description: i18n.translate('core.ui_settings.params.dateFormat.timezoneText', { - defaultMessage: - 'Which timezone should be used. {defaultOption} will use the timezone detected by your browser.', - values: { - defaultOption: '"Browser"', - }, + defaultMessage: 'The default time zone.', }), type: 'select', options: timezones, @@ -115,7 +111,7 @@ export const getDateFormatSettings = (): Record => { }), value: defaultWeekday, description: i18n.translate('core.ui_settings.params.dateFormat.dayOfWeekText', { - defaultMessage: 'What day should weeks start on?', + defaultMessage: 'The day that starts the week.', }), type: 'select', options: weekdays, @@ -141,7 +137,7 @@ export const getDateFormatSettings = (): Record => { }), value: 'MMM D, YYYY @ HH:mm:ss.SSSSSSSSS', description: i18n.translate('core.ui_settings.params.dateNanosFormatText', { - defaultMessage: 'Used for the {dateNanosLink} datatype of Elasticsearch', + defaultMessage: 'The format for {dateNanosLink} data.', values: { dateNanosLink: '' + diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 96327533d4e291..99e4c6a4ca1f30 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -1254,18 +1254,13 @@ "core.toasts.errorToast.seeFullError": "Voir l'erreur en intégralité", "core.ui_settings.params.darkModeText": "Activez le mode sombre pour l'interface utilisateur Kibana. Vous devez actualiser la page pour que ce paramètre s’applique.", "core.ui_settings.params.darkModeTitle": "Mode sombre", - "core.ui_settings.params.dateFormat.dayOfWeekText": "Quel est le premier jour de la semaine ?", "core.ui_settings.params.dateFormat.dayOfWeekTitle": "Jour de la semaine", "core.ui_settings.params.dateFormat.optionsLinkText": "format", "core.ui_settings.params.dateFormat.scaled.intervalsLinkText": "Intervalles ISO8601", "core.ui_settings.params.dateFormat.scaledText": "Les valeurs qui définissent le format utilisé lorsque les données temporelles sont rendues dans l'ordre, et lorsque les horodatages formatés doivent s'adapter à l'intervalle entre les mesures. Les clés sont {intervalsLink}.", "core.ui_settings.params.dateFormat.scaledTitle": "Format de date scalé", "core.ui_settings.params.dateFormat.timezone.invalidValidationMessage": "Fuseau horaire non valide : {timezone}", - "core.ui_settings.params.dateFormat.timezoneText": "Fuseau horaire à utiliser. L’option {defaultOption} utilise le fuseau horaire détecté par le navigateur.", - "core.ui_settings.params.dateFormat.timezoneTitle": "Fuseau horaire pour le format de date", - "core.ui_settings.params.dateFormatText": "{formatLink} utilisé pour les dates formatées", "core.ui_settings.params.dateFormatTitle": "Format de date", - "core.ui_settings.params.dateNanosFormatText": "Utilisé pour le type de données {dateNanosLink} d'Elasticsearch", "core.ui_settings.params.dateNanosFormatTitle": "Date au format nanosecondes", "core.ui_settings.params.dateNanosLinkTitle": "date_nanos", "core.ui_settings.params.dayOfWeekText.invalidValidationMessage": "Jour de la semaine non valide : {dayOfWeek}", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d47ff1ed31496a..70d65550a40563 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1567,18 +1567,13 @@ "core.toasts.errorToast.seeFullError": "完全なエラーを表示", "core.ui_settings.params.darkModeText": "Kibana UIのダークモードを有効にします。この設定を適用するにはページの更新が必要です。", "core.ui_settings.params.darkModeTitle": "ダークモード", - "core.ui_settings.params.dateFormat.dayOfWeekText": "週の初めの曜日を設定します", "core.ui_settings.params.dateFormat.dayOfWeekTitle": "曜日", "core.ui_settings.params.dateFormat.optionsLinkText": "フォーマット", "core.ui_settings.params.dateFormat.scaled.intervalsLinkText": "ISO8601間隔", "core.ui_settings.params.dateFormat.scaledText": "時間ベースのデータが順番にレンダリングされ、フォーマットされたタイムスタンプが測定値の間隔に適応すべき状況で使用されるフォーマットを定義する値です。キーは{intervalsLink}です。", "core.ui_settings.params.dateFormat.scaledTitle": "スケーリングされたデータフォーマットです", "core.ui_settings.params.dateFormat.timezone.invalidValidationMessage": "無効なタイムゾーン:{timezone}", - "core.ui_settings.params.dateFormat.timezoneText": "使用されるタイムゾーンです。{defaultOption}ではご使用のブラウザーにより検知されたタイムゾーンが使用されます。", - "core.ui_settings.params.dateFormat.timezoneTitle": "データフォーマットのタイムゾーン", - "core.ui_settings.params.dateFormatText": "きちんとフォーマットされたデータを表示する際、この{formatLink}を使用します", "core.ui_settings.params.dateFormatTitle": "データフォーマット", - "core.ui_settings.params.dateNanosFormatText": "Elasticsearchの{dateNanosLink}データタイプに使用されます", "core.ui_settings.params.dateNanosFormatTitle": "ナノ秒フォーマットでの日付", "core.ui_settings.params.dateNanosLinkTitle": "date_nanos", "core.ui_settings.params.dayOfWeekText.invalidValidationMessage": "無効な曜日:{dayOfWeek}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 96f9e892ba0b93..90eb0d3c35f653 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1574,18 +1574,13 @@ "core.toasts.errorToast.seeFullError": "请参阅完整的错误信息", "core.ui_settings.params.darkModeText": "对 Kibana UI 启用深色模式。需要刷新页面,才能应用设置。", "core.ui_settings.params.darkModeTitle": "深色模式", - "core.ui_settings.params.dateFormat.dayOfWeekText": "一周应该从哪一天开始?", "core.ui_settings.params.dateFormat.dayOfWeekTitle": "周内日", "core.ui_settings.params.dateFormat.optionsLinkText": "格式", "core.ui_settings.params.dateFormat.scaled.intervalsLinkText": "ISO8601 时间间隔", "core.ui_settings.params.dateFormat.scaledText": "定义在以下场合中采用的格式的值:基于时间的数据按顺序呈现,且经格式化的时间戳应适应度量之间的时间间隔。键是{intervalsLink}。", "core.ui_settings.params.dateFormat.scaledTitle": "标度日期格式", "core.ui_settings.params.dateFormat.timezone.invalidValidationMessage": "时区无效:{timezone}", - "core.ui_settings.params.dateFormat.timezoneText": "应使用哪个时区。{defaultOption} 将使用您的浏览器检测到的时区。", - "core.ui_settings.params.dateFormat.timezoneTitle": "用于设置日期格式的时区", - "core.ui_settings.params.dateFormatText": "显示格式正确的日期时,请使用此{formatLink}", "core.ui_settings.params.dateFormatTitle": "日期格式", - "core.ui_settings.params.dateNanosFormatText": "用于 Elasticsearch 的 {dateNanosLink} 数据类型", "core.ui_settings.params.dateNanosFormatTitle": "纳秒格式的日期", "core.ui_settings.params.dateNanosLinkTitle": "date_nanos", "core.ui_settings.params.dayOfWeekText.invalidValidationMessage": "周内日无效:{dayOfWeek}", From 9514e6be38e2f17c3710f17651e716ce94ec9613 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Fri, 4 Mar 2022 16:07:45 -0500 Subject: [PATCH 12/20] Update and rename project-infra-monitoring-ui.yml to add-to-imui-project.yml (#126963) The previous action was failing with an obscure JSON error, so I've copied the APM and Fleet actions instead. --- .github/workflows/add-to-imui-project.yml | 31 +++++++++++++++++++ .../workflows/project-infra-monitoring-ui.yml | 25 --------------- 2 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/add-to-imui-project.yml delete mode 100644 .github/workflows/project-infra-monitoring-ui.yml diff --git a/.github/workflows/add-to-imui-project.yml b/.github/workflows/add-to-imui-project.yml new file mode 100644 index 00000000000000..3cf120b2e81bc5 --- /dev/null +++ b/.github/workflows/add-to-imui-project.yml @@ -0,0 +1,31 @@ +name: Add to Infra Monitoring UI project +on: + issues: + types: + - labeled +jobs: + add_to_project: + runs-on: ubuntu-latest + if: | + contains(github.event.issue.labels.*.name, 'Team:Infra Monitoring UI') || + contains(github.event.issue.labels.*.name, 'Feature:Stack Monitoring') || + contains(github.event.issue.labels.*.name, 'Feature:Logs UI') || + contains(github.event.issue.labels.*.name, 'Feature:Metrics UI') + steps: + - uses: octokit/graphql-action@v2.x + id: add_to_project + with: + headers: '{"GraphQL-Features": "projects_next_graphql"}' + query: | + mutation add_to_project($projectid:ID!,$contentid:ID!) { + addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { + projectNextItem { + id + } + } + } + projectid: ${{ env.PROJECT_ID }} + contentid: ${{ github.event.issue.node_id }} + env: + PROJECT_ID: "PN_kwDOAGc3Zs1EEA" + GITHUB_TOKEN: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} diff --git a/.github/workflows/project-infra-monitoring-ui.yml b/.github/workflows/project-infra-monitoring-ui.yml deleted file mode 100644 index b9fd04b164a8d5..00000000000000 --- a/.github/workflows/project-infra-monitoring-ui.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Add issues to Infra Monitoring UI project -on: - issues: - types: [labeled] - -jobs: - sync_issues_with_table: - runs-on: ubuntu-latest - name: Add issues to project - steps: - - name: Add - uses: richkuz/projectnext-label-assigner@1.0.2 - id: add_to_projects - with: - config: | - [ - {"label": "Team:Infra Monitoring UI", "projectNumber": 664}, - {"label": "Feature:Stack Monitoring", "projectNumber": 664}, - {"label": "Feature:Logs UI", "projectNumber": 664}, - {"label": "Feature:Metrics UI", "projectNumber": 664}, - ] - env: - GRAPHQL_API_BASE: 'https://api.github.com' - PAT_TOKEN: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 63892cf654fccf56a80a01555190fde84f8e46f1 Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Fri, 4 Mar 2022 22:59:08 +0100 Subject: [PATCH 13/20] [Alerting] Paginated http requests for the expensive queries (#126111) * Fetch index patterns as chunks on indices search input change. Use debounce for the http requests that are triggered on input change --- .../components/index_select_popover.test.tsx | 8 + .../components/index_select_popover.tsx | 21 +-- .../es_index/es_index_connector.test.tsx | 67 ++++++-- .../es_index/es_index_connector.tsx | 27 ++-- .../public/common/index_controls/index.ts | 38 ++--- .../public/common/lib/data_apis.test.ts | 148 ++++++++++++++++++ .../public/common/lib/data_apis.ts | 82 ++++++++-- 7 files changed, 304 insertions(+), 87 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.test.ts diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx index e5c8343fddf6d4..7b27167d5f5f91 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx @@ -11,6 +11,14 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { IndexSelectPopover } from './index_select_popover'; import { EuiComboBox } from '@elastic/eui'; +jest.mock('lodash', () => { + const module = jest.requireActual('lodash'); + return { + ...module, + debounce: (fn: () => unknown) => fn, + }; +}); + jest.mock('../../../../triggers_actions_ui/public', () => { const original = jest.requireActual('../../../../triggers_actions_ui/public'); return { diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx index fbfb296c7b2704..a8b9f3f56dd06e 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { isString } from 'lodash'; +import { isString, debounce } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonIcon, @@ -27,7 +27,6 @@ import { firstFieldOption, getFields, getIndexOptions, - getIndexPatterns, getTimeFieldOptions, IErrorObject, } from '../../../../triggers_actions_ui/public'; @@ -62,16 +61,14 @@ export const IndexSelectPopover: React.FunctionComponent = ({ const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); const [indexOptions, setIndexOptions] = useState([]); - const [indexPatterns, setIndexPatterns] = useState([]); const [areIndicesLoading, setAreIndicesLoading] = useState(false); const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); - useEffect(() => { - const indexPatternsFunction = async () => { - setIndexPatterns(await getIndexPatterns()); - }; - indexPatternsFunction(); - }, []); + const loadIndexOptions = debounce(async (search: string) => { + setAreIndicesLoading(true); + setIndexOptions(await getIndexOptions(http!, search)); + setAreIndicesLoading(false); + }, 250); useEffect(() => { const timeFields = getTimeFieldOptions(esFields); @@ -193,11 +190,7 @@ export const IndexSelectPopover: React.FunctionComponent = ({ setTimeFieldOptions([firstFieldOption, ...timeFields]); } }} - onSearchChange={async (search) => { - setAreIndicesLoading(true); - setIndexOptions(await getIndexOptions(http!, search, indexPatterns)); - setAreIndicesLoading(false); - }} + onSearchChange={loadIndexOptions} onBlur={() => { if (!index) { onIndexChange([]); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx index 6aa7fde6d23e1a..2f6cbabc676cba 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -11,28 +11,32 @@ import { act } from 'react-dom/test-utils'; import { EsIndexActionConnector } from '../types'; import IndexActionConnectorFields from './es_index_connector'; import { EuiComboBox, EuiSwitch, EuiSwitchEvent, EuiSelect } from '@elastic/eui'; +import { screen, render, fireEvent } from '@testing-library/react'; jest.mock('../../../../common/lib/kibana'); +jest.mock('lodash', () => { + const module = jest.requireActual('lodash'); + return { + ...module, + debounce: (fn: () => unknown) => fn, + }; +}); + jest.mock('../../../../common/index_controls', () => ({ firstFieldOption: jest.fn(), getFields: jest.fn(), getIndexOptions: jest.fn(), - getIndexPatterns: jest.fn(), })); -const { getIndexPatterns } = jest.requireMock('../../../../common/index_controls'); -getIndexPatterns.mockResolvedValueOnce([ - { - id: 'indexPattern1', - attributes: { - title: 'indexPattern1', - }, - }, +const { getIndexOptions } = jest.requireMock('../../../../common/index_controls'); + +getIndexOptions.mockResolvedValueOnce([ { - id: 'indexPattern2', - attributes: { - title: 'indexPattern2', - }, + label: 'indexOption', + options: [ + { label: 'indexPattern1', value: 'indexPattern1' }, + { label: 'indexPattern2', value: 'indexPattern2' }, + ], }, ]); @@ -59,6 +63,7 @@ function setupGetFieldsResponse(getFieldsWithDateMapping: boolean) { }, ]); } + describe('IndexActionConnectorFields renders', () => { test('renders correctly when creating connector', async () => { const props = { @@ -281,4 +286,40 @@ describe('IndexActionConnectorFields renders', () => { .filter('[data-test-subj="executionTimeFieldSelect"]'); expect(timeFieldSelect.prop('value')).toEqual('test1'); }); + + test('fetches index names on index combobox input change', async () => { + const mockIndexName = 'test-index'; + const props = { + action: { + actionTypeId: '.index', + config: {}, + secrets: {}, + } as EsIndexActionConnector, + editActionConfig: () => {}, + editActionSecrets: () => {}, + errors: { index: [] }, + readOnly: false, + setCallbacks: () => {}, + isEdit: false, + }; + render(); + + const indexComboBox = await screen.findByTestId('connectorIndexesComboBox'); + + // time field switch should show up if index has date type field mapping + setupGetFieldsResponse(true); + + fireEvent.click(indexComboBox); + + await act(async () => { + const event = { target: { value: mockIndexName } }; + fireEvent.change(screen.getByRole('textbox'), event); + }); + + expect(getIndexOptions).toHaveBeenCalledTimes(1); + expect(getIndexOptions).toHaveBeenCalledWith(expect.anything(), mockIndexName); + expect(await screen.findAllByRole('option')).toHaveLength(2); + expect(screen.getByText('indexPattern1')).toBeInTheDocument(); + expect(screen.getByText('indexPattern2')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index c99477bfa83f9f..7b0515d8904e27 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -19,15 +19,11 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; import { ActionConnectorFieldsProps } from '../../../../types'; import { EsIndexActionConnector } from '.././types'; import { getTimeFieldOptions } from '../../../../common/lib/get_time_options'; -import { - firstFieldOption, - getFields, - getIndexOptions, - getIndexPatterns, -} from '../../../../common/index_controls'; +import { firstFieldOption, getFields, getIndexOptions } from '../../../../common/index_controls'; import { useKibana } from '../../../../common/lib/kibana'; interface TimeFieldOptions { @@ -47,10 +43,9 @@ const IndexActionConnectorFields: React.FunctionComponent< executionTimeField != null ); - const [indexPatterns, setIndexPatterns] = useState([]); const [indexOptions, setIndexOptions] = useState([]); const [timeFieldOptions, setTimeFieldOptions] = useState([]); - const [isIndiciesLoading, setIsIndiciesLoading] = useState(false); + const [areIndiciesLoading, setAreIndicesLoading] = useState(false); const setTimeFields = (fields: TimeFieldOptions[]) => { if (fields.length > 0) { @@ -63,9 +58,14 @@ const IndexActionConnectorFields: React.FunctionComponent< } }; + const loadIndexOptions = debounce(async (search: string) => { + setAreIndicesLoading(true); + setIndexOptions(await getIndexOptions(http!, search)); + setAreIndicesLoading(false); + }, 250); + useEffect(() => { const indexPatternsFunction = async () => { - setIndexPatterns(await getIndexPatterns()); if (index) { const currentEsFields = await getFields(http!, [index]); setTimeFields(getTimeFieldOptions(currentEsFields as any)); @@ -119,11 +119,12 @@ const IndexActionConnectorFields: React.FunctionComponent< fullWidth singleSelection={{ asPlainText: true }} async - isLoading={isIndiciesLoading} + isLoading={areIndiciesLoading} isInvalid={isIndexInvalid} noSuggestions={!indexOptions.length} options={indexOptions} data-test-subj="connectorIndexesComboBox" + data-testid="connectorIndexesComboBox" selectedOptions={ index ? [ @@ -147,11 +148,7 @@ const IndexActionConnectorFields: React.FunctionComponent< const currentEsFields = await getFields(http!, indices); setTimeFields(getTimeFieldOptions(currentEsFields as any)); }} - onSearchChange={async (search) => { - setIsIndiciesLoading(true); - setIndexOptions(await getIndexOptions(http!, search, indexPatterns)); - setIsIndiciesLoading(false); - }} + onSearchChange={loadIndexOptions} onBlur={() => { if (!index) { editActionConfig('index', ''); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts index 072684de68b3ed..b05c3f51de4ab6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts @@ -8,48 +8,30 @@ import { uniq } from 'lodash'; import { HttpSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { - loadIndexPatterns, - getMatchingIndices, - getESIndexFields, - getSavedObjectsClient, -} from '../lib/data_apis'; +import { loadIndexPatterns, getMatchingIndices, getESIndexFields } from '../lib/data_apis'; export interface IOption { label: string; options: Array<{ value: string; label: string }>; } -export const getIndexPatterns = async () => { - // TODO: Implement a possibility to retrive index patterns different way to be able to expose this in consumer plugins - if (getSavedObjectsClient()) { - const indexPatternObjects = await loadIndexPatterns(); - return indexPatternObjects.map((indexPattern: any) => indexPattern.attributes.title); - } - return []; -}; - -export const getIndexOptions = async ( - http: HttpSetup, - pattern: string, - indexPatternsParam: string[] -) => { +export const getIndexOptions = async (http: HttpSetup, pattern: string) => { const options: IOption[] = []; if (!pattern) { return options; } - const matchingIndices = (await getMatchingIndices({ - pattern, - http, - })) as string[]; - const matchingIndexPatterns = indexPatternsParam.filter((anIndexPattern) => { - return anIndexPattern.includes(pattern); - }) as string[]; + const [matchingIndices, matchingIndexPatterns] = await Promise.all([ + getMatchingIndices({ + pattern, + http, + }), + loadIndexPatterns(pattern), + ]); if (matchingIndices.length || matchingIndexPatterns.length) { - const matchingOptions = uniq([...matchingIndices, ...matchingIndexPatterns]); + const matchingOptions = uniq([...(matchingIndices as string[]), ...matchingIndexPatterns]); options.push({ label: i18n.translate( diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.test.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.test.ts new file mode 100644 index 00000000000000..92908dbe4c4c72 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + loadIndexPatterns, + setSavedObjectsClient, + getMatchingIndices, + getESIndexFields, +} from './data_apis'; +import { httpServiceMock } from 'src/core/public/mocks'; + +const mockFind = jest.fn(); +const perPage = 1000; +const http = httpServiceMock.createStartContract(); +const pattern = 'test-pattern'; +const indexes = ['test-index']; + +const generateIndexPattern = (title: string) => ({ + attributes: { + title, + }, +}); + +const mockIndices = { indices: ['indices1', 'indices2'] }; +const mockFields = { + fields: [ + { name: 'name', type: 'type', normalizedType: 'nType', searchable: true, aggregatable: false }, + ], +}; + +const mockPattern = 'test-pattern'; + +describe('Data API', () => { + describe('index fields', () => { + test('fetches index fields', async () => { + http.post.mockResolvedValueOnce(mockFields); + const fields = await getESIndexFields({ indexes, http }); + + expect(http.post).toHaveBeenCalledWith('/api/triggers_actions_ui/data/_fields', { + body: `{"indexPatterns":${JSON.stringify(indexes)}}`, + }); + expect(fields).toEqual(mockFields.fields); + }); + }); + + describe('matching indices', () => { + test('fetches indices', async () => { + http.post.mockResolvedValueOnce(mockIndices); + const indices = await getMatchingIndices({ pattern, http }); + + expect(http.post).toHaveBeenCalledWith('/api/triggers_actions_ui/data/_indices', { + body: `{"pattern":"*${mockPattern}*"}`, + }); + expect(indices).toEqual(mockIndices.indices); + }); + + test('returns empty array if fetch fails', async () => { + http.post.mockRejectedValueOnce(500); + const indices = await getMatchingIndices({ pattern, http }); + expect(indices).toEqual([]); + }); + }); + + describe('index patterns', () => { + beforeEach(() => { + setSavedObjectsClient({ + find: mockFind, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test('fetches the index patterns', async () => { + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-1'), generateIndexPattern('index-2')], + total: 2, + }); + const results = await loadIndexPatterns(mockPattern); + + expect(mockFind).toBeCalledTimes(1); + expect(mockFind).toBeCalledWith({ + fields: ['title'], + page: 1, + perPage, + search: '*test-pattern*', + type: 'index-pattern', + }); + expect(results).toEqual(['index-1', 'index-2']); + }); + + test(`fetches the index patterns as chunks and merges them, if the total number of index patterns more than ${perPage}`, async () => { + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-1'), generateIndexPattern('index-2')], + total: 2010, + }); + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-3'), generateIndexPattern('index-4')], + total: 2010, + }); + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-5'), generateIndexPattern('index-6')], + total: 2010, + }); + const results = await loadIndexPatterns(mockPattern); + + expect(mockFind).toBeCalledTimes(3); + expect(mockFind).toHaveBeenNthCalledWith(1, { + fields: ['title'], + page: 1, + perPage, + search: '*test-pattern*', + type: 'index-pattern', + }); + expect(mockFind).toHaveBeenNthCalledWith(2, { + fields: ['title'], + page: 2, + perPage, + search: '*test-pattern*', + type: 'index-pattern', + }); + expect(mockFind).toHaveBeenNthCalledWith(3, { + fields: ['title'], + page: 3, + perPage, + search: '*test-pattern*', + type: 'index-pattern', + }); + expect(results).toEqual(['index-1', 'index-2', 'index-3', 'index-4', 'index-5', 'index-6']); + }); + + test('returns an empty array if one of the requests fails', async () => { + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-1'), generateIndexPattern('index-2')], + total: 1010, + }); + mockFind.mockRejectedValueOnce(500); + + const results = await loadIndexPatterns(mockPattern); + + expect(results).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts index d8a1ecabcd500b..7ccf3bf71bec7a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts @@ -9,6 +9,17 @@ import { HttpSetup } from 'kibana/public'; const DATA_API_ROOT = '/api/triggers_actions_ui/data'; +const formatPattern = (pattern: string) => { + let formattedPattern = pattern; + if (!formattedPattern.startsWith('*')) { + formattedPattern = `*${formattedPattern}`; + } + if (!formattedPattern.endsWith('*')) { + formattedPattern = `${formattedPattern}*`; + } + return formattedPattern; +}; + export async function getMatchingIndices({ pattern, http, @@ -16,17 +27,17 @@ export async function getMatchingIndices({ pattern: string; http: HttpSetup; }): Promise> { - if (!pattern.startsWith('*')) { - pattern = `*${pattern}`; - } - if (!pattern.endsWith('*')) { - pattern = `${pattern}*`; + try { + const formattedPattern = formatPattern(pattern); + + const { indices } = await http.post>( + `${DATA_API_ROOT}/_indices`, + { body: JSON.stringify({ pattern: formattedPattern }) } + ); + return indices; + } catch (e) { + return []; } - const { indices } = await http.post>( - `${DATA_API_ROOT}/_indices`, - { body: JSON.stringify({ pattern }) } - ); - return indices; } export async function getESIndexFields({ @@ -61,11 +72,48 @@ export const getSavedObjectsClient = () => { return savedObjectsClient; }; -export const loadIndexPatterns = async () => { - const { savedObjects } = await getSavedObjectsClient().find({ - type: 'index-pattern', - fields: ['title'], - perPage: 10000, - }); - return savedObjects; +export const loadIndexPatterns = async (pattern: string) => { + let allSavedObjects = []; + const formattedPattern = formatPattern(pattern); + const perPage = 1000; + + try { + const { savedObjects, total } = await getSavedObjectsClient().find({ + type: 'index-pattern', + fields: ['title'], + page: 1, + search: formattedPattern, + perPage, + }); + + allSavedObjects = savedObjects; + + if (total > perPage) { + let currentPage = 2; + const numberOfPages = Math.ceil(total / perPage); + const promises = []; + + while (currentPage <= numberOfPages) { + promises.push( + getSavedObjectsClient().find({ + type: 'index-pattern', + page: currentPage, + fields: ['title'], + search: formattedPattern, + perPage, + }) + ); + currentPage++; + } + + const paginatedResults = await Promise.all(promises); + + allSavedObjects = paginatedResults.reduce((oldResult, result) => { + return oldResult.concat(result.savedObjects); + }, allSavedObjects); + } + return allSavedObjects.map((indexPattern: any) => indexPattern.attributes.title); + } catch (e) { + return []; + } }; From f8586a87d00082620436f18c5d258c1dfd7a2711 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Fri, 4 Mar 2022 14:02:09 -0800 Subject: [PATCH 14/20] fix for a skipped test `_scripted_fields_filter` (#126866) * fix for a skipped test * cleanup --- test/functional/apps/management/_scripted_fields_filter.js | 4 ++-- test/functional/page_objects/settings_page.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/test/functional/apps/management/_scripted_fields_filter.js b/test/functional/apps/management/_scripted_fields_filter.js index 117b8747c5a0a8..abae9a300994dc 100644 --- a/test/functional/apps/management/_scripted_fields_filter.js +++ b/test/functional/apps/management/_scripted_fields_filter.js @@ -16,8 +16,7 @@ export default function ({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['settings']); - // FLAKY: https://github.com/elastic/kibana/issues/126027 - describe.skip('filter scripted fields', function describeIndexTests() { + describe('filter scripted fields', function describeIndexTests() { before(async function () { // delete .kibana index and then wait for Kibana to re-create it await browser.setWindowSize(1200, 800); @@ -67,6 +66,7 @@ export default function ({ getService, getPageObjects }) { expect(lang).to.be('painless'); } }); + await PageObjects.settings.clearScriptedFieldLanguageFilter('painless'); await PageObjects.settings.setScriptedFieldLanguageFilter('expression'); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index b1e4aa823821b7..70cdbea7fa8970 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -288,7 +288,10 @@ export class SettingsPageObject extends FtrService { } async setScriptedFieldLanguageFilter(language: string) { - await this.testSubjects.clickWhenNotDisabled('scriptedFieldLanguageFilterDropdown'); + await this.retry.try(async () => { + await this.testSubjects.clickWhenNotDisabled('scriptedFieldLanguageFilterDropdown'); + return await this.find.byCssSelector('div.euiPopover__panel-isOpen'); + }); await this.testSubjects.existOrFail('scriptedFieldLanguageFilterDropdown-popover'); await this.testSubjects.existOrFail(`scriptedFieldLanguageFilterDropdown-option-${language}`); await this.testSubjects.click(`scriptedFieldLanguageFilterDropdown-option-${language}`); From 7af9c37016a373093c58aec5844ee040fcbcae72 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Fri, 4 Mar 2022 15:33:14 -0800 Subject: [PATCH 15/20] [Security Solution][Lists] - Hide exception list delete icon if Kibana read only (#126710) Addresses bug #126313 Even if user is given index privileges to lists, UI should follow Kibana privileges. Checks if user is a read only Kibana user and hides the delete icon from exception list view if true. --- .../exceptions/exceptions_table.spec.ts | 21 ++++++++++ .../rules/all/exceptions/columns.tsx | 5 ++- .../all/exceptions/exceptions_table.test.tsx | 42 ++++++++++++++----- .../rules/all/exceptions/exceptions_table.tsx | 14 +++++-- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts index 69bdafd5dccddd..d2578f91720335 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ROLES } from '../../../common/test'; import { getExceptionList, expectedExportedExceptionList } from '../../objects/exception'; import { getNewRule } from '../../objects/rule'; @@ -25,6 +26,7 @@ import { clearSearchSelection, } from '../../tasks/exceptions_table'; import { + EXCEPTIONS_TABLE_DELETE_BTN, EXCEPTIONS_TABLE_LIST_NAME, EXCEPTIONS_TABLE_SHOWING_LISTS, } from '../../screens/exceptions'; @@ -168,3 +170,22 @@ describe('Exceptions Table', () => { cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); }); }); + +describe('Exceptions Table - read only', () => { + before(() => { + // First we login as a privileged user to create exception list + cleanKibana(); + loginAndWaitForPageWithoutDateRange(EXCEPTIONS_URL, ROLES.platform_engineer); + createExceptionList(getExceptionList(), getExceptionList().list_id); + + // Then we login as read-only user to test. + loginAndWaitForPageWithoutDateRange(EXCEPTIONS_URL, ROLES.reader); + waitForExceptionsTableToBeLoaded(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + }); + + it('Delete icon is not shown', () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('not.exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx index 78feb911ee082f..33dff406734c99 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx @@ -27,7 +27,8 @@ export const getAllExceptionListsColumns = ( onExport: (arg: { id: string; listId: string; namespaceType: NamespaceType }) => () => void, onDelete: (arg: { id: string; listId: string; namespaceType: NamespaceType }) => () => void, formatUrl: FormatUrl, - navigateToUrl: (url: string) => Promise + navigateToUrl: (url: string) => Promise, + isKibanaReadOnly: boolean ): AllExceptionListsColumns[] => [ { align: 'left', @@ -155,7 +156,7 @@ export const getAllExceptionListsColumns = ( }, { render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => { - return listId === 'endpoint_list' ? ( + return listId === 'endpoint_list' || isKibanaReadOnly ? ( <> ) : ( ({ - useUserData: jest.fn().mockReturnValue([ - { - loading: false, - canUserCRUD: false, - }, - ]), -})); - describe('ExceptionListsTable', () => { const exceptionList1 = getExceptionListSchemaMock(); const exceptionList2 = { ...getExceptionListSchemaMock(), list_id: 'not_endpoint_list', id: '2' }; @@ -86,9 +79,17 @@ describe('ExceptionListsTable', () => { endpoint_list: exceptionList1, }, ]); + + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: false, + canUserREAD: false, + }, + ]); }); - it('does not render delete option disabled if list is "endpoint_list"', async () => { + it('does not render delete option if list is "endpoint_list"', async () => { const wrapper = mount( @@ -106,4 +107,25 @@ describe('ExceptionListsTable', () => { wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled') ).toBeFalsy(); }); + + it('does not render delete option if user is read only', async () => { + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: false, + canUserREAD: true, + }, + ]); + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsTableListId"]').at(1).text()).toEqual( + 'not_endpoint_list' + ); + expect(wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button')).toHaveLength(0); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 4a7c71a1084a7b..c40b6b95717241 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -60,7 +60,7 @@ const exceptionReferenceModalInitialState: ReferenceModalState = { export const ExceptionListsTable = React.memo(() => { const { formatUrl } = useFormatUrl(SecurityPageName.rules); - const [{ loading: userInfoLoading, canUserCRUD }] = useUserData(); + const [{ loading: userInfoLoading, canUserCRUD, canUserREAD }] = useUserData(); const hasPermissions = userHasPermissions(canUserCRUD); const { loading: listsConfigLoading } = useListsConfig(); @@ -193,8 +193,16 @@ export const ExceptionListsTable = React.memo(() => { ); const exceptionsColumns = useMemo((): AllExceptionListsColumns[] => { - return getAllExceptionListsColumns(handleExport, handleDelete, formatUrl, navigateToUrl); - }, [handleExport, handleDelete, formatUrl, navigateToUrl]); + // Defaulting to true to default to the lower privilege first + const isKibanaReadOnly = (canUserREAD && !canUserCRUD) ?? true; + return getAllExceptionListsColumns( + handleExport, + handleDelete, + formatUrl, + navigateToUrl, + isKibanaReadOnly + ); + }, [handleExport, handleDelete, formatUrl, navigateToUrl, canUserREAD, canUserCRUD]); const handleRefresh = useCallback((): void => { if (refreshExceptions != null) { From 23f7cff88a28fbff83aa466ba38351a649db48cd Mon Sep 17 00:00:00 2001 From: Or Ouziel Date: Sun, 6 Mar 2022 20:18:09 +0200 Subject: [PATCH 16/20] fix SO client bulkUpdate return type (#126349) --- ...kibana-plugin-core-public.savedobjectsclient.bulkupdate.md | 4 ++-- src/core/public/public.api.md | 2 +- src/core/public/saved_objects/saved_objects_client.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md index 0e3bfb2bd896b5..0cbfe4fcdead6e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md @@ -9,7 +9,7 @@ Update multiple documents at once Signature: ```typescript -bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; +bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; ``` ## Parameters @@ -20,7 +20,7 @@ bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): PromiseReturns: -Promise<SavedObjectsBatchResponse<unknown>> +Promise<SavedObjectsBatchResponse<T>> The result of the update operation containing both failed and updated saved objects. diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index e3f2822b5a7c8d..b30c009bf25384 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1128,7 +1128,7 @@ export class SavedObjectsClient { }>) => Promise<{ resolved_objects: ResolvedSimpleSavedObject[]; }>; - bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; + bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; // Warning: (ae-forgotten-export) The symbol "SavedObjectsDeleteOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "SavedObjectsClientContract" needs to be exported by the entry point index.d.ts diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index c19233809a94be..8509ace0476910 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -596,7 +596,7 @@ export class SavedObjectsClient { return renameKeys< PromiseType>, SavedObjectsBatchResponse - >({ saved_objects: 'savedObjects' }, resp) as SavedObjectsBatchResponse; + >({ saved_objects: 'savedObjects' }, resp) as SavedObjectsBatchResponse; }); } From 7ac836116313e458d793d8440f9cba7617ae4d14 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Mon, 7 Mar 2022 01:33:26 -0700 Subject: [PATCH 17/20] [Metrics UI] Update position of legend and it's controls (#115854) * [Metrics UI] Update position of legend and it's controls * updating button colors and moving history button back to the left * updating legend placement * removing unused dependencies * Adding data-test-subj for legendControls * removing unused deps * Fix linting errors * Move high value to top of legend * Reclaim top space left open by GroupNameContainer * Revert "Reclaim top space left open by GroupNameContainer" This reverts commit 411e89e01d99432714b042d0c2b0fcb248874ee2. This extra space is also serving as between-group margin. Also it doesn't solve the scrollbar overlap for multi-group cases. * Move legend after waffle map in dom This allows the waffle map to scroll without it overlapping the legend. * Move show/hide to right * Move timeline legend next to title * Move "hide history" button into timeline area * Revert "Move "hide history" button into timeline area" This reverts commit e6725c106faccdef505f1ffda4827c2fa8036111. * Revert "Move timeline legend next to title" This reverts commit 3d204d3e566d87da3e43c7e2ca9411490a560ced. * Revert "Move show/hide to right" This reverts commit fd1b9bd6571322d1560828d92f8644124b27729a. * Inline LegendControls and ViewSwitcher on mobile * Better legend alignment with action buttons Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kate Farrar Co-authored-by: Kate Farrar Co-authored-by: Mat Schaffer --- .../saved_views/toolbar_control.tsx | 1 + .../components/bottom_drawer.tsx | 17 +- .../inventory_view/components/layout.tsx | 323 +++++++++-------- .../components/nodes_overview.tsx | 7 + .../components/waffle/legend.tsx | 46 +-- .../components/waffle/legend_controls.tsx | 340 +++++++++--------- .../waffle/stepped_gradient_legend.tsx | 92 ++--- .../components/waffle/view_switcher.tsx | 4 +- 8 files changed, 398 insertions(+), 432 deletions(-) diff --git a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx index b44f3ffa20df71..7b7c256d5ad593 100644 --- a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx @@ -150,6 +150,7 @@ export function SavedViewsToolbarControls(props: Props) { data-test-subj="savedViews-openPopover" iconType="arrowDown" iconSide="right" + color="text" > {currentView ? currentView.name diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx index 3681d740d93d07..ad548a632573fd 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { useUiTracker } from '../../../../../../observability/public'; import { useWaffleOptionsContext } from '../hooks/use_waffle_options'; @@ -57,17 +57,6 @@ export const BottomDrawer: React.FC<{ {isOpen ? hideHistory : showHistory} - - {children} - - @@ -97,7 +86,3 @@ const BottomActionTopBar = euiStyled(EuiFlexGroup).attrs({ const ShowHideButton = euiStyled(EuiButtonEmpty).attrs({ size: 's' })` width: 140px; `; - -const RightSideSpacer = euiStyled(EuiSpacer).attrs({ size: 'xs' })` - width: 140px; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index 5a3dafaabbd170..7f3de57b610a4d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -17,8 +17,12 @@ import { calculateBoundsFromNodes } from '../lib/calculate_bounds_from_nodes'; import { PageContent } from '../../../../components/page'; import { useWaffleTimeContext } from '../hooks/use_waffle_time'; import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; -import { DEFAULT_LEGEND, useWaffleOptionsContext } from '../hooks/use_waffle_options'; -import { InfraFormatterType } from '../../../../lib/lib'; +import { + DEFAULT_LEGEND, + useWaffleOptionsContext, + WaffleLegendOptions, +} from '../hooks/use_waffle_options'; +import { InfraFormatterType, InfraWaffleMapBounds } from '../../../../lib/lib'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { Toolbar } from './toolbars/toolbar'; import { ViewSwitcher } from './waffle/view_switcher'; @@ -26,7 +30,7 @@ import { createInventoryMetricFormatter } from '../lib/create_inventory_metric_f import { createLegend } from '../lib/create_legend'; import { useWaffleViewState } from '../hooks/use_waffle_view_state'; import { BottomDrawer } from './bottom_drawer'; -import { Legend } from './waffle/legend'; +import { LegendControls } from './waffle/legend_controls'; interface Props { shouldLoadDefault: boolean; @@ -37,149 +41,184 @@ interface Props { loading: boolean; } -export const Layout = ({ - shouldLoadDefault, - currentView, - reload, - interval, - nodes, - loading, -}: Props) => { - const [showLoading, setShowLoading] = useState(true); - const { metric, groupBy, sort, nodeType, changeView, view, autoBounds, boundsOverride, legend } = - useWaffleOptionsContext(); - const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); - const { applyFilterQuery } = useWaffleFiltersContext(); - const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette; - const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps; - const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors; - - const options = { - formatter: InfraFormatterType.percent, - formatTemplate: '{{value}}', - legend: createLegend(legendPalette, legendSteps, legendReverseColors), - metric, - sort, - groupBy, - }; - - useInterval( - () => { - if (!loading) { - jumpToTime(Date.now()); - } - }, - isAutoReloading ? 5000 : null - ); - - const dataBounds = calculateBoundsFromNodes(nodes); - const bounds = autoBounds ? dataBounds : boundsOverride; - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const formatter = useCallback(createInventoryMetricFormatter(options.metric), [options.metric]); - const { onViewChange } = useWaffleViewState(); - - useEffect(() => { - if (currentView) { - onViewChange(currentView); - } - }, [currentView, onViewChange]); - - useEffect(() => { - // load snapshot data after default view loaded, unless we're not loading a view - if (currentView != null || !shouldLoadDefault) { - reload(); - } - - /** - * INFO: why disable exhaustive-deps - * We need to wait on the currentView not to be null because it is loaded async and could change the view state. - * We don't actually need to watch the value of currentView though, since the view state will be synched up by the - * changing params in the reload method so we should only "watch" the reload method. - * - * TODO: Should refactor this in the future to make it more clear where all the view state is coming - * from and it's precedence [query params, localStorage, defaultView, out of the box view] - */ +interface LegendControlOptions { + auto: boolean; + bounds: InfraWaffleMapBounds; + legend: WaffleLegendOptions; +} + +export const Layout = React.memo( + ({ shouldLoadDefault, currentView, reload, interval, nodes, loading }: Props) => { + const [showLoading, setShowLoading] = useState(true); + const { + metric, + groupBy, + sort, + nodeType, + changeView, + view, + autoBounds, + boundsOverride, + legend, + changeBoundsOverride, + changeAutoBounds, + changeLegend, + } = useWaffleOptionsContext(); + const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); + const { applyFilterQuery } = useWaffleFiltersContext(); + const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette; + const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps; + const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors; + + const options = { + formatter: InfraFormatterType.percent, + formatTemplate: '{{value}}', + legend: createLegend(legendPalette, legendSteps, legendReverseColors), + metric, + sort, + groupBy, + }; + + useInterval( + () => { + if (!loading) { + jumpToTime(Date.now()); + } + }, + isAutoReloading ? 5000 : null + ); + + const dataBounds = calculateBoundsFromNodes(nodes); + const bounds = autoBounds ? dataBounds : boundsOverride; /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [reload, shouldLoadDefault]); - - useEffect(() => { - setShowLoading(true); - }, [options.metric, nodeType]); - - useEffect(() => { - const hasNodes = nodes && nodes.length; - // Don't show loading screen when we're auto-reloading - setShowLoading(!hasNodes); - }, [nodes]); - - return ( - <> - - - {({ measureRef: pageMeasureRef, bounds: { width = 0 } }) => ( - - - {({ measureRef: topActionMeasureRef, bounds: { height: topActionHeight = 0 } }) => ( - <> - - - - - - - - - - {({ measureRef, bounds: { height = 0 } }) => ( - <> - - {view === 'map' && ( - { + if (currentView) { + onViewChange(currentView); + } + }, [currentView, onViewChange]); + + useEffect(() => { + // load snapshot data after default view loaded, unless we're not loading a view + if (currentView != null || !shouldLoadDefault) { + reload(); + } + + /** + * INFO: why disable exhaustive-deps + * We need to wait on the currentView not to be null because it is loaded async and could change the view state. + * We don't actually need to watch the value of currentView though, since the view state will be synched up by the + * changing params in the reload method so we should only "watch" the reload method. + * + * TODO: Should refactor this in the future to make it more clear where all the view state is coming + * from and it's precedence [query params, localStorage, defaultView, out of the box view] + */ + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [reload, shouldLoadDefault]); + + useEffect(() => { + setShowLoading(true); + }, [options.metric, nodeType]); + + useEffect(() => { + const hasNodes = nodes && nodes.length; + // Don't show loading screen when we're auto-reloading + setShowLoading(!hasNodes); + }, [nodes]); + + const handleLegendControlChange = useCallback( + (opts: LegendControlOptions) => { + changeBoundsOverride(opts.bounds); + changeAutoBounds(opts.auto); + changeLegend(opts.legend); + }, + [changeBoundsOverride, changeAutoBounds, changeLegend] + ); + + return ( + <> + + + {({ measureRef: pageMeasureRef, bounds: { width = 0 } }) => ( + + + {({ + measureRef: topActionMeasureRef, + bounds: { height: topActionHeight = 0 }, + }) => ( + <> + + + + + {view === 'map' && ( + + + + )} + + + + + + + + {({ measureRef, bounds: { height = 0 } }) => ( + <> + - + {view === 'map' && ( + - - )} - - )} - - - )} - - - )} - - - - ); -}; + )} + + )} + + + )} + + + )} + + + + ); + } +); const MainContainer = euiStyled.div` position: relative; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx index 297f24e95bc4f1..cec595e4be3d66 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx @@ -18,6 +18,7 @@ import { Map } from './waffle/map'; import { TableView } from './table_view'; import { SnapshotNode } from '../../../../../common/http_api/snapshot_api'; import { calculateBoundsFromNodes } from '../lib/calculate_bounds_from_nodes'; +import { Legend } from './waffle/legend'; export interface KueryFilterQuery { kind: 'kuery'; @@ -131,6 +132,12 @@ export const NodesOverview = ({ bottomMargin={bottomMargin} staticHeight={isStatic} /> + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx index d305203b738c37..853aa98bf62447 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { @@ -17,13 +17,7 @@ import { GradientLegendRT, } from '../../../../../lib/lib'; import { GradientLegend } from './gradient_legend'; -import { LegendControls } from './legend_controls'; import { StepLegend } from './steps_legend'; -import { - DEFAULT_LEGEND, - useWaffleOptionsContext, - WaffleLegendOptions, -} from '../../hooks/use_waffle_options'; import { SteppedGradientLegend } from './stepped_gradient_legend'; interface Props { legend: InfraWaffleMapLegend; @@ -32,39 +26,9 @@ interface Props { formatter: InfraFormatter; } -interface LegendControlOptions { - auto: boolean; - bounds: InfraWaffleMapBounds; - legend: WaffleLegendOptions; -} - -export const Legend: React.FC = ({ dataBounds, legend, bounds, formatter }) => { - const { - changeBoundsOverride, - changeAutoBounds, - autoBounds, - legend: legendOptions, - changeLegend, - boundsOverride, - } = useWaffleOptionsContext(); - const handleChange = useCallback( - (options: LegendControlOptions) => { - changeBoundsOverride(options.bounds); - changeAutoBounds(options.auto); - changeLegend(options.legend); - }, - [changeBoundsOverride, changeAutoBounds, changeLegend] - ); +export const Legend: React.FC = ({ legend, bounds, formatter }) => { return ( - {GradientLegendRT.is(legend) && ( )} @@ -77,8 +41,6 @@ export const Legend: React.FC = ({ dataBounds, legend, bounds, formatter }; const LegendContainer = euiStyled.div` - position: absolute; - bottom: 0px; - left: 10px; - right: 10px; + margin: 0 10px; + display: flex; `; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx index c7479434424a63..61b293888b85dc 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx @@ -26,7 +26,6 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { SyntheticEvent, useState, useCallback, useEffect } from 'react'; import { first, last } from 'lodash'; -import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { InfraWaffleMapBounds, InventoryColorPalette, PALETTES } from '../../../../../lib/lib'; import { WaffleLegendOptions } from '../../hooks/use_waffle_options'; import { getColorPalette } from '../../lib/get_color_palette'; @@ -78,8 +77,10 @@ export const LegendControls = ({ const buttonComponent = ( - - Legend Options - - - <> - - - - - - - + Legend Options + + + <> + - - + + + + + - + + + + + + + + + } + isInvalid={!boundsValidRange} + display="columnCompressed" + error={errors} + > +
+ - - - + + + } + isInvalid={!boundsValidRange} + error={errors} + > +
+ - - + + + + + + - } - isInvalid={!boundsValidRange} - display="columnCompressed" - error={errors} - > -
- + + + + -
- - - } - isInvalid={!boundsValidRange} - error={errors} - > -
- -
-
- - - - - - - - - - - - - - - - + +
+
+ + ); }; - -const ControlContainer = euiStyled.div` - position: absolute; - top: -20px; - right: 6px; - bottom: 0; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx index a9bcfa7995c200..339426b126b9e6 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { EuiText } from '@elastic/eui'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { InfraWaffleMapBounds, @@ -22,18 +23,19 @@ type TickValue = 0 | 1; export const SteppedGradientLegend: React.FC = ({ legend, bounds, formatter }) => { return ( - - - - + - {legend.rules.map((rule, index) => ( - - ))} + {legend.rules + .slice() + .reverse() + .map((rule, index) => ( + + ))} + ); }; @@ -46,62 +48,38 @@ interface TickProps { const TickLabel = ({ value, bounds, formatter }: TickProps) => { const normalizedValue = value === 0 ? bounds.min : bounds.max * value; - const style = { left: `${value * 100}%` }; const label = formatter(normalizedValue); - return {label}; + return ( +
+ {label} +
+ ); }; -const GradientStep = euiStyled.div` - height: ${(props) => props.theme.eui.paddingSizes.s}; - flex: 1 1 auto; - &:first-child { - border-radius: ${(props) => props.theme.eui.euiBorderRadius} 0 0 ${(props) => - props.theme.eui.euiBorderRadius}; - } - &:last-child { - border-radius: 0 ${(props) => props.theme.eui.euiBorderRadius} ${(props) => - props.theme.eui.euiBorderRadius} 0; - } +const LegendContainer = euiStyled.div` + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; `; -const Ticks = euiStyled.div` - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - top: -18px; +const GradientContainer = euiStyled.div` + height: 200px; + width: 10px; + display: flex; + flex-direction: column; + align-items: stretch; `; -const Tick = euiStyled.div` - position: absolute; - font-size: 11px; - text-align: center; - top: 0; - left: 0; - white-space: nowrap; - transform: translate(-50%, 0); +const GradientStep = euiStyled.div` + flex: 1 1 auto; &:first-child { - padding-left: 5px; - transform: translate(0, 0); + border-radius: ${(props) => props.theme.eui.euiBorderRadius} ${(props) => + props.theme.eui.euiBorderRadius} 0 0; } &:last-child { - padding-right: 5px; - transform: translate(-100%, 0); + border-radius: 0 0 ${(props) => props.theme.eui.euiBorderRadius} ${(props) => + props.theme.eui.euiBorderRadius}; } `; - -const GradientContainer = euiStyled.div` - display: flex; - flex-direction; row; - align-items: stretch; - flex-grow: 1; -`; - -const LegendContainer = euiStyled.div` - position: absolute; - height: 10px; - bottom: 0; - left: 0; - right: 40px; -`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx index 4dc288caa98332..8e911f7f829177 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx @@ -37,8 +37,8 @@ export const ViewSwitcher = ({ view, onChange }: Props) => { defaultMessage: 'Switch between table and map view', })} options={buttons} - color="primary" - buttonSize="m" + color="text" + buttonSize="s" idSelected={view} onChange={onChange} isIconOnly From 7c6d314cb0e91cde28db1c24b0acff21e447d839 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Mon, 7 Mar 2022 10:47:18 +0000 Subject: [PATCH 18/20] [Fleet] Retry Saved Object import on conflict error (#126900) * retry SO import on conflict errors * add jitter + increase retries * Apply suggestions from code review Co-authored-by: Josh Dover <1813008+joshdover@users.noreply.github.com> Co-authored-by: Josh Dover <1813008+joshdover@users.noreply.github.com> --- .../epm/kibana/assets/install.test.ts | 124 ++++++++++++++++++ .../services/epm/kibana/assets/install.ts | 51 +++++-- 2 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/epm/kibana/assets/install.test.ts diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.test.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.test.ts new file mode 100644 index 00000000000000..51aee45c83cf3d --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { + ISavedObjectsImporter, + SavedObjectsImportFailure, + SavedObjectsImportSuccess, + SavedObjectsImportResponse, +} from 'src/core/server'; + +import { loggingSystemMock } from '../../../../../../../../src/core/server/mocks'; + +import type { ArchiveAsset } from './install'; + +jest.mock('timers/promises', () => ({ + async setTimeout() {}, +})); + +import { installKibanaSavedObjects } from './install'; + +const mockLogger = loggingSystemMock.createLogger(); + +const mockImporter: jest.Mocked = { + import: jest.fn(), + resolveImportErrors: jest.fn(), +}; + +const createImportError = (so: ArchiveAsset, type: string) => + ({ id: so.id, error: { type } } as SavedObjectsImportFailure); +const createImportSuccess = (so: ArchiveAsset) => + ({ id: so.id, type: so.type, meta: {} } as SavedObjectsImportSuccess); +const createAsset = (asset: Partial) => + ({ id: 1234, type: 'dashboard', attributes: {}, ...asset } as ArchiveAsset); + +const createImportResponse = ( + errors: SavedObjectsImportFailure[] = [], + successResults: SavedObjectsImportSuccess[] = [] +) => + ({ + success: !!successResults.length, + errors, + successResults, + warnings: [], + successCount: successResults.length, + } as SavedObjectsImportResponse); + +describe('installKibanaSavedObjects', () => { + beforeEach(() => { + mockImporter.import.mockReset(); + mockImporter.resolveImportErrors.mockReset(); + }); + + it('should retry on conflict error', async () => { + const asset = createAsset({ attributes: { hello: 'world' } }); + const conflictResponse = createImportResponse([createImportError(asset, 'conflict')]); + const successResponse = createImportResponse([], [createImportSuccess(asset)]); + + mockImporter.import + .mockResolvedValueOnce(conflictResponse) + .mockResolvedValueOnce(successResponse); + + await installKibanaSavedObjects({ + savedObjectsImporter: mockImporter, + logger: mockLogger, + kibanaAssets: [asset], + }); + + expect(mockImporter.import).toHaveBeenCalledTimes(2); + }); + + it('should give up after 50 retries on conflict errors', async () => { + const asset = createAsset({ attributes: { hello: 'world' } }); + const conflictResponse = createImportResponse([createImportError(asset, 'conflict')]); + + mockImporter.import.mockImplementation(() => Promise.resolve(conflictResponse)); + + await expect( + installKibanaSavedObjects({ + savedObjectsImporter: mockImporter, + logger: mockLogger, + kibanaAssets: [asset], + }) + ).rejects.toEqual(expect.any(Error)); + expect(mockImporter.import).toHaveBeenCalledTimes(51); + }); + it('should not retry errors that arent conflict errors', async () => { + const asset = createAsset({ attributes: { hello: 'world' } }); + const errorResponse = createImportResponse([createImportError(asset, 'something_bad')]); + const successResponse = createImportResponse([], [createImportSuccess(asset)]); + + mockImporter.import.mockResolvedValueOnce(errorResponse).mockResolvedValueOnce(successResponse); + + expect( + installKibanaSavedObjects({ + savedObjectsImporter: mockImporter, + logger: mockLogger, + kibanaAssets: [asset], + }) + ).rejects.toEqual(expect.any(Error)); + }); + + it('should resolve reference errors', async () => { + const asset = createAsset({ attributes: { hello: 'world' } }); + const referenceErrorResponse = createImportResponse([ + createImportError(asset, 'missing_references'), + ]); + const successResponse = createImportResponse([], [createImportSuccess(asset)]); + + mockImporter.import.mockResolvedValueOnce(referenceErrorResponse); + mockImporter.resolveImportErrors.mockResolvedValueOnce(successResponse); + + await installKibanaSavedObjects({ + savedObjectsImporter: mockImporter, + logger: mockLogger, + kibanaAssets: [asset], + }); + + expect(mockImporter.import).toHaveBeenCalledTimes(1); + expect(mockImporter.resolveImportErrors).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index 5ab15a1f52e755..d654fab427f198 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { setTimeout } from 'timers/promises'; + import type { SavedObject, SavedObjectsBulkCreateObject, @@ -13,7 +15,6 @@ import type { Logger, } from 'src/core/server'; import type { SavedObjectsImportSuccess, SavedObjectsImportFailure } from 'src/core/server/types'; - import { createListStream } from '@kbn/utils'; import { partition } from 'lodash'; @@ -166,7 +167,40 @@ export async function getKibanaAssets( return result; } -async function installKibanaSavedObjects({ +const isImportConflictError = (e: SavedObjectsImportFailure) => e?.error?.type === 'conflict'; +/** + * retry saved object import if only conflict errors are encountered + */ +async function retryImportOnConflictError( + importCall: () => ReturnType, + { + logger, + maxAttempts = 50, + _attempt = 0, + }: { logger?: Logger; _attempt?: number; maxAttempts?: number } = {} +): ReturnType { + const result = await importCall(); + + const errors = result.errors ?? []; + if (_attempt < maxAttempts && errors.length && errors.every(isImportConflictError)) { + const retryCount = _attempt + 1; + const retryDelayMs = 1000 + Math.floor(Math.random() * 3000); // 1s + 0-3s of jitter + + logger?.debug( + `Retrying import operation after [${ + retryDelayMs * 1000 + }s] due to conflict errors: ${JSON.stringify(errors)}` + ); + + await setTimeout(retryDelayMs); + return retryImportOnConflictError(importCall, { logger, _attempt: retryCount }); + } + + return result; +} + +// only exported for testing +export async function installKibanaSavedObjects({ savedObjectsImporter, kibanaAssets, logger, @@ -185,18 +219,19 @@ async function installKibanaSavedObjects({ return []; } else { const { successResults: importSuccessResults = [], errors: importErrors = [] } = - await savedObjectsImporter.import({ - overwrite: true, - readStream: createListStream(toBeSavedObjects), - createNewCopies: false, - }); + await retryImportOnConflictError(() => + savedObjectsImporter.import({ + overwrite: true, + readStream: createListStream(toBeSavedObjects), + createNewCopies: false, + }) + ); allSuccessResults = importSuccessResults; const [referenceErrors, otherErrors] = partition( importErrors, (e) => e?.error?.type === 'missing_references' ); - if (otherErrors?.length) { throw new Error( `Encountered ${ From 3c9014737a9791bd9365b1fd0f880a5b5d8bdacf Mon Sep 17 00:00:00 2001 From: Muhammad Ibragimov <53621505+mibragimov@users.noreply.github.com> Date: Mon, 7 Mar 2022 16:14:31 +0500 Subject: [PATCH 19/20] [Console] Support auto-complete for data streams (#126235) * Support auto-complete for data streams * Add a test case Co-authored-by: Muhammad Ibragimov Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/components/settings_modal.tsx | 11 +++++++++ .../data_stream_autocomplete_component.js | 20 ++++++++++++++++ .../lib/autocomplete/components/index.js | 1 + src/plugins/console/public/lib/kb/kb.js | 4 ++++ .../public/lib/mappings/mapping.test.js | 9 +++++++ .../console/public/lib/mappings/mappings.js | 24 ++++++++++++++++--- .../console/public/services/settings.ts | 3 ++- .../generated/indices.delete_data_stream.json | 2 +- .../generated/indices.get_data_stream.json | 3 ++- 9 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js diff --git a/src/plugins/console/public/application/components/settings_modal.tsx b/src/plugins/console/public/application/components/settings_modal.tsx index c4be329dabcb88..eafc2dea3f8734 100644 --- a/src/plugins/console/public/application/components/settings_modal.tsx +++ b/src/plugins/console/public/application/components/settings_modal.tsx @@ -70,6 +70,7 @@ export function DevToolsSettingsModal(props: Props) { const [fields, setFields] = useState(props.settings.autocomplete.fields); const [indices, setIndices] = useState(props.settings.autocomplete.indices); const [templates, setTemplates] = useState(props.settings.autocomplete.templates); + const [dataStreams, setDataStreams] = useState(props.settings.autocomplete.dataStreams); const [polling, setPolling] = useState(props.settings.polling); const [pollInterval, setPollInterval] = useState(props.settings.pollInterval); const [tripleQuotes, setTripleQuotes] = useState(props.settings.tripleQuotes); @@ -97,12 +98,20 @@ export function DevToolsSettingsModal(props: Props) { }), stateSetter: setTemplates, }, + { + id: 'dataStreams', + label: i18n.translate('console.settingsPage.dataStreamsLabelText', { + defaultMessage: 'Data streams', + }), + stateSetter: setDataStreams, + }, ]; const checkboxIdToSelectedMap = { fields, indices, templates, + dataStreams, }; const onAutocompleteChange = (optionId: AutocompleteOptions) => { @@ -120,6 +129,7 @@ export function DevToolsSettingsModal(props: Props) { fields, indices, templates, + dataStreams, }, polling, pollInterval, @@ -170,6 +180,7 @@ export function DevToolsSettingsModal(props: Props) { fields, indices, templates, + dataStreams, }); }} > diff --git a/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js new file mode 100644 index 00000000000000..015136b7670f50 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getDataStreams } from '../../mappings/mappings'; +import { ListComponent } from './list_component'; + +export class DataStreamAutocompleteComponent extends ListComponent { + constructor(name, parent, multiValued) { + super(name, getDataStreams, parent, multiValued); + } + + getContextKey() { + return 'data_stream'; + } +} diff --git a/src/plugins/console/public/lib/autocomplete/components/index.js b/src/plugins/console/public/lib/autocomplete/components/index.js index 32078ee2c1519a..4a8838a6fb821f 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index.js +++ b/src/plugins/console/public/lib/autocomplete/components/index.js @@ -23,4 +23,5 @@ export { IdAutocompleteComponent } from './id_autocomplete_component'; export { UsernameAutocompleteComponent } from './username_autocomplete_component'; export { IndexTemplateAutocompleteComponent } from './index_template_autocomplete_component'; export { ComponentTemplateAutocompleteComponent } from './component_template_autocomplete_component'; +export { DataStreamAutocompleteComponent } from './data_stream_autocomplete_component'; export * from './legacy'; diff --git a/src/plugins/console/public/lib/kb/kb.js b/src/plugins/console/public/lib/kb/kb.js index 5f02365a48fdf9..e268f55be558e2 100644 --- a/src/plugins/console/public/lib/kb/kb.js +++ b/src/plugins/console/public/lib/kb/kb.js @@ -16,6 +16,7 @@ import { UsernameAutocompleteComponent, IndexTemplateAutocompleteComponent, ComponentTemplateAutocompleteComponent, + DataStreamAutocompleteComponent, } from '../autocomplete/components'; import $ from 'jquery'; @@ -94,6 +95,9 @@ const parametrizedComponentFactories = { component_template: function (name, parent) { return new ComponentTemplateAutocompleteComponent(name, parent); }, + data_stream: function (name, parent) { + return new DataStreamAutocompleteComponent(name, parent); + }, }; export function getUnmatchedEndpointComponents() { diff --git a/src/plugins/console/public/lib/mappings/mapping.test.js b/src/plugins/console/public/lib/mappings/mapping.test.js index 9191eb736be3c7..e2def74e892cc0 100644 --- a/src/plugins/console/public/lib/mappings/mapping.test.js +++ b/src/plugins/console/public/lib/mappings/mapping.test.js @@ -266,4 +266,13 @@ describe('Mappings', () => { expect(mappings.getIndexTemplates()).toEqual(expectedResult); expect(mappings.getComponentTemplates()).toEqual(expectedResult); }); + + test('Data streams', function () { + mappings.loadDataStreams({ + data_streams: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + expect(mappings.getDataStreams()).toEqual(expectedResult); + }); }); diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js index 75b8a263e8690c..96a5665e730a2b 100644 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ b/src/plugins/console/public/lib/mappings/mappings.js @@ -17,6 +17,7 @@ let perAliasIndexes = []; let legacyTemplates = []; let indexTemplates = []; let componentTemplates = []; +let dataStreams = []; const mappingObj = {}; @@ -60,6 +61,10 @@ export function getComponentTemplates() { return [...componentTemplates]; } +export function getDataStreams() { + return [...dataStreams]; +} + export function getFields(indices, types) { // get fields for indices and types. Both can be a list, a string or null (meaning all). let ret = []; @@ -128,7 +133,9 @@ export function getTypes(indices) { export function getIndices(includeAliases) { const ret = []; $.each(perIndexTypes, function (index) { - ret.push(index); + if (!index.startsWith('.ds')) { + ret.push(index); + } }); if (typeof includeAliases === 'undefined' ? true : includeAliases) { $.each(perAliasIndexes, function (alias) { @@ -204,6 +211,10 @@ export function loadComponentTemplates(data) { componentTemplates = (data.component_templates ?? []).map(({ name }) => name); } +export function loadDataStreams(data) { + dataStreams = (data.data_streams ?? []).map(({ name }) => name); +} + export function loadMappings(mappings) { perIndexTypes = {}; @@ -265,6 +276,7 @@ function retrieveSettings(settingsKey, settingsToRetrieve) { legacyTemplates: '_template', indexTemplates: '_index_template', componentTemplates: '_component_template', + dataStreams: '_data_stream', }; // Fetch autocomplete info if setting is set to true, and if user has made changes. @@ -326,14 +338,16 @@ export function retrieveAutoCompleteInfo(settings, settingsToRetrieve) { 'componentTemplates', templatesSettingToRetrieve ); + const dataStreamsPromise = retrieveSettings('dataStreams', settingsToRetrieve); $.when( mappingPromise, aliasesPromise, legacyTemplatesPromise, indexTemplatesPromise, - componentTemplatesPromise - ).done((mappings, aliases, legacyTemplates, indexTemplates, componentTemplates) => { + componentTemplatesPromise, + dataStreamsPromise + ).done((mappings, aliases, legacyTemplates, indexTemplates, componentTemplates, dataStreams) => { let mappingsResponse; try { if (mappings && mappings.length) { @@ -365,6 +379,10 @@ export function retrieveAutoCompleteInfo(settings, settingsToRetrieve) { loadComponentTemplates(JSON.parse(componentTemplates[0])); } + if (dataStreams) { + loadDataStreams(JSON.parse(dataStreams[0])); + } + if (mappings && aliases) { // Trigger an update event with the mappings, aliases $(mappingObj).trigger('update', [mappingsResponse, aliases[0]]); diff --git a/src/plugins/console/public/services/settings.ts b/src/plugins/console/public/services/settings.ts index 058f6c20c18887..1a7eff3e7ca540 100644 --- a/src/plugins/console/public/services/settings.ts +++ b/src/plugins/console/public/services/settings.ts @@ -14,7 +14,7 @@ export const DEFAULT_SETTINGS = Object.freeze({ pollInterval: 60000, tripleQuotes: true, wrapMode: true, - autocomplete: Object.freeze({ fields: true, indices: true, templates: true }), + autocomplete: Object.freeze({ fields: true, indices: true, templates: true, dataStreams: true }), historyDisabled: false, }); @@ -25,6 +25,7 @@ export interface DevToolsSettings { fields: boolean; indices: boolean; templates: boolean; + dataStreams: boolean; }; polling: boolean; pollInterval: number; diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_data_stream.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_data_stream.json index 9b91e3deb3a089..fb5cb446fb77e2 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_data_stream.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_data_stream.json @@ -13,7 +13,7 @@ "DELETE" ], "patterns": [ - "_data_stream/{name}" + "_data_stream/{data_stream}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/data-streams.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_data_stream.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_data_stream.json index 45199a60f337d6..e383a1df4844a6 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_data_stream.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_data_stream.json @@ -14,7 +14,8 @@ ], "patterns": [ "_data_stream", - "_data_stream/{name}" + "_data_stream/{name}", + "{data_stream}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/data-streams.html" } From c9e66b8327d3d3087bcd1788aed121977515fac0 Mon Sep 17 00:00:00 2001 From: CohenIdo <90558359+CohenIdo@users.noreply.github.com> Date: Mon, 7 Mar 2022 14:31:42 +0200 Subject: [PATCH 20/20] [Cloud Posture] add filterting for benchmark (#126980) --- .../routes/benchmarks/benchmarks.test.ts | 30 +++++++++++++++++++ .../server/routes/benchmarks/benchmarks.ts | 15 ++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts index b728948cf2a056..8c9d04dc207f3e 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts @@ -76,6 +76,18 @@ describe('benchmarks API', () => { }); }); + it('expect to find benchmark_name', async () => { + const validatedQuery = benchmarksInputSchema.validate({ + benchmark_name: 'my_cis_benchmark', + }); + + expect(validatedQuery).toMatchObject({ + page: 1, + per_page: DEFAULT_BENCHMARKS_PER_PAGE, + benchmark_name: 'my_cis_benchmark', + }); + }); + it('should throw when page field is not a positive integer', async () => { expect(() => { benchmarksInputSchema.validate({ page: -2 }); @@ -125,6 +137,24 @@ describe('benchmarks API', () => { }); }); + it('should format request by benchmark_name', async () => { + const mockAgentPolicyService = createPackagePolicyServiceMock(); + + await getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + benchmark_name: 'my_cis_benchmark', + }); + + expect(mockAgentPolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage AND ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.name: *my_cis_benchmark*`, + page: 1, + perPage: 100, + }) + ); + }); + describe('test getAgentPolicies', () => { it('should return one agent policy id when there is duplication', async () => { const agentPolicyService = createMockAgentPolicyService(); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts index 80c526c248c0ff..c52aeead6cd4da 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts @@ -43,8 +43,13 @@ export interface Benchmark { export const DEFAULT_BENCHMARKS_PER_PAGE = 20; export const PACKAGE_POLICY_SAVED_OBJECT_TYPE = 'ingest-package-policies'; -const getPackageNameQuery = (packageName: string): string => { - return `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageName}`; +const getPackageNameQuery = (packageName: string, benchmarkFilter?: string): string => { + const integrationNameQuery = `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageName}`; + const kquery = benchmarkFilter + ? `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageName} AND ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.name: *${benchmarkFilter}*` + : integrationNameQuery; + + return kquery; }; export const getPackagePolicies = async ( @@ -57,7 +62,7 @@ export const getPackagePolicies = async ( throw new Error('packagePolicyService is undefined'); } - const packageNameQuery = getPackageNameQuery(packageName); + const packageNameQuery = getPackageNameQuery(packageName, queryParams.benchmark_name); const { items: packagePolicies } = (await packagePolicyService?.list(soClient, { kuery: packageNameQuery, @@ -193,4 +198,8 @@ export const benchmarksInputSchema = rt.object({ * The number of objects to include in each page */ per_page: rt.number({ defaultValue: DEFAULT_BENCHMARKS_PER_PAGE, min: 0 }), + /** + * Benchmark filter + */ + benchmark_name: rt.maybe(rt.string()), });