diff --git a/.buildkite/package-lock.json b/.buildkite/package-lock.json index 04e3c73fdd2f5..d848470b3ff68 100644 --- a/.buildkite/package-lock.json +++ b/.buildkite/package-lock.json @@ -8,7 +8,7 @@ "name": "kibana-buildkite", "version": "1.0.0", "dependencies": { - "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#ef419d4f761dd256cb59bfab9b59f8b91029eb40" + "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#b9f6b423059cac7554a7402277f2ad3ecfe132a4" } }, "node_modules/@nodelib/fs.scandir": { @@ -355,8 +355,8 @@ }, "node_modules/kibana-buildkite-library": { "version": "1.0.0", - "resolved": "git+https://git@github.com/elastic/kibana-buildkite-library.git#ef419d4f761dd256cb59bfab9b59f8b91029eb40", - "integrity": "sha512-ou/Db/DAhyMeD0uQeLpJ/VfUCg0PGPssIYsr4gJZSTZoqxCDrMtE4nhtH8sXErHq0TaugdqergUhyhVHNBSJiA==", + "resolved": "git+https://git@github.com/elastic/kibana-buildkite-library.git#b9f6b423059cac7554a7402277f2ad3ecfe132a4", + "integrity": "sha512-HsSPeCrKwJKa+1urq/AzELmA1hsrwZjmOKzWzEYcQ63ZAnn8G3QWrGL0dNZQxIto3243EEs6Ne1pUVTMtEJr+Q==", "license": "MIT", "dependencies": { "@octokit/rest": "^18.10.0", @@ -801,9 +801,9 @@ } }, "kibana-buildkite-library": { - "version": "git+https://git@github.com/elastic/kibana-buildkite-library.git#ef419d4f761dd256cb59bfab9b59f8b91029eb40", - "integrity": "sha512-ou/Db/DAhyMeD0uQeLpJ/VfUCg0PGPssIYsr4gJZSTZoqxCDrMtE4nhtH8sXErHq0TaugdqergUhyhVHNBSJiA==", - "from": "kibana-buildkite-library@git+https://git@github.com/elastic/kibana-buildkite-library#ef419d4f761dd256cb59bfab9b59f8b91029eb40", + "version": "git+https://git@github.com/elastic/kibana-buildkite-library.git#b9f6b423059cac7554a7402277f2ad3ecfe132a4", + "integrity": "sha512-HsSPeCrKwJKa+1urq/AzELmA1hsrwZjmOKzWzEYcQ63ZAnn8G3QWrGL0dNZQxIto3243EEs6Ne1pUVTMtEJr+Q==", + "from": "kibana-buildkite-library@git+https://git@github.com/elastic/kibana-buildkite-library#b9f6b423059cac7554a7402277f2ad3ecfe132a4", "requires": { "@octokit/rest": "^18.10.0", "axios": "^0.21.4", diff --git a/.buildkite/package.json b/.buildkite/package.json index daff8bd5db781..4e46ba6637027 100644 --- a/.buildkite/package.json +++ b/.buildkite/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "dependencies": { - "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#ef419d4f761dd256cb59bfab9b59f8b91029eb40" + "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#b9f6b423059cac7554a7402277f2ad3ecfe132a4" } } diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.js b/.buildkite/scripts/pipelines/pull_request/pipeline.js index 35ea19c78cd93..6a4610284e400 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.js +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.js @@ -9,19 +9,7 @@ const execSync = require('child_process').execSync; const fs = require('fs'); const { areChangesSkippable, doAnyChangesMatch } = require('kibana-buildkite-library'); - -const SKIPPABLE_PATHS = [ - /^docs\//, - /^rfcs\//, - /^.ci\/.+\.yml$/, - /^.ci\/es-snapshots\//, - /^.ci\/pipeline-library\//, - /^.ci\/Jenkinsfile_[^\/]+$/, - /^\.github\//, - /\.md$/, - /^\.backportrc\.json$/, - /^nav-kibana-dev\.docnav\.json$/, -]; +const { SKIPPABLE_PR_MATCHERS } = require('./skippable_pr_matchers'); const REQUIRED_PATHS = [ // this file is auto-generated and changes to it need to be validated with CI @@ -47,7 +35,7 @@ const uploadPipeline = (pipelineContent) => { (async () => { try { - const skippable = await areChangesSkippable(SKIPPABLE_PATHS, REQUIRED_PATHS); + const skippable = await areChangesSkippable(SKIPPABLE_PR_MATCHERS, REQUIRED_PATHS); if (skippable) { console.log('All changes in PR are skippable. Skipping CI.'); diff --git a/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js b/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js new file mode 100644 index 0000000000000..2a36e66e11cd6 --- /dev/null +++ b/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + SKIPPABLE_PR_MATCHERS: [ + /^docs\//, + /^rfcs\//, + /^.ci\/.+\.yml$/, + /^.ci\/es-snapshots\//, + /^.ci\/pipeline-library\//, + /^.ci\/Jenkinsfile_[^\/]+$/, + /^\.github\//, + /\.md$/, + /^\.backportrc\.json$/, + /^nav-kibana-dev\.docnav\.json$/, + /^src\/dev\/prs\/kibana_qa_pr_list\.json$/, + /^\.buildkite\/scripts\/pipelines\/pull_request\/skippable_pr_matchers\.js$/, + ], +}; diff --git a/docs/api/actions-and-connectors/legacy/index.asciidoc b/docs/api/actions-and-connectors/legacy/index.asciidoc index 859dd652de984..66ecb2ed31119 100644 --- a/docs/api/actions-and-connectors/legacy/index.asciidoc +++ b/docs/api/actions-and-connectors/legacy/index.asciidoc @@ -1,4 +1,4 @@ [[actions-and-connectors-legacy-apis]] === Deprecated 7.x APIs -These APIs are deprecated and will be removed as of 8.0. +These APIs are deprecated and will be removed in a future release. diff --git a/docs/api/alerting/legacy/index.asciidoc b/docs/api/alerting/legacy/index.asciidoc index cce2c378bdb58..48f37c06ff543 100644 --- a/docs/api/alerting/legacy/index.asciidoc +++ b/docs/api/alerting/legacy/index.asciidoc @@ -1,7 +1,7 @@ [[alerts-api]] === Deprecated 7.x APIs -These APIs are deprecated and will be removed as of 8.0. +These APIs are deprecated and will be removed in a future release. include::create.asciidoc[leveloffset=+1] include::delete.asciidoc[leveloffset=+1] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index aa5d9f53359b7..95003a08b7b09 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -187,37 +187,37 @@ For example, `20m`, `24h`, `7d`, `1w`. Default: `60s`. [[alert-settings]] ==== Alerting settings -`xpack.alerting.maxEphemeralActionsPerAlert`:: +`xpack.alerting.maxEphemeralActionsPerAlert` {ess-icon}:: Sets the number of actions that will run ephemerally. To use this, enable ephemeral tasks in task manager first with <> -`xpack.alerting.cancelAlertsOnRuleTimeout`:: +`xpack.alerting.cancelAlertsOnRuleTimeout` {ess-icon}:: Specifies whether to skip writing alerts and scheduling actions if rule processing was cancelled due to a timeout. Default: `true`. This setting can be overridden by individual rule types. -`xpack.alerting.rules.minimumScheduleInterval.value`:: +`xpack.alerting.rules.minimumScheduleInterval.value` {ess-icon}:: Specifies the minimum schedule interval for rules. This minimum is applied to all rules created or updated after you set this value. The time is formatted as: + `[s,m,h,d]` + For example, `20m`, `24h`, `7d`. This duration cannot exceed `1d`. Default: `1m`. -`xpack.alerting.rules.minimumScheduleInterval.enforce`:: +`xpack.alerting.rules.minimumScheduleInterval.enforce` {ess-icon}:: Specifies the behavior when a new or changed rule has a schedule interval less than the value defined in `xpack.alerting.rules.minimumScheduleInterval.value`. If `false`, rules with schedules less than the interval will be created but warnings will be logged. If `true`, rules with schedules less than the interval cannot be created. Default: `false`. -`xpack.alerting.rules.run.actions.max`:: +`xpack.alerting.rules.run.actions.max` {ess-icon}:: Specifies the maximum number of actions that a rule can generate each time detection checks run. -`xpack.alerting.rules.run.timeout`:: +`xpack.alerting.rules.run.timeout` {ess-icon}:: Specifies the default timeout for tasks associated with all types of rules. The time is formatted as: + `[ms,s,m,h,d,w,M,Y]` + For example, `20m`, `24h`, `7d`, `1w`. Default: `5m`. -`xpack.alerting.rules.run.ruleTypeOverrides`:: +`xpack.alerting.rules.run.ruleTypeOverrides` {ess-icon}:: Overrides the configs under `xpack.alerting.rules.run` for the rule type with the given ID. List the rule identifier and its settings in an array of objects. + For example: @@ -230,7 +230,7 @@ xpack.alerting.rules.run: timeout: '15m' -- -`xpack.alerting.rules.run.actions.connectorTypeOverrides`:: +`xpack.alerting.rules.run.actions.connectorTypeOverrides` {ess-icon}:: Overrides the configs under `xpack.alerting.rules.run.actions` for the connector type with the given ID. List the connector type identifier and its settings in an array of objects. + For example: diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 9f73dcd620d30..9baed7a92a53e 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -57,7 +57,7 @@ pageLoadAssetSize: telemetry: 51957 telemetryManagementSection: 38586 transform: 41007 - triggersActionsUi: 103400 + triggersActionsUi: 104400 upgradeAssistant: 81241 urlForwarding: 32579 usageCollection: 39762 diff --git a/src/dev/prs/kibana_qa_pr_list.json b/src/dev/prs/kibana_qa_pr_list.json index e8d27ba9f2f0a..503c95d2e7c0f 100644 --- a/src/dev/prs/kibana_qa_pr_list.json +++ b/src/dev/prs/kibana_qa_pr_list.json @@ -89,8 +89,10 @@ "Feature:Observability Landing - Milestone 1", "Feature:Osquery", "Feature:Transforms", +"Feature:Unified Integrations", "Synthetics", "Team: AWL: Platform", +"Team: AWP: Visualization", "Team: Actionable Observability", "Team: CTI", "Team: SecuritySolution", @@ -109,6 +111,7 @@ "Team:Infra Monitoring UI", "Team:Ingest Management", "Team:Observability", +"Team:Unified observability", "Team:Onboarding and Lifecycle Mgt", "Team:Operations", "Team:QA", diff --git a/src/plugins/data/public/search/session/sessions_mgmt/components/index.tsx b/src/plugins/data/public/search/session/sessions_mgmt/components/index.tsx index c9ba988e1330b..05eb03fd60ddf 100644 --- a/src/plugins/data/public/search/session/sessions_mgmt/components/index.tsx +++ b/src/plugins/data/public/search/session/sessions_mgmt/components/index.tsx @@ -15,7 +15,7 @@ export { PopoverActionsMenu } from './actions'; export const TableText = ({ children, ...props }: EuiTextProps) => { return ( - + {children} ); diff --git a/src/plugins/data/server/search/strategies/common/async_utils.test.ts b/src/plugins/data/server/search/strategies/common/async_utils.test.ts new file mode 100644 index 0000000000000..7c90a0fd4c124 --- /dev/null +++ b/src/plugins/data/server/search/strategies/common/async_utils.test.ts @@ -0,0 +1,110 @@ +/* + * 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 { getCommonDefaultAsyncSubmitParams, getCommonDefaultAsyncGetParams } from './async_utils'; +import moment from 'moment'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +const getMockSearchSessionsConfig = ({ + enabled = true, + defaultExpiration = moment.duration(7, 'd'), +} = {}) => + ({ + enabled, + defaultExpiration, + } as SearchSessionsConfigSchema); + +describe('request utils', () => { + describe('getCommonDefaultAsyncSubmitParams', () => { + test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_alive` from config if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '259200000ms'); + }); + + test('Uses `keepAlive` of `1m` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_on_completion` if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({}); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', true); + }); + + test('Does not use `keep_on_completion` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', false); + }); + }); + + describe('getCommonDefaultAsyncGetParams', () => { + test('Uses `wait_for_completion_timeout`', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getCommonDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('wait_for_completion_timeout'); + }); + + test('Uses `keep_alive` if `sessionId` is not provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getCommonDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Has no `keep_alive` if `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getCommonDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).not.toHaveProperty('keep_alive'); + }); + + test('Uses `keep_alive` if `sessionId` is provided but sessions disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getCommonDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/common/async_utils.ts b/src/plugins/data/server/search/strategies/common/async_utils.ts new file mode 100644 index 0000000000000..46483ca3f3279 --- /dev/null +++ b/src/plugins/data/server/search/strategies/common/async_utils.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 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 { + AsyncSearchSubmitRequest, + AsyncSearchGetRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import { SearchSessionsConfigSchema } from '../../../../config'; +import { ISearchOptions } from '../../../../common'; + +/** + @internal + */ +export function getCommonDefaultAsyncSubmitParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick< + AsyncSearchSubmitRequest, + 'keep_alive' | 'wait_for_completion_timeout' | 'keep_on_completion' +> { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + const keepAlive = useSearchSessions + ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` + : '1m'; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + // If search sessions are used, store and get an async ID even for short running requests. + keep_on_completion: useSearchSessions, + // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. + keep_alive: keepAlive, + }; +} + +/** + @internal + */ +export function getCommonDefaultAsyncGetParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + ...(useSearchSessions + ? // Don't change the expiration of search requests that are tracked in a search session + undefined + : { + // We still need to do polling for searches not within the context of a search session or when search session disabled + keep_alive: '1m', + }), + }; +} diff --git a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts index 33c6f387d6506..13b4295fb7c63 100644 --- a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts @@ -19,7 +19,8 @@ import { toEqlKibanaSearchResponse } from './response_utils'; import { EqlSearchResponse } from './types'; import { ISearchStrategy } from '../../types'; import { getDefaultSearchParams } from '../es_search'; -import { getDefaultAsyncGetParams, getIgnoreThrottled } from '../ese_search/request_utils'; +import { getIgnoreThrottled } from '../ese_search/request_utils'; +import { getCommonDefaultAsyncGetParams } from '../common/async_utils'; export const eqlSearchStrategyProvider = ( logger: Logger @@ -45,11 +46,11 @@ export const eqlSearchStrategyProvider = ( uiSettingsClient ); const params = id - ? getDefaultAsyncGetParams(null, options) + ? getCommonDefaultAsyncGetParams(null, options) : { ...(await getIgnoreThrottled(uiSettingsClient)), ...defaultParams, - ...getDefaultAsyncGetParams(null, options), + ...getCommonDefaultAsyncGetParams(null, options), ...request.params, }; const response = id diff --git a/src/plugins/data/server/search/strategies/ese_search/request_utils.ts b/src/plugins/data/server/search/strategies/ese_search/request_utils.ts index ea850c80f90b3..07f1c9d1ae9a5 100644 --- a/src/plugins/data/server/search/strategies/ese_search/request_utils.ts +++ b/src/plugins/data/server/search/strategies/ese_search/request_utils.ts @@ -12,6 +12,10 @@ import { AsyncSearchSubmitRequest } from '@elastic/elasticsearch/lib/api/types'; import { ISearchOptions, UI_SETTINGS } from '../../../../common'; import { getDefaultSearchParams } from '../es_search'; import { SearchSessionsConfigSchema } from '../../../../config'; +import { + getCommonDefaultAsyncGetParams, + getCommonDefaultAsyncSubmitParams, +} from '../common/async_utils'; /** * @internal @@ -43,23 +47,10 @@ export async function getDefaultAsyncSubmitParams( | 'keep_on_completion' > > { - const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; - - // TODO: searchSessionsConfig could be "null" if we are running without x-pack which happens only in tests. - // This can be cleaned up when we completely stop separating basic and oss - const keepAlive = useSearchSessions - ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` - : '1m'; - return { // TODO: adjust for partial results batched_reduce_size: 64, - // Wait up to 100ms for the response to return - wait_for_completion_timeout: '100ms', - // If search sessions are used, store and get an async ID even for short running requests. - keep_on_completion: useSearchSessions, - // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. - keep_alive: keepAlive, + ...getCommonDefaultAsyncSubmitParams(searchSessionsConfig, options), ...(await getIgnoreThrottled(uiSettingsClient)), ...(await getDefaultSearchParams(uiSettingsClient)), // If search sessions are used, set the initial expiration time. @@ -73,17 +64,7 @@ export function getDefaultAsyncGetParams( searchSessionsConfig: SearchSessionsConfigSchema | null, options: ISearchOptions ): Pick { - const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; - return { - // Wait up to 100ms for the response to return - wait_for_completion_timeout: '100ms', - ...(useSearchSessions - ? // Don't change the expiration of search requests that are tracked in a search session - undefined - : { - // We still need to do polling for searches not within the context of a search session or when search session disabled - keep_alive: '1m', - }), + ...getCommonDefaultAsyncGetParams(searchSessionsConfig, options), }; } diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts index d05b2710b07ea..de8ced65d16c6 100644 --- a/src/plugins/data/server/search/strategies/sql_search/request_utils.ts +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts @@ -9,6 +9,10 @@ import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; import { ISearchOptions } from '../../../../common'; import { SearchSessionsConfigSchema } from '../../../../config'; +import { + getCommonDefaultAsyncGetParams, + getCommonDefaultAsyncSubmitParams, +} from '../common/async_utils'; /** @internal @@ -17,19 +21,8 @@ export function getDefaultAsyncSubmitParams( searchSessionsConfig: SearchSessionsConfigSchema | null, options: ISearchOptions ): Pick { - const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; - - const keepAlive = useSearchSessions - ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` - : '1m'; - return { - // Wait up to 100ms for the response to return - wait_for_completion_timeout: '100ms', - // If search sessions are used, store and get an async ID even for short running requests. - keep_on_completion: useSearchSessions, - // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. - keep_alive: keepAlive, + ...getCommonDefaultAsyncSubmitParams(searchSessionsConfig, options), }; } @@ -40,17 +33,7 @@ export function getDefaultAsyncGetParams( searchSessionsConfig: SearchSessionsConfigSchema | null, options: ISearchOptions ): Pick { - const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; - return { - // Wait up to 100ms for the response to return - wait_for_completion_timeout: '100ms', - ...(useSearchSessions - ? // Don't change the expiration of search requests that are tracked in a search session - undefined - : { - // We still need to do polling for searches not within the context of a search session or when search session disabled - keep_alive: '1m', - }), + ...getCommonDefaultAsyncGetParams(searchSessionsConfig, options), }; } diff --git a/src/plugins/discover/public/components/discover_grid/constants.ts b/src/plugins/discover/public/components/discover_grid/constants.ts index d026607aef373..f2f5a8e8bebc7 100644 --- a/src/plugins/discover/public/components/discover_grid/constants.ts +++ b/src/plugins/discover/public/components/discover_grid/constants.ts @@ -19,7 +19,7 @@ export const GRID_STYLE = { export const pageSizeArr = [25, 50, 100, 250]; export const defaultPageSize = 100; -export const defaultTimeColumnWidth = 190; +export const defaultTimeColumnWidth = 210; export const toolbarVisibility = { showColumnSelector: { allowHide: false, diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.scss b/src/plugins/discover/public/components/discover_grid/discover_grid.scss index 0204433a5ba1c..113bb60924850 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.scss +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.scss @@ -30,6 +30,15 @@ } } +.dscDiscoverGrid__cellValue { + font-family: $euiCodeFontFamily; +} + +.dscDiscoverGrid__cellPopoverValue { + font-family: $euiCodeFontFamily; + font-size: $euiFontSizeS; +} + .dscDiscoverGrid__footer { background-color: $euiColorLightShade; padding: $euiSize / 2 $euiSize; diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx index a9116e616946f..c98db31a97f7f 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx @@ -207,7 +207,7 @@ describe('Discover grid columns', function () { /> , "id": "timestamp", - "initialWidth": 190, + "initialWidth": 210, "isSortable": true, "schema": "datetime", }, diff --git a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx index be4c69f1ced25..53e5c23cb47d5 100644 --- a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx @@ -92,7 +92,9 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(`"100"`); + expect(component.html()).toMatchInlineSnapshot( + `"100"` + ); }); it('renders bytes column correctly using _source when details is true', () => { @@ -115,7 +117,9 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(`"100"`); + expect(component.html()).toMatchInlineSnapshot( + `"100"` + ); }); it('renders bytes column correctly using fields when details is true', () => { @@ -138,7 +142,9 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(`"100"`); + expect(component.html()).toMatchInlineSnapshot( + `"100"` + ); }); it('renders _source column correctly', () => { @@ -163,7 +169,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -280,7 +286,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -359,7 +365,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -485,7 +491,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -527,7 +533,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -603,6 +609,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` ); - expect(component.html()).toMatchInlineSnapshot(`"-"`); + expect(component.html()).toMatchInlineSnapshot( + `"-"` + ); }); it('renders correctly when invalid column is given', () => { @@ -657,7 +666,9 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(`"-"`); + expect(component.html()).toMatchInlineSnapshot( + `"-"` + ); }); it('renders unmapped fields correctly', () => { @@ -695,6 +706,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` -; + return -; } /** @@ -102,7 +105,11 @@ export const getRenderCellValueFn = : formatHit(row, dataView, fieldsToShow, maxEntries, fieldFormats); return ( - + {pairs.map(([key, value]) => ( {key} @@ -118,6 +125,7 @@ export const getRenderCellValueFn = return ( = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:enableNewSyntheticsView': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'observability:maxSuggestions': { type: 'integer', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index b9d50f888fa93..718f75b80a77d 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -36,6 +36,7 @@ export interface UsageStats { 'discover:maxDocFieldsDisplayed': number; 'securitySolution:rulesTableRefresh': string; 'observability:enableInspectEsQueries': boolean; + 'observability:enableNewSyntheticsView': boolean; 'observability:maxSuggestions': number; 'observability:enableComparisonByDefault': boolean; 'observability:enableInfrastructureView': boolean; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 59d7ba693156d..0c0cafad6bec6 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -202,29 +202,29 @@ } } }, - "search": { + "search-session": { "properties": { - "successCount": { + "transientCount": { "type": "long" }, - "errorCount": { + "persistedCount": { "type": "long" }, - "averageDuration": { - "type": "float" + "totalCount": { + "type": "long" } } }, - "search-session": { + "search": { "properties": { - "transientCount": { + "successCount": { "type": "long" }, - "persistedCount": { + "errorCount": { "type": "long" }, - "totalCount": { - "type": "long" + "averageDuration": { + "type": "float" } } }, @@ -8133,6 +8133,12 @@ "description": "Non-default value of setting." } }, + "observability:enableNewSyntheticsView": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "observability:maxSuggestions": { "type": "integer", "_meta": { diff --git a/src/plugins/unified_search/public/search_bar/index.tsx b/src/plugins/unified_search/public/search_bar/index.tsx index f8c9de7ec7d87..40421a50a5fe2 100644 --- a/src/plugins/unified_search/public/search_bar/index.tsx +++ b/src/plugins/unified_search/public/search_bar/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { injectI18n } from '@kbn/i18n-react'; import { withKibana } from '@kbn/kibana-react-plugin/public'; import type { SearchBarProps } from './search_bar'; +import '../index.scss'; const Fallback = () =>
; diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index ab59511ea6811..a684e5ba928a8 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -31,8 +31,6 @@ import QueryBarTopRow from '../query_string_input/query_bar_top_row'; import { FilterBar, FilterItems } from '../filter_bar'; import { searchBarStyles } from './search_bar.styles'; -import '../index.scss'; - export interface SearchBarInjectedDeps { kibana: KibanaReactContextValue; intl: InjectedIntl; diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index fbf6b96b3136d..f96e4088da78f 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -374,11 +374,13 @@ export class VisualBuilderPageObject extends FtrService { } public async getTopNLabel() { + await this.visChart.waitForVisualizationRenderingStabilized(); const topNLabel = await this.find.byCssSelector('.tvbVisTopN__label'); return await topNLabel.getVisibleText(); } public async getTopNCount() { + await this.visChart.waitForVisualizationRenderingStabilized(); const gaugeCount = await this.find.byCssSelector('.tvbVisTopN__value'); return await gaugeCount.getVisibleText(); } diff --git a/x-pack/plugins/actions/server/actions_client.mock.ts b/x-pack/plugins/actions/server/actions_client.mock.ts index 419babe97c0f4..246c8fa35fc15 100644 --- a/x-pack/plugins/actions/server/actions_client.mock.ts +++ b/x-pack/plugins/actions/server/actions_client.mock.ts @@ -19,6 +19,7 @@ const createActionsClientMock = () => { update: jest.fn(), getAll: jest.fn(), getBulk: jest.fn(), + getOAuthAccessToken: jest.fn(), execute: jest.fn(), enqueueExecution: jest.fn(), ephemeralEnqueuedExecution: jest.fn(), diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index afee13b8c9bca..787b4e450a9e0 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -18,11 +18,14 @@ import { actionsConfigMock } from './actions_config.mock'; import { getActionsConfigurationUtilities } from './actions_config'; import { licenseStateMock } from './lib/license_state.mock'; import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; -import { httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { + httpServerMock, + loggingSystemMock, + elasticsearchServiceMock, + savedObjectsClientMock, +} from '@kbn/core/server/mocks'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; - -import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import { actionExecutorMock } from './lib/action_executor.mock'; import uuid from 'uuid'; import { ActionsAuthorization } from './authorization/actions_authorization'; @@ -37,6 +40,9 @@ import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/s import { Logger } from '@kbn/core/server'; import { connectorTokenClientMock } from './builtin_action_types/lib/connector_token_client.mock'; import { inMemoryMetricsMock } from './monitoring/in_memory_metrics.mock'; +import { getOAuthJwtAccessToken } from './builtin_action_types/lib/get_oauth_jwt_access_token'; +import { getOAuthClientCredentialsAccessToken } from './builtin_action_types/lib/get_oauth_client_credentials_access_token'; +import { OAuthParams } from './routes/get_oauth_access_token'; jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({ SavedObjectsUtils: { @@ -60,6 +66,13 @@ jest.mock('./authorization/get_authorization_mode_by_source', () => { }; }); +jest.mock('./builtin_action_types/lib/get_oauth_jwt_access_token', () => ({ + getOAuthJwtAccessToken: jest.fn(), +})); +jest.mock('./builtin_action_types/lib/get_oauth_client_credentials_access_token', () => ({ + getOAuthClientCredentialsAccessToken: jest.fn(), +})); + const defaultKibanaIndex = '.kibana'; const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -73,6 +86,7 @@ const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); const logger = loggingSystemMock.create().get() as jest.Mocked; const mockTaskManager = taskManagerMock.createSetup(); +const configurationUtilities = actionsConfigMock.create(); let actionsClient: ActionsClient; let mockedLicenseState: jest.Mocked; @@ -115,6 +129,10 @@ beforeEach(() => { usageCounter: mockUsageCounter, connectorTokenClient, }); + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValue(`Bearer jwttokentokentoken`); + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValue( + `Bearer clienttokentokentoken` + ); }); describe('create()', () => { @@ -1274,6 +1292,292 @@ describe('getBulk()', () => { }); }); +describe('getOAuthAccessToken()', () => { + function getOAuthAccessToken( + requestBody: OAuthParams + ): ReturnType { + actionsClient = new ActionsClient({ + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + defaultKibanaIndex, + actionExecutor, + executionEnqueuer, + ephemeralExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + preconfiguredActions: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + isDeprecated: false, + name: 'test', + config: { + foo: 'bar', + }, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + }); + return actionsClient.getOAuthAccessToken(requestBody, logger, configurationUtilities); + } + + describe('authorization', () => { + test('ensures user is authorised to get the type of action', async () => { + await getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error(`Unauthorized to update actions`)); + + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to update actions]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + }); + + test('throws when tokenUrl is not using http or https', async () => { + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'ftp://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Token URL must use http or https]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + + test('throws when tokenUrl does not contain hostname', async () => { + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: '/path/to/myfile', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Token URL must contain hostname]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + + test('throws when tokenUrl is not in allowed hosts', async () => { + configurationUtilities.ensureUriAllowed.mockImplementationOnce(() => { + throw new Error('URI not allowed'); + }); + + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: URI not allowed]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + expect(configurationUtilities.ensureUriAllowed).toHaveBeenCalledWith( + `https://testurl.service-now.com/oauth_token.do` + ); + }); + + test('calls getOAuthJwtAccessToken when type="jwt"', async () => { + const result = await getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }); + expect(result).toEqual({ + accessToken: 'Bearer jwttokentokentoken', + }); + expect(getOAuthJwtAccessToken as jest.Mock).toHaveBeenCalledWith({ + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + }); + expect(getOAuthClientCredentialsAccessToken).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `Successfully retrieved access token using JWT OAuth with tokenUrl https://testurl.service-now.com/oauth_token.do and config {\"clientId\":\"abc\",\"jwtKeyId\":\"def\",\"userIdentifierValue\":\"userA\"}` + ); + }); + + test('calls getOAuthClientCredentialsAccessToken when type="client"', async () => { + const result = await getOAuthAccessToken({ + type: 'client', + options: { + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + }); + expect(result).toEqual({ + accessToken: 'Bearer clienttokentokentoken', + }); + expect(getOAuthClientCredentialsAccessToken as jest.Mock).toHaveBeenCalledWith({ + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + oAuthScope: 'https://graph.microsoft.com/.default', + }); + expect(getOAuthJwtAccessToken).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `Successfully retrieved access token using Client Credentials OAuth with tokenUrl https://login.microsoftonline.com/98765/oauth2/v2.0/token, scope https://graph.microsoft.com/.default and config {\"clientId\":\"abc\",\"tenantId\":\"def\"}` + ); + }); + + test('throws when getOAuthJwtAccessToken throws error', async () => { + (getOAuthJwtAccessToken as jest.Mock).mockRejectedValue(new Error(`Something went wrong!`)); + + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Failed to retrieve access token]`); + + expect(getOAuthJwtAccessToken as jest.Mock).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `Failed to retrieve access token using JWT OAuth with tokenUrl https://testurl.service-now.com/oauth_token.do and config {\"clientId\":\"abc\",\"jwtKeyId\":\"def\",\"userIdentifierValue\":\"userA\"} - Something went wrong!` + ); + }); + + test('throws when getOAuthClientCredentialsAccessToken throws error', async () => { + (getOAuthClientCredentialsAccessToken as jest.Mock).mockRejectedValue( + new Error(`Something went wrong!`) + ); + + await expect( + getOAuthAccessToken({ + type: 'client', + options: { + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Failed to retrieve access token]`); + + expect(getOAuthClientCredentialsAccessToken as jest.Mock).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `Failed to retrieved access token using Client Credentials OAuth with tokenUrl https://login.microsoftonline.com/98765/oauth2/v2.0/token, scope https://graph.microsoft.com/.default and config {\"clientId\":\"abc\",\"tenantId\":\"def\"} - Something went wrong!` + ); + }); +}); + describe('delete()', () => { describe('authorization', () => { test('ensures user is authorised to delete actions', async () => { diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index dacf6de36bd37..89156bb56b51a 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -6,6 +6,7 @@ */ import Boom from '@hapi/boom'; +import url from 'url'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; @@ -18,6 +19,7 @@ import { SavedObject, KibanaRequest, SavedObjectsUtils, + Logger, } from '@kbn/core/server'; import { AuditLogger } from '@kbn/security-plugin/server'; import { RunNowResult } from '@kbn/task-manager-plugin/server'; @@ -46,6 +48,22 @@ import { import { connectorAuditEvent, ConnectorAuditAction } from './lib/audit_events'; import { trackLegacyRBACExemption } from './lib/track_legacy_rbac_exemption'; import { isConnectorDeprecated } from './lib/is_conector_deprecated'; +import { ActionsConfigurationUtilities } from './actions_config'; +import { + OAuthClientCredentialsParams, + OAuthJwtParams, + OAuthParams, +} from './routes/get_oauth_access_token'; +import { + getOAuthJwtAccessToken, + GetOAuthJwtConfig, + GetOAuthJwtSecrets, +} from './builtin_action_types/lib/get_oauth_jwt_access_token'; +import { + getOAuthClientCredentialsAccessToken, + GetOAuthClientCredentialsConfig, + GetOAuthClientCredentialsSecrets, +} from './builtin_action_types/lib/get_oauth_client_credentials_access_token'; // We are assuming there won't be many actions. This is why we will load // all the actions in advance and assume the total count to not go over 10000. @@ -448,6 +466,98 @@ export class ActionsClient { return actionResults; } + public async getOAuthAccessToken( + { type, options }: OAuthParams, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities + ) { + // Verify that user has edit access + await this.authorization.ensureAuthorized('update'); + + // Verify that token url is allowed by allowed hosts config + try { + configurationUtilities.ensureUriAllowed(options.tokenUrl); + } catch (err) { + throw Boom.badRequest(err.message); + } + + // Verify that token url contains a hostname and uses https + const parsedUrl = url.parse( + options.tokenUrl, + false /* parseQueryString */, + true /* slashesDenoteHost */ + ); + + if (!parsedUrl.hostname) { + throw Boom.badRequest(`Token URL must contain hostname`); + } + + if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { + throw Boom.badRequest(`Token URL must use http or https`); + } + + let accessToken: string | null = null; + if (type === 'jwt') { + const tokenOpts = options as OAuthJwtParams; + + try { + accessToken = await getOAuthJwtAccessToken({ + logger, + configurationUtilities, + credentials: { + config: tokenOpts.config as GetOAuthJwtConfig, + secrets: tokenOpts.secrets as GetOAuthJwtSecrets, + }, + tokenUrl: tokenOpts.tokenUrl, + }); + + logger.debug( + `Successfully retrieved access token using JWT OAuth with tokenUrl ${ + tokenOpts.tokenUrl + } and config ${JSON.stringify(tokenOpts.config)}` + ); + } catch (err) { + logger.debug( + `Failed to retrieve access token using JWT OAuth with tokenUrl ${ + tokenOpts.tokenUrl + } and config ${JSON.stringify(tokenOpts.config)} - ${err.message}` + ); + throw Boom.badRequest(`Failed to retrieve access token`); + } + } else if (type === 'client') { + const tokenOpts = options as OAuthClientCredentialsParams; + try { + accessToken = await getOAuthClientCredentialsAccessToken({ + logger, + configurationUtilities, + credentials: { + config: tokenOpts.config as GetOAuthClientCredentialsConfig, + secrets: tokenOpts.secrets as GetOAuthClientCredentialsSecrets, + }, + tokenUrl: tokenOpts.tokenUrl, + oAuthScope: tokenOpts.scope, + }); + + logger.debug( + `Successfully retrieved access token using Client Credentials OAuth with tokenUrl ${ + tokenOpts.tokenUrl + }, scope ${tokenOpts.scope} and config ${JSON.stringify(tokenOpts.config)}` + ); + } catch (err) { + logger.debug( + `Failed to retrieved access token using Client Credentials OAuth with tokenUrl ${ + tokenOpts.tokenUrl + }, scope ${tokenOpts.scope} and config ${JSON.stringify(tokenOpts.config)} - ${ + err.message + }` + ); + throw Boom.badRequest(`Failed to retrieve access token`); + } + } + + return { accessToken }; + } + /** * Delete action */ diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 470e6ce8cdc8e..a6b68d907cb44 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -129,6 +129,17 @@ describe('isUriAllowed', () => { ).toEqual(true); }); + test('returns true for network path references', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + allowedHosts: ['my-domain.com'], + enabledActionTypes: [], + }; + expect(getActionsConfigurationUtilities(config).isUriAllowed('//my-domain.com/foo')).toEqual( + true + ); + }); + test('throws when the hostname in the requested uri is not in the allowedHosts', () => { const config: ActionsConfig = defaultActionsConfig; expect( diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 35e08bb5cfe66..49f1d1fd5445e 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -76,7 +76,7 @@ function isAllowed({ allowedHosts }: ActionsConfig, hostname: string | null): bo function isHostnameAllowedInUri(config: ActionsConfig, uri: string): boolean { return pipe( - tryCatch(() => url.parse(uri)), + tryCatch(() => url.parse(uri, false /* parseQueryString */, true /* slashesDenoteHost */)), map((parsedUrl) => parsedUrl.hostname), mapNullable((hostname) => isAllowed(config, hostname)), getOrElse(() => false) diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts index b33a2d17ed9d8..9dde4790c152d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts @@ -20,8 +20,7 @@ export function createJWTAssertion( logger: Logger, privateKey: string, privateKeyPassword: string | null, - reservedClaims: JWTClaims, - customClaims?: Record + reservedClaims: JWTClaims ): string { const { subject, audience, issuer, expireInMilliseconds, keyId } = reservedClaims; const iat = Math.floor(Date.now() / 1000); @@ -34,7 +33,6 @@ export function createJWTAssertion( iss: issuer, // issuer claim identifies the principal that issued the JWT iat, // issued at claim identifies the time at which the JWT was issued exp: iat + (expireInMilliseconds ?? 3600), // expiration time claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing - ...(customClaims ?? {}), }; try { diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.test.ts new file mode 100644 index 0000000000000..2efa79cf09c48 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.test.ts @@ -0,0 +1,306 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { asyncForEach } from '@kbn/std'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { connectorTokenClientMock } from './connector_token_client.mock'; +import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token'; +import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; + +jest.mock('./request_oauth_client_credentials_token', () => ({ + requestOAuthClientCredentialsToken: jest.fn(), +})); + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const configurationUtilities = actionsConfigMock.create(); +const connectorTokenClient = connectorTokenClientMock.create(); + +describe('getOAuthClientCredentialsAccessToken', () => { + const getOAuthClientCredentialsAccessTokenOpts = { + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + tenantId: 'tenantId', + }, + secrets: { + clientSecret: 'clientSecret', + }, + }, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + connectorTokenClient, + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + test('uses stored access token if it exists', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10000000000).toISOString(), + }, + }); + const accessToken = await getOAuthClientCredentialsAccessToken( + getOAuthClientCredentialsAccessTokenOpts + ); + + expect(accessToken).toEqual('testtokenvalue'); + expect(requestOAuthClientCredentialsToken as jest.Mock).not.toHaveBeenCalled(); + }); + + test('creates new assertion if stored access token does not exist', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthClientCredentialsAccessToken( + getOAuthClientCredentialsAccessTokenOpts + ); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(requestOAuthClientCredentialsToken as jest.Mock).toHaveBeenCalledWith( + 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + logger, + { + scope: 'https://graph.microsoft.com/.default', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: null, + newToken: 'access_token brandnewaccesstoken', + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('creates new assertion if stored access token exists but is expired', async () => { + const createdAt = new Date().toISOString(); + const expiresAt = new Date(Date.now() - 100).toISOString(); + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + }); + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthClientCredentialsAccessToken( + getOAuthClientCredentialsAccessTokenOpts + ); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(requestOAuthClientCredentialsToken as jest.Mock).toHaveBeenCalledWith( + 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + logger, + { + scope: 'https://graph.microsoft.com/.default', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + newToken: 'access_token brandnewaccesstoken', + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('returns null and logs warning if any required fields are missing', async () => { + await asyncForEach(['clientId', 'tenantId'], async (configField: string) => { + const accessToken = await getOAuthClientCredentialsAccessToken({ + ...getOAuthClientCredentialsAccessTokenOpts, + credentials: { + config: { + ...getOAuthClientCredentialsAccessTokenOpts.credentials.config, + [configField]: null, + }, + secrets: getOAuthClientCredentialsAccessTokenOpts.credentials.secrets, + }, + }); + expect(accessToken).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + `Missing required fields for requesting OAuth Client Credentials access token` + ); + }); + + await asyncForEach(['clientSecret'], async (secretsField: string) => { + const accessToken = await getOAuthClientCredentialsAccessToken({ + ...getOAuthClientCredentialsAccessTokenOpts, + credentials: { + config: getOAuthClientCredentialsAccessTokenOpts.credentials.config, + secrets: { + ...getOAuthClientCredentialsAccessTokenOpts.credentials.secrets, + [secretsField]: null, + }, + }, + }); + expect(accessToken).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + `Missing required fields for requesting OAuth Client Credentials access token` + ); + }); + }); + + test('throws error if requestOAuthClientCredentialsToken throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (requestOAuthClientCredentialsToken as jest.Mock).mockRejectedValueOnce( + new Error('requestOAuthClientCredentialsToken error!!') + ); + + await expect( + getOAuthClientCredentialsAccessToken(getOAuthClientCredentialsAccessTokenOpts) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"requestOAuthClientCredentialsToken error!!"`); + }); + + test('logs warning if connectorTokenClient.updateOrReplace throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + connectorTokenClient.updateOrReplace.mockRejectedValueOnce(new Error('updateOrReplace error')); + + const accessToken = await getOAuthClientCredentialsAccessToken( + getOAuthClientCredentialsAccessTokenOpts + ); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(logger.warn).toHaveBeenCalledWith( + `Not able to update connector token for connectorId: 123 due to error: updateOrReplace error` + ); + }); + + test('gets access token if connectorId is not provided', async () => { + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthClientCredentialsAccessToken({ + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + tenantId: 'tenantId', + }, + secrets: { + clientSecret: 'clientSecret', + }, + }, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + connectorTokenClient, + }); + + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + expect(connectorTokenClient.updateOrReplace).not.toHaveBeenCalled(); + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(requestOAuthClientCredentialsToken as jest.Mock).toHaveBeenCalledWith( + 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + logger, + { + scope: 'https://graph.microsoft.com/.default', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + + configurationUtilities + ); + }); + + test('gets access token if connectorTokenClient is not provided', async () => { + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthClientCredentialsAccessToken({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + tenantId: 'tenantId', + }, + secrets: { + clientSecret: 'clientSecret', + }, + }, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + }); + + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + expect(connectorTokenClient.updateOrReplace).not.toHaveBeenCalled(); + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(requestOAuthClientCredentialsToken as jest.Mock).toHaveBeenCalledWith( + 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + logger, + { + scope: 'https://graph.microsoft.com/.default', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + + configurationUtilities + ); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts new file mode 100644 index 0000000000000..803cce2db7668 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ConnectorToken, ConnectorTokenClientContract } from '../../types'; +import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; + +export interface GetOAuthClientCredentialsConfig { + clientId: string; + tenantId: string; +} + +export interface GetOAuthClientCredentialsSecrets { + clientSecret: string; +} + +interface GetOAuthClientCredentialsAccessTokenOpts { + connectorId?: string; + tokenUrl: string; + oAuthScope: string; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + credentials: { + config: GetOAuthClientCredentialsConfig; + secrets: GetOAuthClientCredentialsSecrets; + }; + connectorTokenClient?: ConnectorTokenClientContract; +} + +export const getOAuthClientCredentialsAccessToken = async ({ + connectorId, + logger, + tokenUrl, + oAuthScope, + configurationUtilities, + credentials, + connectorTokenClient, +}: GetOAuthClientCredentialsAccessTokenOpts) => { + const { clientId, tenantId } = credentials.config; + const { clientSecret } = credentials.secrets; + + if (!clientId || !clientSecret || !tenantId) { + logger.warn(`Missing required fields for requesting OAuth Client Credentials access token`); + return null; + } + + let accessToken: string; + let connectorToken: ConnectorToken | null = null; + let hasErrors: boolean = false; + + if (connectorId && connectorTokenClient) { + // Check if there is a token stored for this connector + const { connectorToken: token, hasErrors: errors } = await connectorTokenClient.get({ + connectorId, + }); + connectorToken = token; + hasErrors = errors; + } + + if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { + // request access token with jwt assertion + const tokenResult = await requestOAuthClientCredentialsToken( + tokenUrl, + logger, + { + scope: oAuthScope, + clientId, + clientSecret, + }, + configurationUtilities + ); + accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + + // try to update connector_token SO + if (connectorId && connectorTokenClient) { + try { + await connectorTokenClient.updateOrReplace({ + connectorId, + token: connectorToken, + newToken: accessToken, + expiresInSec: tokenResult.expiresIn, + deleteExisting: hasErrors, + }); + } catch (err) { + logger.warn( + `Not able to update connector token for connectorId: ${connectorId} due to error: ${err.message}` + ); + } + } + } else { + // use existing valid token + accessToken = connectorToken.token; + } + return accessToken; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts new file mode 100644 index 0000000000000..b48456ddd2a8c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts @@ -0,0 +1,350 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { asyncForEach } from '@kbn/std'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { connectorTokenClientMock } from './connector_token_client.mock'; +import { getOAuthJwtAccessToken } from './get_oauth_jwt_access_token'; +import { createJWTAssertion } from './create_jwt_assertion'; +import { requestOAuthJWTToken } from './request_oauth_jwt_token'; + +jest.mock('./create_jwt_assertion', () => ({ + createJWTAssertion: jest.fn(), +})); +jest.mock('./request_oauth_jwt_token', () => ({ + requestOAuthJWTToken: jest.fn(), +})); + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const configurationUtilities = actionsConfigMock.create(); +const connectorTokenClient = connectorTokenClientMock.create(); + +describe('getOAuthJwtAccessToken', () => { + const getOAuthJwtAccessTokenOpts = { + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: 'privateKeyPassword', + }, + }, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + connectorTokenClient, + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + test('uses stored access token if it exists', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10000000000).toISOString(), + }, + }); + const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts); + + expect(accessToken).toEqual('testtokenvalue'); + expect(createJWTAssertion as jest.Mock).not.toHaveBeenCalled(); + expect(requestOAuthJWTToken as jest.Mock).not.toHaveBeenCalled(); + }); + + test('creates new assertion if stored access token does not exist', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: null, + newToken: 'access_token brandnewaccesstoken', + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('creates new assertion if stored access token exists but is expired', async () => { + const createdAt = new Date().toISOString(); + const expiresAt = new Date(Date.now() - 100).toISOString(); + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + newToken: 'access_token brandnewaccesstoken', + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('returns null and logs warning if any required fields are missing', async () => { + await asyncForEach( + ['clientId', 'jwtKeyId', 'userIdentifierValue'], + async (configField: string) => { + const accessToken = await getOAuthJwtAccessToken({ + ...getOAuthJwtAccessTokenOpts, + credentials: { + config: { ...getOAuthJwtAccessTokenOpts.credentials.config, [configField]: null }, + secrets: getOAuthJwtAccessTokenOpts.credentials.secrets, + }, + }); + expect(accessToken).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + `Missing required fields for requesting OAuth JWT access token` + ); + } + ); + + await asyncForEach(['clientSecret', 'privateKey'], async (secretsField: string) => { + const accessToken = await getOAuthJwtAccessToken({ + ...getOAuthJwtAccessTokenOpts, + credentials: { + config: getOAuthJwtAccessTokenOpts.credentials.config, + secrets: { ...getOAuthJwtAccessTokenOpts.credentials.secrets, [secretsField]: null }, + }, + }); + expect(accessToken).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + `Missing required fields for requesting OAuth JWT access token` + ); + }); + }); + + test('throws error if createJWTAssertion throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockImplementationOnce(() => { + throw new Error('createJWTAssertion error!!'); + }); + + await expect( + getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"createJWTAssertion error!!"`); + }); + + test('throws error if requestOAuthJWTToken throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockRejectedValueOnce( + new Error('requestOAuthJWTToken error!!') + ); + + await expect( + getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"requestOAuthJWTToken error!!"`); + }); + + test('logs warning if connectorTokenClient.updateOrReplace throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + connectorTokenClient.updateOrReplace.mockRejectedValueOnce(new Error('updateOrReplace error')); + + const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(logger.warn).toHaveBeenCalledWith( + `Not able to update connector token for connectorId: 123 due to error: updateOrReplace error` + ); + }); + + test('gets access token if connectorId is not provided', async () => { + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthJwtAccessToken({ + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: 'privateKeyPassword', + }, + }, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + connectorTokenClient, + }); + + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + expect(connectorTokenClient.updateOrReplace).not.toHaveBeenCalled(); + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + }); + + test('gets access token if connectorTokenClient is not provided', async () => { + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthJwtAccessToken({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: 'privateKeyPassword', + }, + }, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + }); + + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + expect(connectorTokenClient.updateOrReplace).not.toHaveBeenCalled(); + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts new file mode 100644 index 0000000000000..a4867d99556e7 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts @@ -0,0 +1,109 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ConnectorToken, ConnectorTokenClientContract } from '../../types'; +import { createJWTAssertion } from './create_jwt_assertion'; +import { requestOAuthJWTToken } from './request_oauth_jwt_token'; + +export interface GetOAuthJwtConfig { + clientId: string; + jwtKeyId: string; + userIdentifierValue: string; +} + +export interface GetOAuthJwtSecrets { + clientSecret: string; + privateKey: string; + privateKeyPassword: string | null; +} + +interface GetOAuthJwtAccessTokenOpts { + connectorId?: string; + tokenUrl: string; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + credentials: { + config: GetOAuthJwtConfig; + secrets: GetOAuthJwtSecrets; + }; + connectorTokenClient?: ConnectorTokenClientContract; +} + +export const getOAuthJwtAccessToken = async ({ + connectorId, + logger, + tokenUrl, + configurationUtilities, + credentials, + connectorTokenClient, +}: GetOAuthJwtAccessTokenOpts) => { + const { clientId, jwtKeyId, userIdentifierValue } = credentials.config; + const { clientSecret, privateKey, privateKeyPassword } = credentials.secrets; + + if (!clientId || !clientSecret || !jwtKeyId || !privateKey || !userIdentifierValue) { + logger.warn(`Missing required fields for requesting OAuth JWT access token`); + return null; + } + + let accessToken: string; + let connectorToken: ConnectorToken | null = null; + let hasErrors: boolean = false; + + if (connectorId && connectorTokenClient) { + // Check if there is a token stored for this connector + const { connectorToken: token, hasErrors: errors } = await connectorTokenClient.get({ + connectorId, + }); + connectorToken = token; + hasErrors = errors; + } + + if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { + // generate a new assertion + const assertion = createJWTAssertion(logger, privateKey, privateKeyPassword, { + audience: clientId, + issuer: clientId, + subject: userIdentifierValue, + keyId: jwtKeyId, + }); + + // request access token with jwt assertion + const tokenResult = await requestOAuthJWTToken( + tokenUrl, + { + clientId, + clientSecret, + assertion, + }, + logger, + configurationUtilities + ); + accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + + // try to update connector_token SO + if (connectorId && connectorTokenClient) { + try { + await connectorTokenClient.updateOrReplace({ + connectorId, + token: connectorToken, + newToken: accessToken, + expiresInSec: tokenResult.expiresIn, + deleteExisting: hasErrors, + }); + } catch (err) { + logger.warn( + `Not able to update connector token for connectorId: ${connectorId} due to error: ${err.message}` + ); + } + } + } else { + // use existing valid token + accessToken = connectorToken.token; + } + return accessToken; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 1d1c2c46cb0e4..fbf0d90541659 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -12,8 +12,8 @@ jest.mock('nodemailer', () => ({ jest.mock('./send_email_graph_api', () => ({ sendEmailGraphApi: jest.fn(), })); -jest.mock('./request_oauth_client_credentials_token', () => ({ - requestOAuthClientCredentialsToken: jest.fn(), +jest.mock('./get_oauth_client_credentials_access_token', () => ({ + getOAuthClientCredentialsAccessToken: jest.fn(), })); import { Logger } from '@kbn/core/server'; @@ -24,10 +24,9 @@ import { ProxySettings } from '../../types'; import { actionsConfigMock } from '../../actions_config.mock'; import { CustomHostSettings } from '../../config'; import { sendEmailGraphApi } from './send_email_graph_api'; -import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; +import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token'; import { ConnectorTokenClient } from './connector_token_client'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; -import { connectorTokenClientMock } from './connector_token_client.mock'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; @@ -92,314 +91,38 @@ describe('send_email module', () => { test('uses OAuth 2.0 Client Credentials authentication for email using "exchange_server" service', async () => { const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; + const getOAuthClientCredentialsAccessTokenMock = + getOAuthClientCredentialsAccessToken as jest.Mock; const sendEmailOptions = getSendEmailOptions({ transport: { service: 'exchange_server', clientId: '123456', + tenantId: '98765', clientSecret: 'sdfhkdsjhfksdjfh', }, }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, - }); + getOAuthClientCredentialsAccessTokenMock.mockReturnValueOnce(`Bearer dfjsdfgdjhfgsjdf`); const date = new Date(); date.setDate(date.getDate() + 5); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - token: '11111111', - }, - }); - sendEmailGraphApiMock.mockReturnValue({ status: 202, }); - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 0, - saved_objects: [], - per_page: 500, - page: 1, - }); await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); - requestOAuthClientCredentialsTokenMock.mock.calls[0].pop(); - expect(requestOAuthClientCredentialsTokenMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "https://login.microsoftonline.com/undefined/oauth2/v2.0/token", - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "scope": "https://graph.microsoft.com/.default", - }, - ] - `); - - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; - sendEmailGraphApiMock.mock.calls[0].pop(); - expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "graphApiUrl": undefined, - "headers": Object { - "Authorization": "Bearer dfjsdfgdjhfgsjdf", - "Content-Type": "application/json", - }, - "messageHTML": "

a message

- ", - "options": Object { - "connectorId": "1", - "content": Object { - "message": "a message", - "subject": "a subject", - }, - "hasAuth": true, - "routing": Object { - "bcc": Array [], - "cc": Array [ - "bob@example.com", - "robert@example.com", - ], - "from": "fred@example.com", - "to": Array [ - "jim@example.com", - ], - }, - "transport": Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "password": "changeme", - "service": "exchange_server", - "user": "elastic", - }, - }, - }, - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - ] - `); - - expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(1); - }); - - test('uses existing "access_token" from "connector_token" SO for authentication for email using "exchange_server" service', async () => { - const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; - const sendEmailOptions = getSendEmailOptions({ - transport: { - service: 'exchange_server', - clientId: '123456', - clientSecret: 'sdfhkdsjhfksdjfh', - }, - }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, - }); - - sendEmailGraphApiMock.mockReturnValue({ - status: 202, - }); - const date = new Date(); - date.setDate(date.getDate() + 5); - - unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ - errors: [], - }); - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 2, - saved_objects: [ - { - id: '1', - score: 1, - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - }, - }, - ], - per_page: 500, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - token: '11111111', + expect(getOAuthClientCredentialsAccessTokenMock).toHaveBeenCalledWith({ + configurationUtilities: sendEmailOptions.configurationUtilities, + connectorId: '1', + connectorTokenClient, + credentials: { + config: { clientId: '123456', tenantId: '98765' }, + secrets: { clientSecret: 'sdfhkdsjhfksdjfh' }, }, + logger: mockLogger, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', }); - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); - expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(0); - - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; - sendEmailGraphApiMock.mock.calls[0].pop(); - expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "graphApiUrl": undefined, - "headers": Object { - "Authorization": "11111111", - "Content-Type": "application/json", - }, - "messageHTML": "

a message

- ", - "options": Object { - "connectorId": "1", - "content": Object { - "message": "a message", - "subject": "a subject", - }, - "hasAuth": true, - "routing": Object { - "bcc": Array [], - "cc": Array [ - "bob@example.com", - "robert@example.com", - ], - "from": "fred@example.com", - "to": Array [ - "jim@example.com", - ], - }, - "transport": Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "password": "changeme", - "service": "exchange_server", - "user": "elastic", - }, - }, - }, - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - ] - `); - - expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(0); - }); - - test('request the new token and update existing "access_token" when it is expired for "exchange_server" email service', async () => { - const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; - const sendEmailOptions = getSendEmailOptions({ - transport: { - service: 'exchange_server', - clientId: '123456', - clientSecret: 'sdfhkdsjhfksdjfh', - }, - }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, - }); - - sendEmailGraphApiMock.mockReturnValue({ - status: 202, - }); - const date = new Date(); - date.setDate(date.getDate() - 5); - - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 2, - saved_objects: [ - { - id: '1', - score: 1, - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - }, - }, - ], - per_page: 500, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - token: '11111111', - }, - }); - - unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - token: '11111111', - }, - }); - unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ - errors: [], - }); - - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - token: '11111111', - }, - }); - - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); - expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; sendEmailGraphApiMock.mock.calls[0].pop(); expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` @@ -435,6 +158,7 @@ describe('send_email module', () => { "clientSecret": "sdfhkdsjhfksdjfh", "password": "changeme", "service": "exchange_server", + "tenantId": "98765", "user": "elastic", }, }, @@ -452,209 +176,42 @@ describe('send_email module', () => { }, ] `); - - expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(1); }); - test('sending email for "exchange_server" wont fail if connectorTokenClient throw the errors, just log warning message', async () => { + test('throws error if null access token returned when using OAuth 2.0 Client Credentials authentication', async () => { const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; + const getOAuthClientCredentialsAccessTokenMock = + getOAuthClientCredentialsAccessToken as jest.Mock; const sendEmailOptions = getSendEmailOptions({ transport: { service: 'exchange_server', clientId: '123456', + tenantId: '98765', clientSecret: 'sdfhkdsjhfksdjfh', }, }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, - }); - - sendEmailGraphApiMock.mockReturnValue({ - status: 202, - }); - const date = new Date(); - date.setDate(date.getDate() + 5); - - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 0, - saved_objects: [], - per_page: 500, - page: 1, - }); - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); - - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); - expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); - expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(1); - expect(mockLogger.warn.mock.calls[0]).toMatchObject([ - `Not able to update connector token for connectorId: 1 due to error: Fail`, - ]); + getOAuthClientCredentialsAccessTokenMock.mockReturnValueOnce(null); - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; - sendEmailGraphApiMock.mock.calls[0].pop(); - expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "graphApiUrl": undefined, - "headers": Object { - "Authorization": "Bearer dfjsdfgdjhfgsjdf", - "Content-Type": "application/json", - }, - "messageHTML": "

a message

- ", - "options": Object { - "connectorId": "1", - "content": Object { - "message": "a message", - "subject": "a subject", - }, - "hasAuth": true, - "routing": Object { - "bcc": Array [], - "cc": Array [ - "bob@example.com", - "robert@example.com", - ], - "from": "fred@example.com", - "to": Array [ - "jim@example.com", - ], - }, - "transport": Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "password": "changeme", - "service": "exchange_server", - "user": "elastic", - }, - }, - }, - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction] { - "calls": Array [ - Array [ - "Failed to create connector_token for connectorId \\"1\\" and tokenType: \\"access_token\\". Error: Fail", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction] { - "calls": Array [ - Array [ - "Not able to update connector token for connectorId: 1 due to error: Fail", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - }, - ] - `); - }); + await expect(() => + sendEmail(mockLogger, sendEmailOptions, connectorTokenClient) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to retrieve access token for connectorId: 1"` + ); - test('delete duplication tokens if connectorTokenClient get method has the errors, like decription error', async () => { - const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; - const sendEmailOptions = getSendEmailOptions({ - transport: { - service: 'exchange_server', - clientId: '123456', - clientSecret: 'sdfhkdsjhfksdjfh', + expect(getOAuthClientCredentialsAccessTokenMock).toHaveBeenCalledWith({ + configurationUtilities: sendEmailOptions.configurationUtilities, + connectorId: '1', + connectorTokenClient, + credentials: { + config: { clientId: '123456', tenantId: '98765' }, + secrets: { clientSecret: 'sdfhkdsjhfksdjfh' }, }, + logger: mockLogger, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, - }); - - sendEmailGraphApiMock.mockReturnValue({ - status: 202, - }); - const date = new Date(); - date.setDate(date.getDate() + 5); - - const connectorTokenClientM = connectorTokenClientMock.create(); - connectorTokenClientM.get.mockResolvedValueOnce({ - hasErrors: true, - connectorToken: null, - }); - - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClientM); - expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); - expect(connectorTokenClientM.updateOrReplace.mock.calls.length).toBe(1); - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; - sendEmailGraphApiMock.mock.calls[0].pop(); - expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "graphApiUrl": undefined, - "headers": Object { - "Authorization": "Bearer dfjsdfgdjhfgsjdf", - "Content-Type": "application/json", - }, - "messageHTML": "

a message

- ", - "options": Object { - "connectorId": "1", - "content": Object { - "message": "a message", - "subject": "a subject", - }, - "hasAuth": true, - "routing": Object { - "bcc": Array [], - "cc": Array [ - "bob@example.com", - "robert@example.com", - ], - "from": "fred@example.com", - "to": Array [ - "jim@example.com", - ], - }, - "transport": Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "password": "changeme", - "service": "exchange_server", - "user": "elastic", - }, - }, - }, - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - ] - `); + expect(sendEmailGraphApiMock).not.toHaveBeenCalled(); }); test('handles unauthenticated email using not secure host/port', async () => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index 983846adc71e0..f2b059e51e0d6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -14,9 +14,9 @@ import { ActionsConfigurationUtilities } from '../../actions_config'; import { CustomHostSettings } from '../../config'; import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options'; import { sendEmailGraphApi } from './send_email_graph_api'; -import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; import { ConnectorTokenClientContract, ProxySettings } from '../../types'; import { AdditionalEmailServices } from '../../../common'; +import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -86,41 +86,28 @@ async function sendEmailWithExchange( const { transport, configurationUtilities, connectorId } = options; const { clientId, clientSecret, tenantId, oauthTokenUrl } = transport; - let accessToken: string; - - const { connectorToken, hasErrors } = await connectorTokenClient.get({ connectorId }); - if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { - // request new access token for microsoft exchange online server with Graph API scope - const tokenResult = await requestOAuthClientCredentialsToken( - oauthTokenUrl ?? `${EXCHANGE_ONLINE_SERVER_HOST}/${tenantId}/oauth2/v2.0/token`, - logger, - { - scope: GRAPH_API_OAUTH_SCOPE, - clientId, - clientSecret, + const accessToken = await getOAuthClientCredentialsAccessToken({ + connectorId, + logger, + configurationUtilities, + credentials: { + config: { + clientId: clientId as string, + tenantId: tenantId as string, + }, + secrets: { + clientSecret: clientSecret as string, }, - configurationUtilities - ); - accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + }, + oAuthScope: GRAPH_API_OAUTH_SCOPE, + tokenUrl: oauthTokenUrl ?? `${EXCHANGE_ONLINE_SERVER_HOST}/${tenantId}/oauth2/v2.0/token`, + connectorTokenClient, + }); - // try to update connector_token SO - try { - await connectorTokenClient.updateOrReplace({ - connectorId, - token: connectorToken, - newToken: accessToken, - expiresInSec: tokenResult.expiresIn, - deleteExisting: hasErrors, - }); - } catch (err) { - logger.warn( - `Not able to update connector token for connectorId: ${connectorId} due to error: ${err.message}` - ); - } - } else { - // use existing valid token - accessToken = connectorToken.token; + if (!accessToken) { + throw new Error(`Unable to retrieve access token for connectorId: ${connectorId}`); } + const headers = { 'Content-Type': 'application/json', Authorization: accessToken, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts index dae4e59728a0c..64a80977709e5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -14,19 +14,14 @@ import { createServiceError, getPushedDate, throwIfSubActionIsNotSupported, - getAccessToken, getAxiosInstance, } from './utils'; import { connectorTokenClientMock } from '../lib/connector_token_client.mock'; import { actionsConfigMock } from '../../actions_config.mock'; -import { createJWTAssertion } from '../lib/create_jwt_assertion'; -import { requestOAuthJWTToken } from '../lib/request_oauth_jwt_token'; +import { getOAuthJwtAccessToken } from '../lib/get_oauth_jwt_access_token'; -jest.mock('../lib/create_jwt_assertion', () => ({ - createJWTAssertion: jest.fn(), -})); -jest.mock('../lib/request_oauth_jwt_token', () => ({ - requestOAuthJWTToken: jest.fn(), +jest.mock('../lib/get_oauth_jwt_access_token', () => ({ + getOAuthJwtAccessToken: jest.fn(), })); jest.mock('axios', () => ({ @@ -195,7 +190,7 @@ describe('utils', () => { }); }); - test('creates axios instance with interceptor when isOAuth is true and OAuth fields are defined', () => { + test('creates axios instance with interceptor when isOAuth is true and OAuth fields are defined', async () => { connectorTokenClient.get.mockResolvedValueOnce({ hasErrors: false, connectorToken: { @@ -235,206 +230,34 @@ describe('utils', () => { expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); expect(createAxiosInstanceMock).toHaveBeenCalledWith(); expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1); - }); - }); - describe('getAccessToken', () => { - const getAccessTokenOpts = { - connectorId: '123', - logger, - configurationUtilities, - credentials: { - config: { - apiUrl: 'https://servicenow', - usesTableApi: true, - isOAuth: true, - clientId: 'clientId', - jwtKeyId: 'jwtKeyId', - userIdentifierValue: 'userIdentifierValue', - }, - secrets: { - clientSecret: 'clientSecret', - privateKey: 'privateKey', - privateKeyPassword: 'privateKeyPassword', - username: null, - password: null, - }, - }, - snServiceUrl: 'https://dev23432523.service-now.com', - connectorTokenClient, - }; - beforeEach(() => { - jest.resetAllMocks(); - jest.clearAllMocks(); - }); + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce('Bearer tokentokentoken'); - test('uses stored access token if it exists', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: { - id: '1', - connectorId: '123', - tokenType: 'access_token', - token: 'testtokenvalue', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 10000000000).toISOString(), - }, - }); - const accessToken = await getAccessToken(getAccessTokenOpts); - - expect(accessToken).toEqual('testtokenvalue'); - expect(createJWTAssertion as jest.Mock).not.toHaveBeenCalled(); - expect(requestOAuthJWTToken as jest.Mock).not.toHaveBeenCalled(); - }); - - test('creates new assertion if stored access token does not exist', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: null, - }); - (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); - (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ - tokenType: 'access_token', - accessToken: 'brandnewaccesstoken', - expiresIn: 1000, + const mockRequestCallback = (axiosInstanceMock.interceptors.request.use as jest.Mock).mock + .calls[0][0]; + expect(await mockRequestCallback({ headers: {} })).toEqual({ + headers: { Authorization: 'Bearer tokentokentoken' }, }); - const accessToken = await getAccessToken(getAccessTokenOpts); - - expect(accessToken).toEqual('access_token brandnewaccesstoken'); - expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( - logger, - 'privateKey', - 'privateKeyPassword', - { - audience: 'clientId', - issuer: 'clientId', - subject: 'userIdentifierValue', - keyId: 'jwtKeyId', - } - ); - expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( - 'https://dev23432523.service-now.com/oauth_token.do', - { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, - logger, - configurationUtilities - ); - expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + expect(getOAuthJwtAccessToken as jest.Mock).toHaveBeenCalledWith({ connectorId: '123', - token: null, - newToken: 'access_token brandnewaccesstoken', - expiresInSec: 1000, - deleteExisting: false, - }); - }); - - test('creates new assertion if stored access token exists but is expired', async () => { - const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() - 100).toISOString(); - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: { - id: '1', - connectorId: '123', - tokenType: 'access_token', - token: 'testtokenvalue', - createdAt, - expiresAt, - }, - }); - (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); - (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ - tokenType: 'access_token', - accessToken: 'brandnewaccesstoken', - expiresIn: 1000, - }); - - const accessToken = await getAccessToken(getAccessTokenOpts); - - expect(accessToken).toEqual('access_token brandnewaccesstoken'); - expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( - logger, - 'privateKey', - 'privateKeyPassword', - { - audience: 'clientId', - issuer: 'clientId', - subject: 'userIdentifierValue', - keyId: 'jwtKeyId', - } - ); - expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( - 'https://dev23432523.service-now.com/oauth_token.do', - { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, logger, - configurationUtilities - ); - expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ - connectorId: '123', - token: { - id: '1', - connectorId: '123', - tokenType: 'access_token', - token: 'testtokenvalue', - createdAt, - expiresAt, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + }, }, - newToken: 'access_token brandnewaccesstoken', - expiresInSec: 1000, - deleteExisting: false, - }); - }); - - test('throws error if createJWTAssertion throws error', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: null, - }); - (createJWTAssertion as jest.Mock).mockImplementationOnce(() => { - throw new Error('createJWTAssertion error!!'); - }); - - await expect(getAccessToken(getAccessTokenOpts)).rejects.toThrowErrorMatchingInlineSnapshot( - `"createJWTAssertion error!!"` - ); - }); - - test('throws error if requestOAuthJWTToken throws error', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: null, - }); - (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); - (requestOAuthJWTToken as jest.Mock).mockRejectedValueOnce( - new Error('requestOAuthJWTToken error!!') - ); - - await expect(getAccessToken(getAccessTokenOpts)).rejects.toThrowErrorMatchingInlineSnapshot( - `"requestOAuthJWTToken error!!"` - ); - }); - - test('logs warning if connectorTokenClient.updateOrReplace throws error', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: null, - }); - (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); - (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ - tokenType: 'access_token', - accessToken: 'brandnewaccesstoken', - expiresIn: 1000, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + connectorTokenClient, }); - connectorTokenClient.updateOrReplace.mockRejectedValueOnce( - new Error('updateOrReplace error') - ); - - const accessToken = await getAccessToken(getAccessTokenOpts); - - expect(accessToken).toEqual('access_token brandnewaccesstoken'); - expect(logger.warn).toHaveBeenCalledWith( - `Not able to update ServiceNow connector token for connectorId: 123 due to error: updateOrReplace error` - ); }); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts index 84d6741398bce..538967269b1ea 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -21,8 +21,7 @@ import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; import * as i18n from './translations'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { ConnectorTokenClientContract } from '../../types'; -import { createJWTAssertion } from '../lib/create_jwt_assertion'; -import { requestOAuthJWTToken } from '../lib/request_oauth_jwt_token'; +import { getOAuthJwtAccessToken } from '../lib/get_oauth_jwt_access_token'; export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => useOldApi @@ -83,13 +82,13 @@ export const throwIfSubActionIsNotSupported = ({ } }; -export interface GetAccessTokenAndAxiosInstanceOpts { - connectorId: string; +export interface GetAxiosInstanceOpts { + connectorId?: string; logger: Logger; configurationUtilities: ActionsConfigurationUtilities; credentials: ExternalServiceCredentials; snServiceUrl: string; - connectorTokenClient: ConnectorTokenClientContract; + connectorTokenClient?: ConnectorTokenClientContract; } export const getAxiosInstance = ({ @@ -99,7 +98,7 @@ export const getAxiosInstance = ({ credentials, snServiceUrl, connectorTokenClient, -}: GetAccessTokenAndAxiosInstanceOpts): AxiosInstance => { +}: GetAxiosInstanceOpts): AxiosInstance => { const { config, secrets } = credentials; const { isOAuth } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; @@ -114,15 +113,25 @@ export const getAxiosInstance = ({ axiosInstance = axios.create(); axiosInstance.interceptors.request.use( async (axiosConfig: AxiosRequestConfig) => { - const accessToken = await getAccessToken({ + const accessToken = await getOAuthJwtAccessToken({ connectorId, logger, configurationUtilities, credentials: { - config: config as ServiceNowPublicConfigurationType, - secrets, + config: { + clientId: config.clientId as string, + jwtKeyId: config.jwtKeyId as string, + userIdentifierValue: config.userIdentifierValue as string, + }, + secrets: { + clientSecret: secrets.clientSecret as string, + privateKey: secrets.privateKey as string, + privateKeyPassword: secrets.privateKeyPassword + ? (secrets.privateKeyPassword as string) + : null, + }, }, - snServiceUrl, + tokenUrl: `${snServiceUrl}/oauth_token.do`, connectorTokenClient, }); axiosConfig.headers.Authorization = accessToken; @@ -136,75 +145,3 @@ export const getAxiosInstance = ({ return axiosInstance; }; - -export const getAccessToken = async ({ - connectorId, - logger, - configurationUtilities, - credentials, - snServiceUrl, - connectorTokenClient, -}: GetAccessTokenAndAxiosInstanceOpts) => { - const { isOAuth, clientId, jwtKeyId, userIdentifierValue } = - credentials.config as ServiceNowPublicConfigurationType; - const { clientSecret, privateKey, privateKeyPassword } = - credentials.secrets as ServiceNowSecretConfigurationType; - - let accessToken: string; - - // Check if there is a token stored for this connector - const { connectorToken, hasErrors } = await connectorTokenClient.get({ connectorId }); - - if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { - // generate a new assertion - if ( - !isOAuth || - !clientId || - !clientSecret || - !jwtKeyId || - !privateKey || - !userIdentifierValue - ) { - return null; - } - - const assertion = createJWTAssertion(logger, privateKey, privateKeyPassword, { - audience: clientId, - issuer: clientId, - subject: userIdentifierValue, - keyId: jwtKeyId, - }); - - // request access token with jwt assertion - const tokenResult = await requestOAuthJWTToken( - `${snServiceUrl}/oauth_token.do`, - { - clientId, - clientSecret, - assertion, - }, - logger, - configurationUtilities - ); - accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; - - // try to update connector_token SO - try { - await connectorTokenClient.updateOrReplace({ - connectorId, - token: connectorToken, - newToken: accessToken, - expiresInSec: tokenResult.expiresIn, - deleteExisting: hasErrors, - }); - } catch (err) { - logger.warn( - `Not able to update ServiceNow connector token for connectorId: ${connectorId} due to error: ${err.message}` - ); - } - } else { - // use existing valid token - accessToken = connectorToken.token; - } - return accessToken; -}; diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index 093236c939aa1..12898cea5a482 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -521,7 +521,7 @@ test('logs a warning when alert executor throws an error', async () => { executorMock.mockRejectedValue(new Error('this action execution is intended to fail')); await actionExecutor.execute(executeParams); expect(loggerMock.warn).toBeCalledWith( - 'action execution failure: test:1: action-1: an error occurred while running the action executor: this action execution is intended to fail' + 'action execution failure: test:1: action-1: an error occurred while running the action: this action execution is intended to fail' ); }); diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index fe77b72f47aa3..b9ed252c6afc2 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -19,6 +19,7 @@ import { validateConnector, } from './validate_with_schema'; import { + ActionType, ActionTypeExecutorResult, ActionTypeRegistryContract, GetServicesFunction, @@ -30,6 +31,7 @@ import { ActionsClient } from '../actions_client'; import { ActionExecutionSource } from './action_execution_source'; import { RelatedSavedObjects } from './related_saved_objects'; import { createActionEventLogRecordObject } from './create_action_event_log_record_object'; +import { ActionExecutionError, ActionExecutionErrorReason } from './errors/action_execution_error'; // 1,000,000 nanoseconds in 1 millisecond const Millis2Nanos = 1000 * 1000; @@ -157,24 +159,6 @@ export class ActionExecutor { } const actionType = actionTypeRegistry.get(actionTypeId); - let validatedParams: Record; - let validatedConfig: Record; - let validatedSecrets: Record; - try { - validatedParams = validateParams(actionType, params); - validatedConfig = validateConfig(actionType, config); - validatedSecrets = validateSecrets(actionType, secrets); - if (actionType.validate?.connector) { - validateConnector(actionType, { - config, - secrets, - }); - } - } catch (err) { - span?.setOutcome('failure'); - return { status: 'error', actionId, message: err.message, retry: false }; - } - const actionLabel = `${actionTypeId}:${actionId}: ${name}`; logger.debug(`executing action ${actionLabel}`); @@ -221,6 +205,14 @@ export class ActionExecutor { let rawResult: ActionTypeExecutorResult; try { + const { validatedParams, validatedConfig, validatedSecrets } = validateAction({ + actionId, + actionType, + params, + config, + secrets, + }); + rawResult = await actionType.executor({ actionId, services, @@ -231,14 +223,19 @@ export class ActionExecutor { taskInfo, }); } catch (err) { - rawResult = { - actionId, - status: 'error', - message: 'an error occurred while running the action executor', - serviceMessage: err.message, - retry: false, - }; + if (err.reason === ActionExecutionErrorReason.Validation) { + rawResult = err.result; + } else { + rawResult = { + actionId, + status: 'error', + message: 'an error occurred while running the action', + serviceMessage: err.message, + retry: false, + }; + } } + eventLogger.stopTiming(event); // allow null-ish return to indicate success @@ -411,3 +408,38 @@ function actionErrorToMessage(result: ActionTypeExecutorResult): string return message; } + +interface ValidateActionOpts { + actionId: string; + actionType: ActionType; + params: Record; + config: unknown; + secrets: unknown; +} + +function validateAction({ actionId, actionType, params, config, secrets }: ValidateActionOpts) { + let validatedParams: Record; + let validatedConfig: Record; + let validatedSecrets: Record; + + try { + validatedParams = validateParams(actionType, params); + validatedConfig = validateConfig(actionType, config); + validatedSecrets = validateSecrets(actionType, secrets); + if (actionType.validate?.connector) { + validateConnector(actionType, { + config, + secrets, + }); + } + + return { validatedParams, validatedConfig, validatedSecrets }; + } catch (err) { + throw new ActionExecutionError(err.message, ActionExecutionErrorReason.Validation, { + actionId, + status: 'error', + message: err.message, + retry: false, + }); + } +} diff --git a/x-pack/plugins/actions/server/lib/errors/action_execution_error.ts b/x-pack/plugins/actions/server/lib/errors/action_execution_error.ts new file mode 100644 index 0000000000000..ad43008ef8e20 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/errors/action_execution_error.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. + */ + +import { ActionTypeExecutorResult } from '../../types'; + +export enum ActionExecutionErrorReason { + Validation = 'validation', +} + +export class ActionExecutionError extends Error { + public readonly reason: ActionExecutionErrorReason; + public readonly result: ActionTypeExecutorResult; + + constructor( + message: string, + reason: ActionExecutionErrorReason, + result: ActionTypeExecutorResult + ) { + super(message); + this.reason = reason; + this.result = result; + } +} diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index d89a3c96b01b9..3f3895ec5b69f 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -109,7 +109,7 @@ describe('Actions Plugin', () => { httpServerMock.createKibanaRequest(), httpServerMock.createResponseFactory() )) as unknown as ActionsApiRequestHandlerContext; - actionsContextHandler!.getActionsClient(); + expect(actionsContextHandler!.getActionsClient()).toBeDefined(); }); it('should throw error when ESO plugin is missing encryption key', async () => { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 1fad2a6189693..c097b94a85950 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -311,12 +311,13 @@ export class ActionsPlugin implements Plugin(), - this.licenseState, + defineRoutes({ + router: core.http.createRouter(), + licenseState: this.licenseState, + logger: this.logger, actionsConfigUtils, - this.usageCounter - ); + usageCounter: this.usageCounter, + }); // Cleanup failed execution task definition if (this.actionsConfig.cleanupFailedExecutionsTask.enabled) { diff --git a/x-pack/plugins/actions/server/routes/get_oauth_access_token.test.ts b/x-pack/plugins/actions/server/routes/get_oauth_access_token.test.ts new file mode 100644 index 0000000000000..888e87dbdf1f4 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/get_oauth_access_token.test.ts @@ -0,0 +1,227 @@ +/* + * 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 { getOAuthAccessToken } from './get_oauth_access_token'; +import { Logger } from '@kbn/core/server'; +import { httpServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { mockHandlerArguments } from './legacy/_mock_handler_arguments'; +import { verifyAccessAndContext } from './verify_access_and_context'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsClientMock } from '../actions_client.mock'; + +jest.mock('./verify_access_and_context', () => ({ + verifyAccessAndContext: jest.fn(), +})); + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const configurationUtilities = actionsConfigMock.create(); + +beforeEach(() => { + jest.resetAllMocks(); + (verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler); +}); + +describe('getOAuthAccessToken', () => { + it('returns jwt access token for given jwt oauth config', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getOAuthAccessToken(router, licenseState, logger, configurationUtilities); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector/_oauth_access_token"`); + + const actionsClient = actionsClientMock.create(); + actionsClient.getOAuthAccessToken.mockResolvedValueOnce({ + accessToken: 'Bearer jwttokentokentoken', + }); + + const requestBody = { + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }; + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { + body: requestBody, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "accessToken": "Bearer jwttokentokentoken", + }, + } + `); + + expect(actionsClient.getOAuthAccessToken).toHaveBeenCalledTimes(1); + expect(actionsClient.getOAuthAccessToken.mock.calls[0]).toEqual([ + requestBody, + logger, + configurationUtilities, + ]); + + expect(res.ok).toHaveBeenCalledWith({ + body: { + accessToken: 'Bearer jwttokentokentoken', + }, + }); + }); + + it('returns client credentials access token for given client credentials oauth config', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getOAuthAccessToken(router, licenseState, logger, configurationUtilities); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector/_oauth_access_token"`); + + const actionsClient = actionsClientMock.create(); + actionsClient.getOAuthAccessToken.mockResolvedValueOnce({ + accessToken: 'Bearer clienttokentokentoken', + }); + + const requestBody = { + type: 'client', + options: { + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + }; + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { + body: requestBody, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "accessToken": "Bearer clienttokentokentoken", + }, + } + `); + + expect(actionsClient.getOAuthAccessToken).toHaveBeenCalledTimes(1); + expect(actionsClient.getOAuthAccessToken.mock.calls[0]).toEqual([ + requestBody, + logger, + configurationUtilities, + ]); + + expect(res.ok).toHaveBeenCalledWith({ + body: { + accessToken: 'Bearer clienttokentokentoken', + }, + }); + }); + + it('ensures the license allows getting servicenow access token', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getOAuthAccessToken(router, licenseState, logger, configurationUtilities); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector/_oauth_access_token"`); + + const [context, req, res] = mockHandlerArguments( + {}, + { + body: { + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); + + it('ensures the license check prevents getting service now access token', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + (verifyAccessAndContext as jest.Mock).mockImplementation(() => async () => { + throw new Error('OMG'); + }); + + getOAuthAccessToken(router, licenseState, logger, configurationUtilities); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector/_oauth_access_token"`); + + const [context, req, res] = mockHandlerArguments( + {}, + { + body: { + type: 'client', + options: { + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + }, + }, + ['ok'] + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); +}); diff --git a/x-pack/plugins/actions/server/routes/get_oauth_access_token.ts b/x-pack/plugins/actions/server/routes/get_oauth_access_token.ts new file mode 100644 index 0000000000000..e1b612d321bcd --- /dev/null +++ b/x-pack/plugins/actions/server/routes/get_oauth_access_token.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { IRouter, Logger } from '@kbn/core/server'; +import { ILicenseState } from '../lib'; +import { INTERNAL_BASE_ACTION_API_PATH } from '../../common'; +import { ActionsRequestHandlerContext } from '../types'; +import { verifyAccessAndContext } from './verify_access_and_context'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +const oauthJwtBodySchema = schema.object({ + tokenUrl: schema.string(), + config: schema.object({ + clientId: schema.string(), + jwtKeyId: schema.string(), + userIdentifierValue: schema.string(), + }), + secrets: schema.object({ + clientSecret: schema.string(), + privateKey: schema.string(), + privateKeyPassword: schema.maybe(schema.string()), + }), +}); + +export type OAuthJwtParams = TypeOf; + +const oauthClientCredentialsBodySchema = schema.object({ + tokenUrl: schema.string(), + scope: schema.string(), + config: schema.object({ + clientId: schema.string(), + tenantId: schema.string(), + }), + secrets: schema.object({ + clientSecret: schema.string(), + }), +}); + +export type OAuthClientCredentialsParams = TypeOf; + +const bodySchema = schema.object({ + type: schema.oneOf([schema.literal('jwt'), schema.literal('client')]), + options: schema.conditional( + schema.siblingRef('type'), + schema.literal('jwt'), + oauthJwtBodySchema, + oauthClientCredentialsBodySchema + ), +}); + +export type OAuthParams = TypeOf; + +export const getOAuthAccessToken = ( + router: IRouter, + licenseState: ILicenseState, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities +) => { + router.post( + { + path: `${INTERNAL_BASE_ACTION_API_PATH}/connector/_oauth_access_token`, + validate: { + body: bodySchema, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const actionsClient = (await context.actions).getActionsClient(); + + return res.ok({ + body: await actionsClient.getOAuthAccessToken(req.body, logger, configurationUtilities), + }); + }) + ) + ); +}; diff --git a/x-pack/plugins/actions/server/routes/index.ts b/x-pack/plugins/actions/server/routes/index.ts index ab90141ae1c80..2822aa3668900 100644 --- a/x-pack/plugins/actions/server/routes/index.ts +++ b/x-pack/plugins/actions/server/routes/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IRouter } from '@kbn/core/server'; +import { IRouter, Logger } from '@kbn/core/server'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { ILicenseState } from '../lib'; import { ActionsRequestHandlerContext } from '../types'; @@ -17,15 +17,21 @@ import { getAllActionRoute } from './get_all'; import { connectorTypesRoute } from './connector_types'; import { updateActionRoute } from './update'; import { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; +import { getOAuthAccessToken } from './get_oauth_access_token'; import { defineLegacyRoutes } from './legacy'; import { ActionsConfigurationUtilities } from '../actions_config'; -export function defineRoutes( - router: IRouter, - licenseState: ILicenseState, - actionsConfigUtils: ActionsConfigurationUtilities, - usageCounter?: UsageCounter -) { +export interface RouteOptions { + router: IRouter; + licenseState: ILicenseState; + logger: Logger; + actionsConfigUtils: ActionsConfigurationUtilities; + usageCounter?: UsageCounter; +} + +export function defineRoutes(opts: RouteOptions) { + const { router, licenseState, logger, actionsConfigUtils, usageCounter } = opts; + defineLegacyRoutes(router, licenseState, usageCounter); createActionRoute(router, licenseState); @@ -36,5 +42,6 @@ export function defineRoutes( connectorTypesRoute(router, licenseState); executeActionRoute(router, licenseState); + getOAuthAccessToken(router, licenseState, logger, actionsConfigUtils); getWellKnownEmailServiceRoute(router, licenseState); } diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index c8f282bf695d7..4509a004c6e58 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -75,6 +75,7 @@ export interface RuleAggregations { ruleEnabledStatus: { enabled: number; disabled: number }; ruleMutedStatus: { muted: number; unmuted: number }; ruleSnoozedStatus: { snoozed: number }; + ruleTags: string[]; } export interface MappedParamsProperties { diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts index 7123f1bf4ad6c..8c24b457df565 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts @@ -60,6 +60,7 @@ describe('aggregateRulesRoute', () => { ruleSnoozedStatus: { snoozed: 4, }, + ruleTags: ['a', 'b', 'c'], }; rulesClient.aggregate.mockResolvedValueOnce(aggregateResult); @@ -94,6 +95,11 @@ describe('aggregateRulesRoute', () => { "rule_snoozed_status": Object { "snoozed": 4, }, + "rule_tags": Array [ + "a", + "b", + "c", + ], }, } `); @@ -129,6 +135,7 @@ describe('aggregateRulesRoute', () => { rule_snoozed_status: { snoozed: 4, }, + rule_tags: ['a', 'b', 'c'], }, }); }); diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts index 312def72dd65e..c48c74fc28754 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts @@ -50,6 +50,7 @@ const rewriteBodyRes: RewriteResponseCase = ({ ruleEnabledStatus, ruleMutedStatus, ruleSnoozedStatus, + ruleTags, ...rest }) => ({ ...rest, @@ -57,6 +58,7 @@ const rewriteBodyRes: RewriteResponseCase = ({ rule_enabled_status: ruleEnabledStatus, rule_muted_status: ruleMutedStatus, rule_snoozed_status: ruleSnoozedStatus, + rule_tags: ruleTags, }); export const aggregateRulesRoute = ( 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 00f67437ae4f2..e229b15fcd1cd 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -133,6 +133,12 @@ export interface RuleAggregation { doc_count: number; }>; }; + tags: { + buckets: Array<{ + key: string; + doc_count: number; + }>; + }; } export interface ConstructorOptions { @@ -200,6 +206,7 @@ export interface AggregateResult { ruleEnabledStatus?: { enabled: number; disabled: number }; ruleMutedStatus?: { muted: number; unmuted: number }; ruleSnoozedStatus?: { snoozed: number }; + ruleTags?: string[]; } export interface FindResult { @@ -921,6 +928,9 @@ export class RulesClient { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + tags: { + terms: { field: 'alert.attributes.tags', order: { _key: 'asc' } }, + }, snoozed: { date_range: { field: 'alert.attributes.snoozeEndTime', @@ -990,6 +1000,9 @@ export class RulesClient { snoozed: snoozedBuckets.reduce((acc, bucket) => acc + bucket.doc_count, 0), }; + const tagsBuckets = resp.aggregations.tags?.buckets || []; + ret.ruleTags = tagsBuckets.map((bucket) => bucket.key); + return ret; } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index b74059e4be3d6..1a3d203162bd6 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -112,6 +112,22 @@ describe('aggregate()', () => { }, ], }, + tags: { + buckets: [ + { + key: 'a', + doc_count: 10, + }, + { + key: 'b', + doc_count: 20, + }, + { + key: 'c', + doc_count: 30, + }, + ], + }, }, }); @@ -160,6 +176,11 @@ describe('aggregate()', () => { "ruleSnoozedStatus": Object { "snoozed": 2, }, + "ruleTags": Array [ + "a", + "b", + "c", + ], } `); expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -187,6 +208,9 @@ describe('aggregate()', () => { ranges: [{ from: 'now' }], }, }, + tags: { + terms: { field: 'alert.attributes.tags', order: { _key: 'asc' } }, + }, }, }, ]); @@ -221,6 +245,9 @@ describe('aggregate()', () => { ranges: [{ from: 'now' }], }, }, + tags: { + terms: { field: 'alert.attributes.tags', order: { _key: 'asc' } }, + }, }, }, ]); diff --git a/x-pack/plugins/cases/docs/openapi/README.md b/x-pack/plugins/cases/docs/openapi/README.md new file mode 100644 index 0000000000000..1ff3e24c2e91f --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/README.md @@ -0,0 +1,29 @@ +# OpenAPI (Experimental) + +The current self-contained spec file is [as JSON](https://raw.githubusercontent.com/elastic/kibana/master/x-pack/plugins/cases/common/openapi/bundled.json) or [as YAML](https://raw.githubusercontent.com/elastic/kibana/master/x-pack/plugins/cases/common/openapi/bundled.yaml) and can be used for online tools like those found at https://openapi.tools/. +This spec is experimental and may be incomplete or change later. + +A guide about the openApi specification can be found at [https://swagger.io/docs/specification/about/](https://swagger.io/docs/specification/about/). + +## The `openapi` folder + +* `entrypoint.yaml` is the overview file which pulls together all the paths and components. +* [Paths](paths/README.md): this defines each endpoint. A path can have one operation per http method. +* [Components](components/README.md): Reusable components + +## Tools + +It is possible to validate the docs before bundling them with the following +command in the `x-pack/plugins/cases/docs/openapi/` folder: + + ``` + npx swagger-cli validate entrypoint.yaml + ``` + +Then you can generate the `bundled` files by running the following commands: + + ``` + npx @redocly/openapi-cli bundle --ext yaml --output bundled.yaml entrypoint.yaml + npx @redocly/openapi-cli bundle --ext json --output bundled.json entrypoint.yaml + ``` + diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json new file mode 100644 index 0000000000000..31feae3331b04 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -0,0 +1,2122 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Cases", + "description": "OpenAPI schema for Cases endpoints", + "version": "0.1", + "contact": { + "name": "Cases Team" + }, + "license": { + "name": "Elastic License 2.0", + "url": "https://www.elastic.co/licensing/elastic-license" + } + }, + "tags": [ + { + "name": "cases", + "description": "Case APIs enable you to open and track issues." + }, + { + "name": "kibana", + "description": "Kibana APIs enable you to interact with Kibana features." + } + ], + "servers": [ + { + "url": "http://localhost:5601", + "description": "local" + } + ], + "paths": { + "/api/cases": { + "post": { + "description": "Creates a case. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "An object that contains the connector configuration.", + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "description": { + "description": "The description for the case.", + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "description": "An object that contains the case settings.", + "type": "object", + "properties": { + "syncAlerts": { + "description": "Turns alert syncing on or off.", + "type": "boolean" + } + } + }, + "tags": { + "description": "The words and phrases that help categorize cases. It can be an empty array.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "A title for the case.", + "type": "string" + } + }, + "required": [ + "connector", + "description", + "owner", + "settings", + "tags", + "title" + ] + }, + "examples": { + "createCaseRequest": { + "$ref": "#/components/examples/create_case_request" + } + } + } + } + }, + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "closed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "comments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "connector": { + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2022-05-13T09:16:17.416Z" + }, + "created_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "ahunley@imf.usa.gov" + }, + "full_name": { + "type": "string", + "example": "Alan Hunley" + }, + "username": { + "type": "string", + "example": "ahunley" + } + } + }, + "description": { + "type": "string", + "example": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" + }, + "external_service": { + "type": "object", + "properties": { + "connector_id": { + "type": "string" + }, + "connector_name": { + "type": "string" + }, + "external_id": { + "type": "string" + }, + "external_title": { + "type": "string" + }, + "external_url": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "pushed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + } + } + }, + "id": { + "type": "string", + "example": "66b9aa00-94fa-11ea-9f74-e7e108796192" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "type": "object", + "properties": { + "syncAlerts": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "phishing", + "social engineering", + "bubblegum" + ] + }, + "title": { + "type": "string", + "example": "This case will self-destruct in 5 seconds" + }, + "totalAlerts": { + "type": "integer", + "example": 0 + }, + "totalComment": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "updated_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "version": { + "type": "string", + "example": "WzUzMiwxXQ==" + } + } + }, + "examples": { + "createCaseResponse": { + "$ref": "#/components/examples/create_case_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "delete": { + "description": "Deletes one or more cases. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "name": "ids", + "description": "The cases that you want to removed. To retrieve case IDs, use the find cases API. All non-ASCII characters must be URL encoded.", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "example": "d4e7abb0-b462-11ec-9a8d-698504725a43" + } + ], + "responses": { + "204": { + "description": "Indicates a successful call." + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "patch": { + "description": "Updates one or more cases. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're updating.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connector": { + "description": "An object that contains the connector configuration.", + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "description": { + "description": "The description for the case.", + "type": "string" + }, + "id": { + "description": "The identifier for the case.", + "type": "string" + }, + "settings": { + "description": "An object that contains the case settings.", + "type": "object", + "properties": { + "syncAlerts": { + "description": "Turns alert syncing on or off.", + "type": "boolean" + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "description": "The words and phrases that help categorize cases.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "A title for the case.", + "type": "string" + }, + "version": { + "description": "The current version of the case.", + "type": "string" + } + }, + "required": [ + "id", + "version" + ] + } + } + } + }, + "examples": { + "updateCaseRequest": { + "$ref": "#/components/examples/update_case_request" + } + } + } + } + }, + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "closed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "comments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "connector": { + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2022-05-13T09:16:17.416Z" + }, + "created_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "ahunley@imf.usa.gov" + }, + "full_name": { + "type": "string", + "example": "Alan Hunley" + }, + "username": { + "type": "string", + "example": "ahunley" + } + } + }, + "description": { + "type": "string", + "example": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" + }, + "external_service": { + "type": "object", + "properties": { + "connector_id": { + "type": "string" + }, + "connector_name": { + "type": "string" + }, + "external_id": { + "type": "string" + }, + "external_title": { + "type": "string" + }, + "external_url": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "pushed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + } + } + }, + "id": { + "type": "string", + "example": "66b9aa00-94fa-11ea-9f74-e7e108796192" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "type": "object", + "properties": { + "syncAlerts": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "phishing", + "social engineering", + "bubblegum" + ] + }, + "title": { + "type": "string", + "example": "This case will self-destruct in 5 seconds" + }, + "totalAlerts": { + "type": "integer", + "example": 0 + }, + "totalComment": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "updated_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "version": { + "type": "string", + "example": "WzUzMiwxXQ==" + } + } + }, + "examples": { + "updateCaseResponse": { + "$ref": "#/components/examples/update_case_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "/s/{spaceId}/api/cases": { + "post": { + "description": "Creates a case. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "$ref": "#/components/parameters/space_id" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "An object that contains the connector configuration.", + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "description": { + "description": "The description for the case.", + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "description": "An object that contains the case settings.", + "type": "object", + "properties": { + "syncAlerts": { + "description": "Turns alert syncing on or off.", + "type": "boolean" + } + } + }, + "tags": { + "description": "The words and phrases that help categorize cases. It can be an empty array.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "A title for the case.", + "type": "string" + } + }, + "required": [ + "connector", + "description", + "owner", + "settings", + "tags", + "title" + ] + }, + "examples": { + "createCaseRequest": { + "$ref": "#/components/examples/create_case_request" + } + } + } + } + }, + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "closed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "comments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "connector": { + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2022-05-13T09:16:17.416Z" + }, + "created_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "ahunley@imf.usa.gov" + }, + "full_name": { + "type": "string", + "example": "Alan Hunley" + }, + "username": { + "type": "string", + "example": "ahunley" + } + } + }, + "description": { + "type": "string", + "example": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" + }, + "external_service": { + "type": "object", + "properties": { + "connector_id": { + "type": "string" + }, + "connector_name": { + "type": "string" + }, + "external_id": { + "type": "string" + }, + "external_title": { + "type": "string" + }, + "external_url": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "pushed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + } + } + }, + "id": { + "type": "string", + "example": "66b9aa00-94fa-11ea-9f74-e7e108796192" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "type": "object", + "properties": { + "syncAlerts": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "phishing", + "social engineering", + "bubblegum" + ] + }, + "title": { + "type": "string", + "example": "This case will self-destruct in 5 seconds" + }, + "totalAlerts": { + "type": "integer", + "example": 0 + }, + "totalComment": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "updated_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "version": { + "type": "string", + "example": "WzUzMiwxXQ==" + } + } + }, + "examples": { + "createCaseResponse": { + "$ref": "#/components/examples/create_case_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "delete": { + "description": "Deletes one or more cases. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "$ref": "#/components/parameters/space_id" + }, + { + "name": "ids", + "description": "The cases that you want to removed. All non-ASCII characters must be URL encoded.", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "example": "d4e7abb0-b462-11ec-9a8d-698504725a43" + } + ], + "responses": { + "204": { + "description": "Indicates a successful call." + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "patch": { + "description": "Updates one or more cases. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're updating.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "$ref": "#/components/parameters/space_id" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connector": { + "description": "An object that contains the connector configuration.", + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "description": { + "description": "The description for the case.", + "type": "string" + }, + "id": { + "description": "The identifier for the case.", + "type": "string" + }, + "settings": { + "description": "An object that contains the case settings.", + "type": "object", + "properties": { + "syncAlerts": { + "description": "Turns alert syncing on or off.", + "type": "boolean" + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "description": "The words and phrases that help categorize cases.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "A title for the case.", + "type": "string" + }, + "version": { + "description": "The current version of the case.", + "type": "string" + } + }, + "required": [ + "id", + "version" + ] + } + } + } + }, + "examples": { + "updateCaseRequest": { + "$ref": "#/components/examples/update_case_request" + } + } + } + } + }, + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "closed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "comments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "connector": { + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2022-05-13T09:16:17.416Z" + }, + "created_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "ahunley@imf.usa.gov" + }, + "full_name": { + "type": "string", + "example": "Alan Hunley" + }, + "username": { + "type": "string", + "example": "ahunley" + } + } + }, + "description": { + "type": "string", + "example": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" + }, + "external_service": { + "type": "object", + "properties": { + "connector_id": { + "type": "string" + }, + "connector_name": { + "type": "string" + }, + "external_id": { + "type": "string" + }, + "external_title": { + "type": "string" + }, + "external_url": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "pushed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + } + } + }, + "id": { + "type": "string", + "example": "66b9aa00-94fa-11ea-9f74-e7e108796192" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "type": "object", + "properties": { + "syncAlerts": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "phishing", + "social engineering", + "bubblegum" + ] + }, + "title": { + "type": "string", + "example": "This case will self-destruct in 5 seconds" + }, + "totalAlerts": { + "type": "integer", + "example": 0 + }, + "totalComment": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "updated_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "version": { + "type": "string", + "example": "WzUzMiwxXQ==" + } + } + }, + "examples": { + "updateCaseResponse": { + "$ref": "#/components/examples/update_case_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + } + }, + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "apiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "ApiKey" + } + }, + "parameters": { + "kbn_xsrf": { + "schema": { + "type": "string" + }, + "in": "header", + "name": "kbn-xsrf", + "required": true + }, + "space_id": { + "in": "path", + "name": "spaceId", + "description": "An identifier for the space.", + "required": true, + "schema": { + "type": "string", + "example": "default" + } + } + }, + "schemas": { + "connector_types": { + "type": "string", + "description": "The type of connector.", + "enum": [ + ".jira", + ".none", + ".resilient", + ".servicenow", + ".servicenow-sir", + ".swimlane" + ] + }, + "owners": { + "type": "string", + "description": "Owner apps", + "enum": [ + "cases", + "observability", + "securitySolution" + ] + }, + "status": { + "type": "string", + "description": "The status of the case.", + "enum": [ + "closed", + "in-progress", + "open" + ] + } + }, + "examples": { + "create_case_request": { + "summary": "Create a security case that uses a Jira connector.", + "value": { + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants.", + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering" + ], + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": "High", + "parent": null + } + }, + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution" + } + }, + "create_case_response": { + "summary": "The create case API returns a JSON object that includes the user who created the case and the case identifier, version, and creation time.", + "value": { + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", + "version": "WzUzMiwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution", + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": null, + "updated_by": null, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": "High" + } + }, + "external_service": null + } + }, + "update_case_request": { + "summary": "Update the case description, tags, and connector.", + "value": { + "cases": [ + { + "id": "a18b38a0-71b0-11ea-a0b2-c51ea50a58e2", + "version": "WzIzLDFd", + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": null, + "parent": null + } + }, + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + } + } + ] + } + }, + "update_case_response": { + "summary": "This is an example response when the case description, tags, and connector were updated.", + "value": [ + { + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", + "version": "WzU0OCwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution", + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": "2022-05-13T09:48:33.043Z", + "updated_by": { + "email": "classified@hms.oo.gov.uk", + "full_name": "Classified", + "username": "M" + }, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": null + } + }, + "external_service": { + "external_title": "IS-4", + "pushed_by": { + "full_name": "Classified", + "email": "classified@hms.oo.gov.uk", + "username": "M" + }, + "external_url": "https://hms.atlassian.net/browse/IS-4", + "pushed_at": "2022-05-13T09:20:40.672Z", + "connector_id": "05da469f-1fde-4058-99a3-91e4807e2de8", + "external_id": "10003", + "connector_name": "Jira" + } + } + ] + } + } + }, + "security": [ + { + "basicAuth": [] + }, + { + "apiKeyAuth": [] + } + ] +} \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml new file mode 100644 index 0000000000000..afad92f489a74 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -0,0 +1,1811 @@ +openapi: 3.0.1 +info: + title: Cases + description: OpenAPI schema for Cases endpoints + version: '0.1' + contact: + name: Cases Team + license: + name: Elastic License 2.0 + url: https://www.elastic.co/licensing/elastic-license +tags: + - name: cases + description: Case APIs enable you to open and track issues. + - name: kibana + description: Kibana APIs enable you to interact with Kibana features. +servers: + - url: http://localhost:5601 + description: local +paths: + /api/cases: + post: + description: > + Creates a case. You must have all privileges for the **Cases** feature + in the **Management**, **Observability**, or **Security** section of the + Kibana feature privileges, depending on the owner of the case you're + creating. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + content: + application/json: + schema: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM and + ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type is + sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM Resilient + connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for ServiceNow + SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow ITSM + connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + description: + description: The description for the case. + type: string + owner: + $ref: '#/components/schemas/owners' + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + tags: + description: >- + The words and phrases that help categorize cases. It can be + an empty array. + type: array + items: + type: string + title: + description: A title for the case. + type: string + required: + - connector + - description + - owner + - settings + - tags + - title + examples: + createCaseRequest: + $ref: '#/components/examples/create_case_request' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + closed_at: + type: string + format: date-time + nullable: true + example: null + closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + comments: + type: array + items: + type: string + example: [] + connector: + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM + and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type + is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + created_at: + type: string + format: date-time + example: '2022-05-13T09:16:17.416Z' + created_by: + type: object + properties: + email: + type: string + example: ahunley@imf.usa.gov + full_name: + type: string + example: Alan Hunley + username: + type: string + example: ahunley + description: + type: string + example: >- + James Bond clicked on a highly suspicious email banner + advertising cheap holidays for underpaid civil servants. + Operation bubblegum is active. Repeat - operation + bubblegum is now active + external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + id: + type: string + example: 66b9aa00-94fa-11ea-9f74-e7e108796192 + owner: + $ref: '#/components/schemas/owners' + settings: + type: object + properties: + syncAlerts: + type: boolean + example: true + status: + $ref: '#/components/schemas/status' + tags: + type: array + items: + type: string + example: + - phishing + - social engineering + - bubblegum + title: + type: string + example: This case will self-destruct in 5 seconds + totalAlerts: + type: integer + example: 0 + totalComment: + type: integer + example: 0 + updated_at: + type: string + format: date-time + nullable: true + example: null + updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + version: + type: string + example: WzUzMiwxXQ== + examples: + createCaseResponse: + $ref: '#/components/examples/create_case_response' + servers: + - url: https://localhost:5601 + delete: + description: > + Deletes one or more cases. You must have all privileges for the + **Cases** feature in the **Management**, **Observability**, or + **Security** section of the Kibana feature privileges, depending on the + owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - name: ids + description: >- + The cases that you want to removed. To retrieve case IDs, use the + find cases API. All non-ASCII characters must be URL encoded. + in: query + required: true + schema: + type: string + example: d4e7abb0-b462-11ec-9a8d-698504725a43 + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + patch: + description: > + Updates one or more cases. You must have all privileges for the + **Cases** feature in the **Management**, **Observability**, or + **Security** section of the Kibana feature privileges, depending on the + owner of the case you're updating. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + content: + application/json: + schema: + type: object + properties: + cases: + type: array + items: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + fields: + description: >- + An object containing the connector fields. To + create a case without a connector, specify null. + If you want to omit any individual field, specify + null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow + ITSM and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: >- + The type of incident for IBM Resilient + connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue + type is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and + ServiceNow SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow + ITSM connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution + can be delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case + without a connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + description: + description: The description for the case. + type: string + id: + description: The identifier for the case. + type: string + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + status: + $ref: '#/components/schemas/status' + tags: + description: The words and phrases that help categorize cases. + type: array + items: + type: string + title: + description: A title for the case. + type: string + version: + description: The current version of the case. + type: string + required: + - id + - version + examples: + updateCaseRequest: + $ref: '#/components/examples/update_case_request' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + closed_at: + type: string + format: date-time + nullable: true + example: null + closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + comments: + type: array + items: + type: string + example: [] + connector: + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM + and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type + is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + created_at: + type: string + format: date-time + example: '2022-05-13T09:16:17.416Z' + created_by: + type: object + properties: + email: + type: string + example: ahunley@imf.usa.gov + full_name: + type: string + example: Alan Hunley + username: + type: string + example: ahunley + description: + type: string + example: >- + James Bond clicked on a highly suspicious email banner + advertising cheap holidays for underpaid civil servants. + Operation bubblegum is active. Repeat - operation + bubblegum is now active + external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + id: + type: string + example: 66b9aa00-94fa-11ea-9f74-e7e108796192 + owner: + $ref: '#/components/schemas/owners' + settings: + type: object + properties: + syncAlerts: + type: boolean + example: true + status: + $ref: '#/components/schemas/status' + tags: + type: array + items: + type: string + example: + - phishing + - social engineering + - bubblegum + title: + type: string + example: This case will self-destruct in 5 seconds + totalAlerts: + type: integer + example: 0 + totalComment: + type: integer + example: 0 + updated_at: + type: string + format: date-time + nullable: true + example: null + updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + version: + type: string + example: WzUzMiwxXQ== + examples: + updateCaseResponse: + $ref: '#/components/examples/update_case_response' + servers: + - url: https://localhost:5601 + servers: + - url: https://localhost:5601 + /s/{spaceId}/api/cases: + post: + description: > + Creates a case. You must have all privileges for the **Cases** feature + in the **Management**, **Observability**, or **Security** section of the + Kibana feature privileges, depending on the owner of the case you're + creating. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/components/parameters/space_id' + requestBody: + content: + application/json: + schema: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM and + ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type is + sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM Resilient + connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for ServiceNow + SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow ITSM + connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + description: + description: The description for the case. + type: string + owner: + $ref: '#/components/schemas/owners' + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + tags: + description: >- + The words and phrases that help categorize cases. It can be + an empty array. + type: array + items: + type: string + title: + description: A title for the case. + type: string + required: + - connector + - description + - owner + - settings + - tags + - title + examples: + createCaseRequest: + $ref: '#/components/examples/create_case_request' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + closed_at: + type: string + format: date-time + nullable: true + example: null + closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + comments: + type: array + items: + type: string + example: [] + connector: + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM + and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type + is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + created_at: + type: string + format: date-time + example: '2022-05-13T09:16:17.416Z' + created_by: + type: object + properties: + email: + type: string + example: ahunley@imf.usa.gov + full_name: + type: string + example: Alan Hunley + username: + type: string + example: ahunley + description: + type: string + example: >- + James Bond clicked on a highly suspicious email banner + advertising cheap holidays for underpaid civil servants. + Operation bubblegum is active. Repeat - operation + bubblegum is now active + external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + id: + type: string + example: 66b9aa00-94fa-11ea-9f74-e7e108796192 + owner: + $ref: '#/components/schemas/owners' + settings: + type: object + properties: + syncAlerts: + type: boolean + example: true + status: + $ref: '#/components/schemas/status' + tags: + type: array + items: + type: string + example: + - phishing + - social engineering + - bubblegum + title: + type: string + example: This case will self-destruct in 5 seconds + totalAlerts: + type: integer + example: 0 + totalComment: + type: integer + example: 0 + updated_at: + type: string + format: date-time + nullable: true + example: null + updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + version: + type: string + example: WzUzMiwxXQ== + examples: + createCaseResponse: + $ref: '#/components/examples/create_case_response' + servers: + - url: https://localhost:5601 + delete: + description: > + Deletes one or more cases. You must have all privileges for the + **Cases** feature in the **Management**, **Observability**, or + **Security** section of the Kibana feature privileges, depending on the + owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/components/parameters/space_id' + - name: ids + description: >- + The cases that you want to removed. All non-ASCII characters must be + URL encoded. + in: query + required: true + schema: + type: string + example: d4e7abb0-b462-11ec-9a8d-698504725a43 + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + patch: + description: > + Updates one or more cases. You must have all privileges for the + **Cases** feature in the **Management**, **Observability**, or + **Security** section of the Kibana feature privileges, depending on the + owner of the case you're updating. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/components/parameters/space_id' + requestBody: + content: + application/json: + schema: + type: object + properties: + cases: + type: array + items: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + fields: + description: >- + An object containing the connector fields. To + create a case without a connector, specify null. + If you want to omit any individual field, specify + null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow + ITSM and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: >- + The type of incident for IBM Resilient + connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue + type is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and + ServiceNow SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow + ITSM connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution + can be delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case + without a connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + description: + description: The description for the case. + type: string + id: + description: The identifier for the case. + type: string + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + status: + $ref: '#/components/schemas/status' + tags: + description: The words and phrases that help categorize cases. + type: array + items: + type: string + title: + description: A title for the case. + type: string + version: + description: The current version of the case. + type: string + required: + - id + - version + examples: + updateCaseRequest: + $ref: '#/components/examples/update_case_request' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + closed_at: + type: string + format: date-time + nullable: true + example: null + closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + comments: + type: array + items: + type: string + example: [] + connector: + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM + and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type + is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + created_at: + type: string + format: date-time + example: '2022-05-13T09:16:17.416Z' + created_by: + type: object + properties: + email: + type: string + example: ahunley@imf.usa.gov + full_name: + type: string + example: Alan Hunley + username: + type: string + example: ahunley + description: + type: string + example: >- + James Bond clicked on a highly suspicious email banner + advertising cheap holidays for underpaid civil servants. + Operation bubblegum is active. Repeat - operation + bubblegum is now active + external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + id: + type: string + example: 66b9aa00-94fa-11ea-9f74-e7e108796192 + owner: + $ref: '#/components/schemas/owners' + settings: + type: object + properties: + syncAlerts: + type: boolean + example: true + status: + $ref: '#/components/schemas/status' + tags: + type: array + items: + type: string + example: + - phishing + - social engineering + - bubblegum + title: + type: string + example: This case will self-destruct in 5 seconds + totalAlerts: + type: integer + example: 0 + totalComment: + type: integer + example: 0 + updated_at: + type: string + format: date-time + nullable: true + example: null + updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + version: + type: string + example: WzUzMiwxXQ== + examples: + updateCaseResponse: + $ref: '#/components/examples/update_case_response' + servers: + - url: https://localhost:5601 + servers: + - url: https://localhost:5601 +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + apiKeyAuth: + type: apiKey + in: header + name: ApiKey + parameters: + kbn_xsrf: + schema: + type: string + in: header + name: kbn-xsrf + required: true + space_id: + in: path + name: spaceId + description: An identifier for the space. + required: true + schema: + type: string + example: default + schemas: + connector_types: + type: string + description: The type of connector. + enum: + - .jira + - .none + - .resilient + - .servicenow + - .servicenow-sir + - .swimlane + owners: + type: string + description: Owner apps + enum: + - cases + - observability + - securitySolution + status: + type: string + description: The status of the case. + enum: + - closed + - in-progress + - open + examples: + create_case_request: + summary: Create a security case that uses a Jira connector. + value: + description: >- + James Bond clicked on a highly suspicious email banner advertising + cheap holidays for underpaid civil servants. + title: This case will self-destruct in 5 seconds + tags: + - phishing + - social engineering + connector: + id: 131d4448-abe0-4789-939d-8ef60680b498 + name: My connector + type: .jira + fields: + issueType: '10006' + priority: High + parent: null + settings: + syncAlerts: true + owner: securitySolution + create_case_response: + summary: >- + The create case API returns a JSON object that includes the user who + created the case and the case identifier, version, and creation time. + value: + id: 66b9aa00-94fa-11ea-9f74-e7e108796192 + version: WzUzMiwxXQ== + comments: [] + totalComment: 0 + totalAlerts: 0 + title: This case will self-destruct in 5 seconds + tags: + - phishing + - social engineering + - bubblegum + settings: + syncAlerts: true + owner: securitySolution + description: >- + James Bond clicked on a highly suspicious email banner advertising + cheap holidays for underpaid civil servants. Operation bubblegum is + active. Repeat - operation bubblegum is now active + closed_at: null + closed_by: null + created_at: '2022-05-13T09:16:17.416Z' + created_by: + email: ahunley@imf.usa.gov + full_name: Alan Hunley + username: ahunley + status: open + updated_at: null + updated_by: null + connector: + id: 131d4448-abe0-4789-939d-8ef60680b498 + name: My connector + type: .jira + fields: + issueType: '10006' + parent: null + priority: High + external_service: null + update_case_request: + summary: Update the case description, tags, and connector. + value: + cases: + - id: a18b38a0-71b0-11ea-a0b2-c51ea50a58e2 + version: WzIzLDFd + connector: + id: 131d4448-abe0-4789-939d-8ef60680b498 + name: My connector + type: .jira + fields: + issueType: '10006' + priority: null + parent: null + description: >- + James Bond clicked on a highly suspicious email banner advertising + cheap holidays for underpaid civil servants. Operation bubblegum + is active. Repeat - operation bubblegum is now active! + tags: + - phishing + - social engineering + - bubblegum + settings: + syncAlerts: true + update_case_response: + summary: >- + This is an example response when the case description, tags, and + connector were updated. + value: + - id: 66b9aa00-94fa-11ea-9f74-e7e108796192 + version: WzU0OCwxXQ== + comments: [] + totalComment: 0 + totalAlerts: 0 + title: This case will self-destruct in 5 seconds + tags: + - phishing + - social engineering + - bubblegum + settings: + syncAlerts: true + owner: securitySolution + description: >- + James Bond clicked on a highly suspicious email banner advertising + cheap holidays for underpaid civil servants. Operation bubblegum is + active. Repeat - operation bubblegum is now active! + closed_at: null + closed_by: null + created_at: '2022-05-13T09:16:17.416Z' + created_by: + email: ahunley@imf.usa.gov + full_name: Alan Hunley + username: ahunley + status: open + updated_at: '2022-05-13T09:48:33.043Z' + updated_by: + email: classified@hms.oo.gov.uk + full_name: Classified + username: M + connector: + id: 131d4448-abe0-4789-939d-8ef60680b498 + name: My connector + type: .jira + fields: + issueType: '10006' + parent: null + priority: null + external_service: + external_title: IS-4 + pushed_by: + full_name: Classified + email: classified@hms.oo.gov.uk + username: M + external_url: https://hms.atlassian.net/browse/IS-4 + pushed_at: '2022-05-13T09:20:40.672Z' + connector_id: 05da469f-1fde-4058-99a3-91e4807e2de8 + external_id: '10003' + connector_name: Jira +security: + - basicAuth: [] + - apiKeyAuth: [] diff --git a/x-pack/plugins/cases/docs/openapi/components/README.md b/x-pack/plugins/cases/docs/openapi/components/README.md new file mode 100644 index 0000000000000..0841562a33150 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/README.md @@ -0,0 +1,7 @@ +Reusable components +=========== + + - `examples` - reusable [Example objects](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#exampleObject) + - `headers` - reusable [Header objects](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#headerObject) + - `parameters` - reusable [Parameter objects](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameterObject) + - `schemas` - reusable [Schema objects](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#schemaObject) diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_request.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_request.yaml new file mode 100644 index 0000000000000..0659ed18a8569 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_request.yaml @@ -0,0 +1,21 @@ +summary: Create a security case that uses a Jira connector. +value: + { + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants.", + "title": "This case will self-destruct in 5 seconds", + "tags": [ "phishing","social engineering"], + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": "High", + "parent": null + } + }, + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution" + } diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml new file mode 100644 index 0000000000000..f9f2ce3d61beb --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml @@ -0,0 +1,42 @@ +summary: The create case API returns a JSON object that includes the user who created the case and the case identifier, version, and creation time. +value: + { + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", + "version": "WzUzMiwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution", + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": null, + "updated_by": null, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": "High" + } + }, + "external_service": null + } diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_request.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_request.yaml new file mode 100644 index 0000000000000..7ecb306cf0735 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_request.yaml @@ -0,0 +1,29 @@ +summary: Update the case description, tags, and connector. +value: + { + "cases": [ + { + "id": "a18b38a0-71b0-11ea-a0b2-c51ea50a58e2", + "version": "WzIzLDFd", + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": null, + "parent": null + } + }, + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + } + } + ] +} \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml new file mode 100644 index 0000000000000..a73191868c8ee --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml @@ -0,0 +1,60 @@ +summary: This is an example response when the case description, tags, and connector were updated. +value: + [ + { + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", + "version": "WzU0OCwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution", + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": "2022-05-13T09:48:33.043Z", + "updated_by": { + "email": "classified@hms.oo.gov.uk", + "full_name": "Classified", + "username": "M" + }, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": null, + } + }, + "external_service": { + "external_title": "IS-4", + "pushed_by": { + "full_name": "Classified", + "email": "classified@hms.oo.gov.uk", + "username": "M" + }, + "external_url": "https://hms.atlassian.net/browse/IS-4", + "pushed_at": "2022-05-13T09:20:40.672Z", + "connector_id": "05da469f-1fde-4058-99a3-91e4807e2de8", + "external_id": "10003", + "connector_name": "Jira" + } + } + ] \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/headers/kbn_xsrf.yaml b/x-pack/plugins/cases/docs/openapi/components/headers/kbn_xsrf.yaml new file mode 100644 index 0000000000000..3d8dfae634e68 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/headers/kbn_xsrf.yaml @@ -0,0 +1,5 @@ +schema: + type: string +in: header +name: kbn-xsrf +required: true diff --git a/x-pack/plugins/cases/docs/openapi/components/parameters/space_id.yaml b/x-pack/plugins/cases/docs/openapi/components/parameters/space_id.yaml new file mode 100644 index 0000000000000..0ff325b08a082 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/parameters/space_id.yaml @@ -0,0 +1,7 @@ +in: path +name: spaceId +description: An identifier for the space. +required: true +schema: + type: string + example: default diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml new file mode 100644 index 0000000000000..780496f1591b4 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml @@ -0,0 +1,117 @@ +closed_at: + type: string + format: date-time + nullable: true + example: null +closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null +comments: + type: array + items: + type: string + example: [] +connector: + type: object + properties: + $ref: 'connector_properties.yaml' +created_at: + type: string + format: date-time + example: "2022-05-13T09:16:17.416Z" +created_by: + type: object + properties: + email: + type: string + example: "ahunley@imf.usa.gov" + full_name: + type: string + example: "Alan Hunley" + username: + type: string + example: "ahunley" +description: + type: string + example: "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active" +external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null +id: + type: string + example: "66b9aa00-94fa-11ea-9f74-e7e108796192" +owner: + $ref: 'owners.yaml' +settings: + type: object + properties: + syncAlerts: + type: boolean + example: true +status: + $ref: 'status.yaml' +tags: + type: array + items: + type: string + example: ["phishing","social engineering","bubblegum"] +title: + type: string + example: "This case will self-destruct in 5 seconds" +totalAlerts: + type: integer + example: 0 +totalComment: + type: integer + example: 0 +updated_at: + type: string + format: date-time + nullable: true + example: null +updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null +version: + type: string + example: "WzUzMiwxXQ==" diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/closure_types.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/closure_types.yaml new file mode 100644 index 0000000000000..f09063d0db18f --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/closure_types.yaml @@ -0,0 +1,5 @@ +type: string +description: Indicates whether a case is automatically closed when it is pushed to external systems (`close-by-pushing`) or not automatically closed (`close-by-user`). +enum: + - close-by-pushing + - close-by-user \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/comment_types.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/comment_types.yaml new file mode 100644 index 0000000000000..a6a86ae163b20 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/comment_types.yaml @@ -0,0 +1,5 @@ +type: string +description: The type of comment. +enum: + - alert + - user \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/connector_properties.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/connector_properties.yaml new file mode 100644 index 0000000000000..c2bc2ab7c887a --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/connector_properties.yaml @@ -0,0 +1,65 @@ +fields: + description: An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors. + type: string + destIp: + description: A comma-separated list of destination IPs for ServiceNow SecOps connectors. + type: string + impact: + description: The effect an incident had on business for ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: A comma-separated list of malware hashes for ServiceNow SecOps connectors. + type: string + malwareUrl: + description: A comma-separated list of malware URLs for ServiceNow SecOps connectors. + type: string + parent: + description: The key of the parent issue, when the issue type is sub-task for Jira connectors. + type: string + priority: + description: The priority of the issue for Jira and ServiceNow SecOps connectors. + type: string + severity: + description: The severity of the incident for ServiceNow ITSM connectors. + type: string + severityCode: + description: The severity code of the incident for IBM Resilient connectors. + type: number + sourceIp: + description: A comma-separated list of source IPs for ServiceNow SecOps connectors. + type: string + subcategory: + description: The subcategory of the incident for ServiceNow ITSM connectors. + type: string + urgency: + description: The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type +id: + description: The identifier for the connector. To create a case without a connector, use `none`. + type: string +name: + description: The name of the connector. To create a case without a connector, use `none`. + type: string +type: + $ref: 'connector_types.yaml' \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/connector_types.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/connector_types.yaml new file mode 100644 index 0000000000000..24c1ec5880828 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/connector_types.yaml @@ -0,0 +1,9 @@ +type: string +description: The type of connector. +enum: + - .jira + - .none + - .resilient + - .servicenow + - .servicenow-sir + - .swimlane \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/owners.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/owners.yaml new file mode 100644 index 0000000000000..f39324a36e702 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/owners.yaml @@ -0,0 +1,6 @@ +type: string +description: Owner apps +enum: + - cases + - observability + - securitySolution \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/status.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/status.yaml new file mode 100644 index 0000000000000..1fe2e342dd776 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/status.yaml @@ -0,0 +1,6 @@ +type: string +description: The status of the case. +enum: + - closed + - in-progress + - open \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/entrypoint.yaml b/x-pack/plugins/cases/docs/openapi/entrypoint.yaml new file mode 100644 index 0000000000000..14155c156b0cc --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/entrypoint.yaml @@ -0,0 +1,92 @@ +openapi: 3.0.1 +info: + title: Cases + description: OpenAPI schema for Cases endpoints + version: '0.1' + contact: + name: Cases Team + license: + name: Elastic License 2.0 + url: https://www.elastic.co/licensing/elastic-license +tags: + - name: cases + description: Case APIs enable you to open and track issues. + - name: kibana + description: Kibana APIs enable you to interact with Kibana features. +servers: + - url: 'http://localhost:5601' + description: local +paths: + /api/cases: + $ref: paths/api@cases.yaml +# /api/cases/_find: +# $ref: paths/api@cases@_find.yaml +# '/api/cases/alerts/{alertId}': +# $ref: 'paths/api@cases@alerts@{alertid}.yaml' +# '/api/cases/configure': +# $ref: paths/api@cases@configure.yaml +# '/api/cases/configure/{configurationId}': +# $ref: paths/api@cases@configure@{configurationid}.yaml +# '/api/cases/configure/connectors/_find': +# $ref: paths/api@cases@configure@connectors@_find.yaml +# '/api/cases/reporters': +# $ref: 'paths/api@cases@reporters.yaml' +# '/api/cases/status': +# $ref: 'paths/api@cases@status.yaml' +# '/api/cases/tags': +# $ref: 'paths/api@cases@tags.yaml' +# '/api/cases/{caseId}': +# $ref: 'paths/api@cases@{caseid}.yaml' +# '/api/cases/{caseId}/alerts': +# $ref: 'paths/api@cases@{caseid}@alerts.yaml' +# '/api/cases/{caseId}/comments': +# $ref: 'paths/api@cases@{caseid}@comments.yaml' +# '/api/cases/{caseId}/comments/{commentId}': +# $ref: 'paths/api@cases@{caseid}@comments@{commentid}.yaml' +# '/api/cases/{caseId}/connector/{connectorId}/_push': +# $ref: 'paths/api@cases@{caseid}@connector@{connectorid}@_push.yaml' +# '/api/cases/{caseId}/user_actions': +# $ref: 'paths/api@cases@{caseid}@user_actions.yaml' + + '/s/{spaceId}/api/cases': + $ref: 'paths/s@{spaceid}@api@cases.yaml' + # '/s/{spaceId}/api/cases/_find': + # $ref: 'paths/s@{spaceid}@api@cases@_find.yaml' + # '/s/{spaceId}/api/cases/alerts/{alertId}': + # $ref: 'paths/s@{spaceid}@api@cases@alerts@{alertid}.yaml' + # '/s/{spaceId}/api/cases/configure': + # $ref: paths/s@{spaceid}@api@cases@configure.yaml + # '/s/{spaceId}/api/cases/configure/{configurationId}': + # $ref: paths/s@{spaceid}@api@cases@configure@{configurationid}.yaml + # '/s/{spaceId}/api/cases/configure/connectors/_find': + # $ref: paths/s@{spaceid}@api@cases@configure@connectors@_find.yaml + # '/s/{spaceId}/api/cases/reporters': + # $ref: 'paths/s@{spaceid}@api@cases@reporters.yaml' + # '/s/{spaceId}/api/cases/status': + # $ref: 'paths/s@{spaceid}@api@cases@status.yaml' + # '/s/{spaceId}/api/cases/tags': + # $ref: 'paths/s@{spaceid}@api@cases@tags.yaml' + # '/s/{spaceId}/api/cases/{caseId}': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}.yaml' + # '/s/{spaceId}/api/cases/{caseId}/alerts': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@alerts.yaml' + # '/s/{spaceId}/api/cases/{caseId}/comments': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@comments.yaml' + # '/s/{spaceId}/api/cases/{caseId}/comments/{commentId}': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@comments@{commentid}.yaml' + # '/s/{spaceId}/api/cases/{caseId}/connector/{connectorId}/_push': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@connector@{connectorid}@_push.yaml' + # '/s/{spaceId}/api/cases/{caseId}/user_actions': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@user_actions.yaml' +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + apiKeyAuth: + type: apiKey + in: header + name: ApiKey +security: + - basicAuth: [] + - apiKeyAuth: [] diff --git a/x-pack/plugins/cases/docs/openapi/paths/README.md b/x-pack/plugins/cases/docs/openapi/paths/README.md new file mode 100644 index 0000000000000..b7818c8474fc8 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/paths/README.md @@ -0,0 +1,10 @@ +Paths +===== + +Each path definition for which there is a specification exists within this folder. + +These files currently use the following conventions: + +* path separator token (e.g. `@`) is included in the file name +* path parameter (e.g. `{example}`) is included in the file name +* there is one file per path; each file can contain multiple operations diff --git a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml new file mode 100644 index 0000000000000..c37bb3ecef645 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml @@ -0,0 +1,161 @@ +post: + description: > + Creates a case. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + $ref: '../components/schemas/connector_properties.yaml' + description: + description: The description for the case. + type: string + owner: + $ref: '../components/schemas/owners.yaml' + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + tags: + description: The words and phrases that help categorize cases. It can be an empty array. + type: array + items: + type: string + title: + description: A title for the case. + type: string + required: + - connector + - description + - owner + - settings + - tags + - title + examples: + createCaseRequest: + $ref: '../components/examples/create_case_request.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + $ref: '../components/schemas/case_response_properties.yaml' + examples: + createCaseResponse: + $ref: '../components/examples/create_case_response.yaml' + servers: + - url: https://localhost:5601 + +delete: + description: > + Deletes one or more cases. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + - name: ids + description: The cases that you want to removed. To retrieve case IDs, use the find cases API. All non-ASCII characters must be URL encoded. + in: query + required: true + schema: + type: string + example: 'd4e7abb0-b462-11ec-9a8d-698504725a43' + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + +patch: + description: > + Updates one or more cases. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're updating. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + cases: + type: array + items: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + $ref: '../components/schemas/connector_properties.yaml' + description: + description: The description for the case. + type: string + id: + description: The identifier for the case. + type: string + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + status: + $ref: '../components/schemas/status.yaml' + tags: + description: The words and phrases that help categorize cases. + type: array + items: + type: string + title: + description: A title for the case. + type: string + version: + description: The current version of the case. + type: string + required: + - id + - version + examples: + updateCaseRequest: + $ref: '../components/examples/update_case_request.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + $ref: '../components/schemas/case_response_properties.yaml' + examples: + updateCaseResponse: + $ref: '../components/examples/update_case_response.yaml' + servers: + - url: https://localhost:5601 + +servers: + - url: https://localhost:5601 \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml new file mode 100644 index 0000000000000..c03ea64a53538 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml @@ -0,0 +1,164 @@ +post: + description: > + Creates a case. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + - $ref: '../components/parameters/space_id.yaml' + requestBody: + content: + application/json: + schema: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + $ref: '../components/schemas/connector_properties.yaml' + description: + description: The description for the case. + type: string + owner: + $ref: '../components/schemas/owners.yaml' + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + tags: + description: The words and phrases that help categorize cases. It can be an empty array. + type: array + items: + type: string + title: + description: A title for the case. + type: string + required: + - connector + - description + - owner + - settings + - tags + - title + examples: + createCaseRequest: + $ref: '../components/examples/create_case_request.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + $ref: '../components/schemas/case_response_properties.yaml' + examples: + createCaseResponse: + $ref: '../components/examples/create_case_response.yaml' + servers: + - url: https://localhost:5601 + +delete: + description: > + Deletes one or more cases. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + - $ref: '../components/parameters/space_id.yaml' + - name: ids + description: The cases that you want to removed. All non-ASCII characters must be URL encoded. + in: query + required: true + schema: + type: string + example: 'd4e7abb0-b462-11ec-9a8d-698504725a43' + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + +patch: + description: > + Updates one or more cases. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're updating. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + - $ref: '../components/parameters/space_id.yaml' + requestBody: + content: + application/json: + schema: + type: object + properties: + cases: + type: array + items: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + $ref: '../components/schemas/connector_properties.yaml' + description: + description: The description for the case. + type: string + id: + description: The identifier for the case. + type: string + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + status: + $ref: '../components/schemas/status.yaml' + tags: + description: The words and phrases that help categorize cases. + type: array + items: + type: string + title: + description: A title for the case. + type: string + version: + description: The current version of the case. + type: string + required: + - id + - version + examples: + updateCaseRequest: + $ref: '../components/examples/update_case_request.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + $ref: '../components/schemas/case_response_properties.yaml' + examples: + updateCaseResponse: + $ref: '../components/examples/update_case_response.yaml' + servers: + - url: https://localhost:5601 + +servers: + - url: https://localhost:5601 \ No newline at end of file diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index ac2729564b387..50a3c69f2073e 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -35,6 +35,7 @@ import { CreateCaseOwnerSelector } from './owner_selector'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { CaseAttachments } from '../../types'; +import { Severity } from './severity'; interface ContainerProps { big?: boolean; @@ -88,6 +89,9 @@ export const CreateCaseFormFields: React.FC = React.m + + + {canShowCaseSolutionSelection && ( = React.m + ), }), diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 634f518ae5ebd..bfa4f391458da 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -10,7 +10,7 @@ import { mount, ReactWrapper } from 'enzyme'; import { act, RenderResult, waitFor, within } from '@testing-library/react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { CommentType, ConnectorTypes } from '../../../common/api'; +import { CaseSeverity, CommentType, ConnectorTypes } from '../../../common/api'; import { useKibana } from '../../common/lib/kibana'; import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; @@ -182,6 +182,7 @@ describe('Create case', () => { ); expect(renderResult.getByTestId('caseTitle')).toBeTruthy(); + expect(renderResult.getByTestId('caseSeverity')).toBeTruthy(); expect(renderResult.getByTestId('caseDescription')).toBeTruthy(); expect(renderResult.getByTestId('caseTags')).toBeTruthy(); expect(renderResult.getByTestId('caseConnectors')).toBeTruthy(); @@ -208,6 +209,34 @@ describe('Create case', () => { }); }); + it('should post a case on submit click with the selected severity', async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const renderResult = mockedContext.render( + + + + + ); + + await fillFormReactTestingLib(renderResult); + + userEvent.click(renderResult.getByTestId('case-severity-selection')); + expect(renderResult.getByTestId('case-severity-selection-high')).toBeTruthy(); + userEvent.click(renderResult.getByTestId('case-severity-selection-high')); + + userEvent.click(renderResult.getByTestId('create-case-submit')); + await waitFor(() => { + expect(postCase).toBeCalledWith({ + ...sampleData, + severity: CaseSeverity.HIGH, + }); + }); + }); + it('does not submits the title when the length is longer than 64 characters', async () => { const longTitle = 'This is a title that should not be saved as it is longer than 64 characters.'; @@ -285,6 +314,18 @@ describe('Create case', () => { ); }); + it('should select LOW as the default severity', async () => { + const renderResult = mockedContext.render( + + + + + ); + expect(renderResult.getByTestId('caseSeverity')).toBeTruthy(); + // there should be 2 low elements. one for the options popover and one for the displayed one. + expect(renderResult.getAllByTestId('case-severity-selection-low').length).toBe(2); + }); + it('should select the default connector set in the configuration', async () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 4385053a8c8c0..a65e9f5960e9d 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -14,7 +14,7 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service' import { useConnectors } from '../../containers/configure/use_connectors'; import { Case } from '../../containers/types'; -import { NONE_CONNECTOR_ID } from '../../../common/api'; +import { CaseSeverity, NONE_CONNECTOR_ID } from '../../../common/api'; import { UseCreateAttachments, useCreateAttachments, @@ -28,6 +28,7 @@ const initialCaseValue: FormProps = { description: '', tags: [], title: '', + severity: CaseSeverity.LOW, connectorId: NONE_CONNECTOR_ID, fields: null, syncAlerts: true, diff --git a/x-pack/plugins/cases/public/components/create/mock.ts b/x-pack/plugins/cases/public/components/create/mock.ts index 8ab515c79f67e..38d57bf24781e 100644 --- a/x-pack/plugins/cases/public/components/create/mock.ts +++ b/x-pack/plugins/cases/public/components/create/mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CasePostRequest, ConnectorTypes } from '../../../common/api'; +import { CasePostRequest, CaseSeverity, ConnectorTypes } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { choices } from '../connectors/mock'; @@ -13,6 +13,7 @@ export const sampleTags = ['coke', 'pepsi']; export const sampleData: CasePostRequest = { description: 'what a great description', tags: sampleTags, + severity: CaseSeverity.LOW, title: 'what a cool title', connector: { fields: null, diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index b7c363b263998..d72b1cc523f0d 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -17,6 +17,7 @@ import { import * as i18n from './translations'; import { OptionalFieldLabel } from './optional_field_label'; +import { SEVERITY_TITLE } from '../severity/translations'; const { emptyField, maxLengthField } = fieldValidators; export const schemaTags = { @@ -83,6 +84,9 @@ export const schema: FormSchema = { ], }, tags: schemaTags, + severity: { + label: SEVERITY_TITLE, + }, connectorId: { type: FIELD_TYPES.SUPER_SELECT, label: i18n.CONNECTORS, diff --git a/x-pack/plugins/cases/public/components/create/severity.test.tsx b/x-pack/plugins/cases/public/components/create/severity.test.tsx new file mode 100644 index 0000000000000..d2434a37a4392 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/severity.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 { CaseSeverity } from '../../../common/api'; +import React from 'react'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import { Form, FormHook, useForm } from '../../common/shared_imports'; +import { Severity } from './severity'; +import { FormProps, schema } from './schema'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/dom'; + +let globalForm: FormHook; +const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm({ + defaultValue: { severity: CaseSeverity.LOW }, + schema: { + severity: schema.severity, + }, + }); + + globalForm = form; + + return
{children}
; +}; +describe('Severity form field', () => { + let appMockRender: AppMockRenderer; + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + it('renders', () => { + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('caseSeverity')).toBeTruthy(); + expect(result.getByTestId('case-severity-selection')).not.toHaveAttribute('disabled'); + }); + + // default to LOW in this test configuration + it('defaults to the correct value', () => { + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('caseSeverity')).toBeTruthy(); + // two items. one for the popover one for the selected field + expect(result.getAllByTestId('case-severity-selection-low').length).toBe(2); + }); + + it('selects the correct value when changed', async () => { + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('caseSeverity')).toBeTruthy(); + userEvent.click(result.getByTestId('case-severity-selection')); + userEvent.click(result.getByTestId('case-severity-selection-high')); + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ severity: 'high' }); + }); + }); + + it('disables when loading data', () => { + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('case-severity-selection')).toHaveAttribute('disabled'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/severity.tsx b/x-pack/plugins/cases/public/components/create/severity.tsx new file mode 100644 index 0000000000000..730eab5d77ac6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/severity.tsx @@ -0,0 +1,50 @@ +/* + * 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 { EuiFormRow } from '@elastic/eui'; +import React, { memo } from 'react'; +import { CaseSeverity } from '../../../common/api'; +import { UseField, useFormContext, useFormData } from '../../common/shared_imports'; +import { SeveritySelector } from '../severity/selector'; +import { SEVERITY_TITLE } from '../severity/translations'; + +interface Props { + isLoading: boolean; +} + +const SeverityFieldFormComponent = ({ isLoading }: { isLoading: boolean }) => { + const { setFieldValue } = useFormContext(); + const [{ severity }] = useFormData({ watch: ['severity'] }); + const onSeverityChange = (newSeverity: CaseSeverity) => { + setFieldValue('severity', newSeverity); + }; + return ( + + + + ); +}; +SeverityFieldFormComponent.displayName = 'SeverityFieldForm'; + +const SeverityComponent: React.FC = ({ isLoading }) => ( + +); + +SeverityComponent.displayName = 'SeverityComponent'; + +export const Severity = memo(SeverityComponent); diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index cfd0d45667417..36be9e590f216 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -11,6 +11,7 @@ import { homePluginMock } from '@kbn/home-plugin/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; import { CloudPlugin, CloudConfigType } from './plugin'; import { firstValueFrom } from 'rxjs'; +import { Sha256 } from '@kbn/core/public/utils'; describe('Cloud Plugin', () => { describe('#setup', () => { @@ -74,6 +75,9 @@ describe('Cloud Plugin', () => { }); describe('setupTelemetryContext', () => { + const username = '1234'; + const expectedHashedPlainUsername = new Sha256().update(username, 'utf8').digest('hex'); + beforeEach(() => { jest.clearAllMocks(); }); @@ -121,9 +125,7 @@ describe('Cloud Plugin', () => { test('register the context provider for the cloud user with hashed user ID when security is available', async () => { const { coreSetup } = await setupPlugin({ config: { id: 'cloudId' }, - currentUserProps: { - username: '1234', - }, + currentUserProps: { username }, }); expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); @@ -140,9 +142,7 @@ describe('Cloud Plugin', () => { it('user hash includes cloud id', async () => { const { coreSetup: coreSetup1 } = await setupPlugin({ config: { id: 'esOrg1' }, - currentUserProps: { - username: '1234', - }, + currentUserProps: { username }, }); const [{ context$: context1$ }] = @@ -151,12 +151,11 @@ describe('Cloud Plugin', () => { )!; const hashId1 = await firstValueFrom(context1$); + expect(hashId1).not.toEqual(expectedHashedPlainUsername); const { coreSetup: coreSetup2 } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg2' }, - currentUserProps: { - username: '1234', - }, + currentUserProps: { username }, }); const [{ context$: context2$ }] = @@ -165,15 +164,17 @@ describe('Cloud Plugin', () => { )!; const hashId2 = await firstValueFrom(context2$); + expect(hashId2).not.toEqual(expectedHashedPlainUsername); expect(hashId1).not.toEqual(hashId2); }); - test('user hash does not include cloudId when not provided', async () => { + test('user hash does not include cloudId when authenticated via Cloud SAML', async () => { const { coreSetup } = await setupPlugin({ - config: {}, + config: { id: 'cloudDeploymentId' }, currentUserProps: { - username: '1234', + username, + authentication_realm: { type: 'saml', name: 'cloud-saml-kibana' }, }, }); @@ -184,7 +185,24 @@ describe('Cloud Plugin', () => { )!; await expect(firstValueFrom(context$)).resolves.toEqual({ - userId: '03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4', + userId: expectedHashedPlainUsername, + }); + }); + + test('user hash does not include cloudId when not provided', async () => { + const { coreSetup } = await setupPlugin({ + config: {}, + currentUserProps: { username }, + }); + + expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); + + const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find( + ([{ name }]) => name === 'cloud_user_id' + )!; + + await expect(firstValueFrom(context$)).resolves.toEqual({ + userId: expectedHashedPlainUsername, }); }); diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index db6b2305495bf..1bccf219225dc 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -261,10 +261,21 @@ export class CloudPlugin implements Plugin { analytics.registerContextProvider({ name: 'cloud_user_id', context$: from(security.authc.getCurrentUser()).pipe( - map((user) => user.username), + map((user) => { + if ( + getIsCloudEnabled(cloudId) && + user.authentication_realm?.type === 'saml' && + user.authentication_realm?.name === 'cloud-saml-kibana' + ) { + // If authenticated via Cloud SAML, use the SAML username as the user ID + return user.username; + } + + return cloudId ? `${cloudId}:${user.username}` : user.username; + }), // Join the cloud org id and the user to create a truly unique user id. // The hashing here is to keep it at clear as possible in our source code that we do not send literal user IDs - map((userId) => ({ userId: sha256(cloudId ? `${cloudId}:${userId}` : `${userId}`) })), + map((userId) => ({ userId: sha256(userId) })), catchError(() => of({ userId: undefined })) ), schema: { diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index b2edd268c8485..30e9651b6e739 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -31,7 +31,7 @@ export const INTERNAL_FEATURE_FLAGS = { showBenchmarks: true, showManageRulesMock: false, showRisksMock: false, - showFindingsGroupBy: false, + showFindingsGroupBy: true, } as const; export const cspRuleAssetSavedObjectType = 'csp_rule'; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/index.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/index.ts index 574534290214a..5f093a19157f9 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/index.ts @@ -5,6 +5,6 @@ * 2.0. */ -export { installPipelines, isTopLevelPipeline } from './install'; +export { prepareToInstallPipelines, isTopLevelPipeline } from './install'; export { deletePreviousPipelines, deletePipeline } from './remove'; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index 49dae4d86b639..da035a44c9921 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -6,13 +6,12 @@ */ import type { TransportRequestOptions } from '@elastic/elasticsearch'; -import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { ElasticsearchAssetType } from '../../../../types'; import type { EsAssetReference, RegistryDataStream, InstallablePackage } from '../../../../types'; import { getAsset, getPathParts } from '../../archive'; import type { ArchiveEntry } from '../../archive'; -import { updateEsAssetReferences } from '../../packages/install'; import { FLEET_FINAL_PIPELINE_CONTENT, FLEET_FINAL_PIPELINE_ID, @@ -36,23 +35,23 @@ export const isTopLevelPipeline = (path: string) => { ); }; -export const installPipelines = async ( +export const prepareToInstallPipelines = ( installablePackage: InstallablePackage, - paths: string[], - esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract, - logger: Logger, - esReferences: EsAssetReference[] -) => { + paths: string[] +): { + assetsToAdd: EsAssetReference[]; + install: (esClient: ElasticsearchClient, logger: Logger) => Promise; +} => { // unlike other ES assets, pipeline names are versioned so after a template is updated // it can be created pointing to the new template, without removing the old one and effecting data // so do not remove the currently installed pipelines here const dataStreams = installablePackage.data_streams; - const { name: pkgName, version: pkgVersion } = installablePackage; + const { version: pkgVersion } = installablePackage; const pipelinePaths = paths.filter((path) => isPipeline(path)); const topLevelPipelinePaths = paths.filter((path) => isTopLevelPipeline(path)); - if (!dataStreams?.length && topLevelPipelinePaths.length === 0) return []; + if (!dataStreams?.length && topLevelPipelinePaths.length === 0) + return { assetsToAdd: [], install: () => Promise.resolve() }; // get and save pipeline refs before installing pipelines let pipelineRefs = dataStreams @@ -85,41 +84,41 @@ export const installPipelines = async ( pipelineRefs = [...pipelineRefs, ...topLevelPipelineRefs]; - esReferences = await updateEsAssetReferences(savedObjectsClient, pkgName, esReferences, { + return { assetsToAdd: pipelineRefs, - }); - - const pipelines = dataStreams - ? dataStreams.reduce>>((acc, dataStream) => { - if (dataStream.ingest_pipeline) { - acc.push( - installAllPipelines({ - dataStream, - esClient, - logger, - paths: pipelinePaths, - installablePackage, - }) - ); - } - return acc; - }, []) - : []; - - if (topLevelPipelinePaths) { - pipelines.push( - installAllPipelines({ - dataStream: undefined, - esClient, - logger, - paths: topLevelPipelinePaths, - installablePackage, - }) - ); - } + install: async (esClient, logger) => { + const pipelines = dataStreams + ? dataStreams.reduce>>((acc, dataStream) => { + if (dataStream.ingest_pipeline) { + acc.push( + installAllPipelines({ + dataStream, + esClient, + logger, + paths: pipelinePaths, + installablePackage, + }) + ); + } + return acc; + }, []) + : []; + + if (topLevelPipelinePaths) { + pipelines.push( + installAllPipelines({ + dataStream: undefined, + esClient, + logger, + paths: topLevelPipelinePaths, + installablePackage, + }) + ); + } - await Promise.all(pipelines); - return esReferences; + await Promise.all(pipelines); + }, + }; }; export function rewriteIngestPipeline( diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts index 998d0f9fb1ae5..3478da69bf721 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts @@ -4,30 +4,19 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { loggerMock } from '@kbn/logging-mocks'; - import { createAppContextStartContractMock } from '../../../../mocks'; import { appContextService } from '../../..'; import type { RegistryDataStream } from '../../../../types'; -import type { Field } from '../../fields/field'; -import { installTemplate } from './install'; +import { prepareTemplate } from './install'; -describe('EPM install', () => { +describe('EPM index template install', () => { beforeEach(async () => { appContextService.start(createAppContextStartContractMock()); }); - it('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix not set', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.indices.getIndexTemplate.mockImplementation(() => - elasticsearchServiceMock.createSuccessTransportRequestPromise({ index_templates: [] }) - ); - - const fields: Field[] = []; + it('tests prepareTemplate to use correct priority and index_patterns for data stream with dataset_is_prefix not set', async () => { const dataStreamDatasetIsPrefixUnset = { type: 'metrics', dataset: 'package.dataset', @@ -43,29 +32,14 @@ describe('EPM install', () => { }; const templateIndexPatternDatasetIsPrefixUnset = 'metrics-package.dataset-*'; const templatePriorityDatasetIsPrefixUnset = 200; - await installTemplate({ - esClient, - logger: loggerMock.create(), - fields, - dataStream: dataStreamDatasetIsPrefixUnset, - packageVersion: pkg.version, - packageName: pkg.name, - }); - - const sentTemplate = ( - esClient.indices.putIndexTemplate.mock.calls[0][0] as estypes.IndicesPutIndexTemplateRequest - ).body; - - expect(sentTemplate).toBeDefined(); - expect(sentTemplate?.priority).toBe(templatePriorityDatasetIsPrefixUnset); - expect(sentTemplate?.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); + const { + indexTemplate: { indexTemplate }, + } = prepareTemplate({ pkg, dataStream: dataStreamDatasetIsPrefixUnset }); + expect(indexTemplate.priority).toBe(templatePriorityDatasetIsPrefixUnset); + expect(indexTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); }); - it('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to false', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.indices.getIndexTemplate.mockResponse({ index_templates: [] }); - - const fields: Field[] = []; + it('tests prepareTemplate to use correct priority and index_patterns for data stream with dataset_is_prefix set to false', async () => { const dataStreamDatasetIsPrefixFalse = { type: 'metrics', dataset: 'package.dataset', @@ -82,29 +56,15 @@ describe('EPM install', () => { }; const templateIndexPatternDatasetIsPrefixFalse = 'metrics-package.dataset-*'; const templatePriorityDatasetIsPrefixFalse = 200; - await installTemplate({ - esClient, - logger: loggerMock.create(), - fields, - dataStream: dataStreamDatasetIsPrefixFalse, - packageVersion: pkg.version, - packageName: pkg.name, - }); - - const sentTemplate = ( - esClient.indices.putIndexTemplate.mock.calls[0][0] as estypes.IndicesPutIndexTemplateRequest - ).body; + const { + indexTemplate: { indexTemplate }, + } = prepareTemplate({ pkg, dataStream: dataStreamDatasetIsPrefixFalse }); - expect(sentTemplate).toBeDefined(); - expect(sentTemplate?.priority).toBe(templatePriorityDatasetIsPrefixFalse); - expect(sentTemplate?.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixFalse]); + expect(indexTemplate.priority).toBe(templatePriorityDatasetIsPrefixFalse); + expect(indexTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixFalse]); }); - it('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to true', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.indices.getIndexTemplate.mockResponse({ index_templates: [] }); - - const fields: Field[] = []; + it('tests prepareTemplate to use correct priority and index_patterns for data stream with dataset_is_prefix set to true', async () => { const dataStreamDatasetIsPrefixTrue = { type: 'metrics', dataset: 'package.dataset', @@ -121,71 +81,11 @@ describe('EPM install', () => { }; const templateIndexPatternDatasetIsPrefixTrue = 'metrics-package.dataset.*-*'; const templatePriorityDatasetIsPrefixTrue = 150; - await installTemplate({ - esClient, - logger: loggerMock.create(), - fields, - dataStream: dataStreamDatasetIsPrefixTrue, - packageVersion: pkg.version, - packageName: pkg.name, - }); - - const sentTemplate = ( - esClient.indices.putIndexTemplate.mock.calls[0][0] as estypes.IndicesPutIndexTemplateRequest - ).body; - - expect(sentTemplate).toBeDefined(); - expect(sentTemplate?.priority).toBe(templatePriorityDatasetIsPrefixTrue); - expect(sentTemplate?.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixTrue]); - }); - - it('tests installPackage remove the aliases property if the property existed', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - esClient.indices.getIndexTemplate.mockResponse({ - index_templates: [ - { - name: 'metrics-package.dataset', - // @ts-expect-error not full interface - index_template: { - index_patterns: ['metrics-package.dataset-*'], - template: { aliases: {} }, - }, - }, - ], - }); - - const fields: Field[] = []; - const dataStreamDatasetIsPrefixUnset = { - type: 'metrics', - dataset: 'package.dataset', - title: 'test data stream', - release: 'experimental', - package: 'package', - path: 'path', - ingest_pipeline: 'default', - } as RegistryDataStream; - const pkg = { - name: 'package', - version: '0.0.1', - }; - const templateIndexPatternDatasetIsPrefixUnset = 'metrics-package.dataset-*'; - const templatePriorityDatasetIsPrefixUnset = 200; - await installTemplate({ - esClient, - logger: loggerMock.create(), - fields, - dataStream: dataStreamDatasetIsPrefixUnset, - packageVersion: pkg.version, - packageName: pkg.name, - }); + const { + indexTemplate: { indexTemplate }, + } = prepareTemplate({ pkg, dataStream: dataStreamDatasetIsPrefixTrue }); - const sentTemplate = ( - esClient.indices.putIndexTemplate.mock.calls[0][0] as estypes.IndicesPutIndexTemplateRequest - ).body; - expect(sentTemplate).toBeDefined(); - expect(sentTemplate?.template?.aliases).not.toBeDefined(); - expect(sentTemplate?.priority).toBe(templatePriorityDatasetIsPrefixUnset); - expect(sentTemplate?.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); + expect(indexTemplate.priority).toBe(templatePriorityDatasetIsPrefixTrue); + expect(indexTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixTrue]); }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 2d2e5b2ffea2a..df6d9d84a08c5 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -7,7 +7,7 @@ import { merge } from 'lodash'; import Boom from '@hapi/boom'; -import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { ElasticsearchAssetType } from '../../../../types'; import type { @@ -20,13 +20,12 @@ import type { TemplateMapEntry, TemplateMap, EsAssetReference, + PackageInfo, } from '../../../../types'; import { loadFieldsFromYaml, processFields } from '../../fields/field'; -import type { Field } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; import { getAsset, getPathParts } from '../../archive'; -import { updateEsAssetReferences } from '../../packages/install'; import { FLEET_COMPONENT_TEMPLATES, PACKAGE_TEMPLATE_SUFFIX, @@ -47,65 +46,55 @@ import { buildDefaultSettings } from './default_settings'; const FLEET_COMPONENT_TEMPLATE_NAMES = FLEET_COMPONENT_TEMPLATES.map((tmpl) => tmpl.name); -export const installTemplates = async ( +export const prepareToInstallTemplates = ( installablePackage: InstallablePackage, - esClient: ElasticsearchClient, - logger: Logger, paths: string[], - savedObjectsClient: SavedObjectsClientContract, esReferences: EsAssetReference[] -): Promise<{ - installedTemplates: IndexTemplateEntry[]; - installedEsReferences: EsAssetReference[]; -}> => { - // install any pre-built index template assets, - // atm, this is only the base package's global index templates - // Install component templates first, as they are used by the index templates - await installPreBuiltComponentTemplates(paths, esClient, logger); - await installPreBuiltTemplates(paths, esClient, logger); - +): { + assetsToAdd: EsAssetReference[]; + assetsToRemove: EsAssetReference[]; + install: (esClient: ElasticsearchClient, logger: Logger) => Promise; +} => { // remove package installation's references to index templates - esReferences = await updateEsAssetReferences( - savedObjectsClient, - installablePackage.name, - esReferences, - { - assetsToRemove: esReferences.filter( - ({ type }) => - type === ElasticsearchAssetType.indexTemplate || - type === ElasticsearchAssetType.componentTemplate - ), - } + const assetsToRemove = esReferences.filter( + ({ type }) => + type === ElasticsearchAssetType.indexTemplate || + type === ElasticsearchAssetType.componentTemplate ); // build templates per data stream from yml files const dataStreams = installablePackage.data_streams; - if (!dataStreams) return { installedTemplates: [], installedEsReferences: esReferences }; - - const installedTemplatesNested = await Promise.all( - dataStreams.map((dataStream) => - installTemplateForDataStream({ - pkg: installablePackage, - esClient, - logger, - dataStream, - }) - ) - ); - const installedTemplates = installedTemplatesNested.flat(); + if (!dataStreams) return { assetsToAdd: [], assetsToRemove, install: () => Promise.resolve([]) }; - // get template refs to save - const installedIndexTemplateRefs = getAllTemplateRefs(installedTemplates); - - // add package installation's references to index templates - esReferences = await updateEsAssetReferences( - savedObjectsClient, - installablePackage.name, - esReferences, - { assetsToAdd: installedIndexTemplateRefs } + const templates = dataStreams.map((dataStream) => + prepareTemplate({ pkg: installablePackage, dataStream }) ); + const assetsToAdd = getAllTemplateRefs(templates.map((template) => template.indexTemplate)); + + return { + assetsToAdd, + assetsToRemove, + install: async (esClient, logger) => { + // install any pre-built index template assets, + // atm, this is only the base package's global index templates + // Install component templates first, as they are used by the index templates + await installPreBuiltComponentTemplates(paths, esClient, logger); + await installPreBuiltTemplates(paths, esClient, logger); + + await Promise.all( + templates.map((template) => + installComponentAndIndexTemplateForDataStream({ + esClient, + logger, + componentTemplates: template.componentTemplates, + indexTemplate: template.indexTemplate, + }) + ) + ); - return { installedTemplates, installedEsReferences: esReferences }; + return templates.map((template) => template.indexTemplate); + }, + }; }; const installPreBuiltTemplates = async ( @@ -187,31 +176,24 @@ const isComponentTemplate = (path: string) => { }; /** - * installTemplateForDataStream installs one template for each data stream + * installComponentAndIndexTemplateForDataStream installs one template for each data stream * * The template is currently loaded with the pkgkey-package-data_stream */ -export async function installTemplateForDataStream({ - pkg, +export async function installComponentAndIndexTemplateForDataStream({ esClient, logger, - dataStream, + componentTemplates, + indexTemplate, }: { - pkg: InstallablePackage; esClient: ElasticsearchClient; logger: Logger; - dataStream: RegistryDataStream; -}): Promise { - const fields = await loadFieldsFromYaml(pkg, dataStream.path); - return installTemplate({ - esClient, - logger, - fields, - dataStream, - packageVersion: pkg.version, - packageName: pkg.name, - }); + componentTemplates: TemplateMap; + indexTemplate: IndexTemplateEntry; +}) { + await installDataStreamComponentTemplates({ esClient, logger, componentTemplates }); + await installTemplate({ esClient, logger, template: indexTemplate }); } function putComponentTemplate( @@ -291,35 +273,18 @@ function buildComponentTemplates(params: { return templatesMap; } -async function installDataStreamComponentTemplates(params: { - mappings: IndexTemplateMappings; - templateName: string; - registryElasticsearch: RegistryElasticsearch | undefined; +async function installDataStreamComponentTemplates({ + esClient, + logger, + componentTemplates, +}: { esClient: ElasticsearchClient; logger: Logger; - packageName: string; - defaultSettings: IndexTemplate['template']['settings']; + componentTemplates: TemplateMap; }) { - const { - templateName, - registryElasticsearch, - esClient, - packageName, - defaultSettings, - logger, - mappings, - } = params; - const componentTemplates = buildComponentTemplates({ - mappings, - templateName, - registryElasticsearch, - packageName, - defaultSettings, - }); - const templateEntries = Object.entries(componentTemplates); // TODO: Check return values for errors await Promise.all( - templateEntries.map(async ([name, body]) => { + Object.entries(componentTemplates).map(async ([name, body]) => { if (isUserSettingsTemplate(name)) { try { // Attempt to create custom component templates, ignore if they already exist @@ -342,8 +307,6 @@ async function installDataStreamComponentTemplates(params: { } }) ); - - return { componentTemplateNames: Object.keys(componentTemplates) }; } export async function ensureDefaultComponentTemplates( @@ -387,21 +350,15 @@ export async function ensureComponentTemplate( return { isCreated: !existingTemplate }; } -export async function installTemplate({ - esClient, - logger, - fields, +export function prepareTemplate({ + pkg, dataStream, - packageVersion, - packageName, }: { - esClient: ElasticsearchClient; - logger: Logger; - fields: Field[]; + pkg: Pick; dataStream: RegistryDataStream; - packageVersion: string; - packageName: string; -}): Promise { +}): { componentTemplates: TemplateMap; indexTemplate: IndexTemplateEntry } { + const { name: packageName, version: packageVersion } = pkg; + const fields = loadFieldsFromYaml(pkg, dataStream.path); const validFields = processFields(fields); const mappings = generateMappings(validFields); const templateName = generateTemplateName(dataStream); @@ -425,40 +382,51 @@ export async function installTemplate({ ilmPolicy: dataStream.ilm_policy, }); - const { componentTemplateNames } = await installDataStreamComponentTemplates({ + const componentTemplates = buildComponentTemplates({ + defaultSettings, mappings, + packageName, templateName, registryElasticsearch: dataStream.elasticsearch, - esClient, - logger, - packageName, - defaultSettings, }); const template = getTemplate({ templateIndexPattern, pipelineName, packageName, - composedOfTemplates: componentTemplateNames, + composedOfTemplates: Object.keys(componentTemplates), templatePriority, hidden: dataStream.hidden, }); + return { + componentTemplates, + indexTemplate: { + templateName, + indexTemplate: template, + }, + }; +} + +async function installTemplate({ + esClient, + logger, + template, +}: { + esClient: ElasticsearchClient; + logger: Logger; + template: IndexTemplateEntry; +}) { // TODO: Check return values for errors const esClientParams = { - name: templateName, - body: template, + name: template.templateName, + body: template.indexTemplate, }; await retryTransientEsErrors( () => esClient.indices.putIndexTemplate(esClientParams, { ignore: [404] }), { logger } ); - - return { - templateName, - indexTemplate: template, - }; } export function getAllTemplateRefs(installedTemplates: IndexTemplateEntry[]) { diff --git a/x-pack/plugins/fleet/server/services/epm/fields/field.ts b/x-pack/plugins/fleet/server/services/epm/fields/field.ts index 3f1a8d8b2b7ba..0e00840b0c74e 100644 --- a/x-pack/plugins/fleet/server/services/epm/fields/field.ts +++ b/x-pack/plugins/fleet/server/services/epm/fields/field.ts @@ -261,12 +261,12 @@ const isFields = (path: string) => { * Gets all field files, optionally filtered by dataset, extracts .yml files, merges them together */ -export const loadFieldsFromYaml = async ( +export const loadFieldsFromYaml = ( pkg: Pick, datasetName?: string -): Promise => { +): Field[] => { // Fetch all field definition files - const fieldDefinitionFiles = await getAssetsData(pkg, isFields, datasetName); + const fieldDefinitionFiles = getAssetsData(pkg, isFields, datasetName); return fieldDefinitionFiles.reduce((acc, file) => { // Make sure it is defined as it is optional. Should never happen. if (file.buffer) { 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 1462cd61c4bd3..b9582ce1cf148 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 @@ -267,6 +267,7 @@ export async function installKibanaSavedObjects({ overwrite: true, readStream: createListStream(toBeSavedObjects), createNewCopies: false, + refresh: false, }) ); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 24c324e6b7cd0..0124bff41736f 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -22,10 +22,10 @@ import { import type { InstallablePackage, InstallSource, PackageAssetReference } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; import type { AssetReference, Installation, InstallType } from '../../../types'; -import { installTemplates } from '../elasticsearch/template/install'; +import { prepareToInstallTemplates } from '../elasticsearch/template/install'; import { removeLegacyTemplates } from '../elasticsearch/template/remove_legacy'; import { - installPipelines, + prepareToInstallPipelines, isTopLevelPipeline, deletePreviousPipelines, } from '../elasticsearch/ingest_pipeline'; @@ -39,7 +39,7 @@ import { saveArchiveEntries } from '../archive/storage'; import { ConcurrentInstallOperationError } from '../../../errors'; import { packagePolicyService } from '../..'; -import { createInstallation } from './install'; +import { createInstallation, updateEsAssetReferences } from './install'; import { withPackageSpan } from './utils'; // this is only exported for testing @@ -146,17 +146,45 @@ export async function _installPackage({ installMlModel(packageInfo, paths, esClient, savedObjectsClient, logger, esReferences) ); - // installs versionized pipelines without removing currently installed ones - esReferences = await withPackageSpan('Install ingest pipelines', () => - installPipelines(packageInfo, paths, esClient, savedObjectsClient, logger, esReferences) + /** + * In order to install assets in parallel, we need to split the preparation step from the installation step. This + * allows us to know which asset references are going to be installed so that we can save them on the packages + * SO before installation begins. In the case of a failure during installing any individual asset, we'll have the + * references necessary to remove any assets in that were successfully installed during the rollback phase. + * + * This split of prepare/install could be extended to all asset types. Besides performance, it also allows us to + * more easily write unit tests against the asset generation code without needing to mock ES responses. + */ + const preparedIngestPipelines = prepareToInstallPipelines(packageInfo, paths); + const preparedIndexTemplates = prepareToInstallTemplates(packageInfo, paths, esReferences); + + // Update the references for the templates and ingest pipelines together. Need to be done togther to avoid race + // conditions on updating the installed_es field at the same time + // These must be saved before we actually attempt to install the templates or pipelines so that we know what to + // cleanup in the case that a single asset fails to install. + esReferences = await updateEsAssetReferences( + savedObjectsClient, + packageInfo.name, + esReferences, + { + assetsToRemove: preparedIndexTemplates.assetsToRemove, + assetsToAdd: [ + ...preparedIngestPipelines.assetsToAdd, + ...preparedIndexTemplates.assetsToAdd, + ], + } ); - // install or update the templates referencing the newly installed pipelines - const { installedTemplates, installedEsReferences: esReferencesAfterTemplates } = - await withPackageSpan('Install index templates', () => - installTemplates(packageInfo, esClient, logger, paths, savedObjectsClient, esReferences) - ); - esReferences = esReferencesAfterTemplates; + // Install index templates and ingest pipelines in parallel since they typically take the longest + const [installedTemplates] = await Promise.all([ + withPackageSpan('Install index templates', () => + preparedIndexTemplates.install(esClient, logger) + ), + // installs versionized pipelines without removing currently installed ones + withPackageSpan('Install ingest pipelines', () => + preparedIngestPipelines.install(esClient, logger) + ), + ]); try { await removeLegacyTemplates({ packageInfo, esClient, logger }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts index 0621d05d21497..d67e76f90e551 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts @@ -51,11 +51,11 @@ export function getAssets( // ASK: Does getAssetsData need an installSource now? // if so, should it be an Installation vs InstallablePackage or add another argument? -export async function getAssetsData( +export function getAssetsData( packageInfo: Pick, filter = (path: string): boolean => true, datasetName?: string -): Promise { +): ArchiveEntry[] { // Gather all asset data const assets = getAssets(packageInfo, filter, datasetName); const entries: ArchiveEntry[] = assets.map((path) => { diff --git a/x-pack/plugins/ml/public/application/routing/ml_page_wrapper.tsx b/x-pack/plugins/ml/public/application/routing/ml_page_wrapper.tsx index f1674a12b77c6..4c2a6d0058edc 100644 --- a/x-pack/plugins/ml/public/application/routing/ml_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/routing/ml_page_wrapper.tsx @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React, { FC } from 'react'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; diff --git a/x-pack/plugins/ml/public/application/routing/use_active_route.ts b/x-pack/plugins/ml/public/application/routing/use_active_route.ts index 925db8185c379..9183e45c3d0ae 100644 --- a/x-pack/plugins/ml/public/application/routing/use_active_route.ts +++ b/x-pack/plugins/ml/public/application/routing/use_active_route.ts @@ -8,11 +8,21 @@ import { useLocation, useRouteMatch } from 'react-router-dom'; import { keyBy } from 'lodash'; import { useMemo } from 'react'; +import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; +import { useMlKibana } from '../contexts/kibana'; import type { MlRoute } from './router'; +/** + * Provides an active route of the ML app. + * @param routesList + */ export const useActiveRoute = (routesList: MlRoute[]): MlRoute => { const { pathname } = useLocation(); + const { + services: { executionContext }, + } = useMlKibana(); + /** * Temp fix for routes with params. */ @@ -30,8 +40,14 @@ export const useActiveRoute = (routesList: MlRoute[]): MlRoute => { } // Remove trailing slash from the pathname const pathnameKey = pathname.replace(/\/$/, ''); - return routesMap[pathnameKey]; + return routesMap[pathnameKey] ?? routesMap['/overview']; }, [pathname]); - return activeRoute ?? routesMap['/overview']; + useExecutionContext(executionContext, { + name: 'Machine Learning', + type: 'application', + page: activeRoute?.path, + }); + + return activeRoute; }; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx index 9f31f5777f9de..85350629263e4 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx @@ -11,6 +11,7 @@ import { Observable } from 'rxjs'; import { FormattedMessage } from '@kbn/i18n-react'; import { throttle } from 'lodash'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { useEmbeddableExecutionContext } from '../common/use_embeddable_execution_context'; import { useAnomalyChartsInputResolver } from './use_anomaly_charts_input_resolver'; import type { IAnomalyChartsEmbeddable } from './anomaly_charts_embeddable'; import type { @@ -27,6 +28,7 @@ import { ANOMALY_THRESHOLD } from '../../../common'; import { TimeBuckets } from '../../application/util/time_buckets'; import { EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER } from '../../ui_actions/triggers'; import { MlLocatorParams } from '../../../common/types/locator'; +import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '..'; const RESIZE_THROTTLE_TIME_MS = 500; @@ -55,6 +57,13 @@ export const EmbeddableAnomalyChartsContainer: FC { + useEmbeddableExecutionContext( + services[0].executionContext, + embeddableInput, + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + id + ); + const [chartWidth, setChartWidth] = useState(0); const [severity, setSeverity] = useState( optionValueToThreshold( diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx index 06c400481491a..c354057d971bb 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx @@ -11,6 +11,7 @@ import { Observable } from 'rxjs'; import { CoreStart } from '@kbn/core/public'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useEmbeddableExecutionContext } from '../common/use_embeddable_execution_context'; import { IAnomalySwimlaneEmbeddable } from './anomaly_swimlane_embeddable'; import { useSwimlaneInputResolver } from './swimlane_input_resolver'; import { SwimlaneType } from '../../application/explorer/explorer_constants'; @@ -22,6 +23,7 @@ import { AppStateSelectedCells } from '../../application/explorer/explorer_utils import { MlDependencies } from '../../application/app'; import { SWIM_LANE_SELECTION_TRIGGER } from '../../ui_actions'; import { + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneServices, @@ -52,6 +54,13 @@ export const EmbeddableSwimLaneContainer: FC = ( onLoading, onError, }) => { + useEmbeddableExecutionContext( + services[0].executionContext, + embeddableInput, + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + id + ); + const [chartWidth, setChartWidth] = useState(0); const [fromPage, setFromPage] = useState(1); diff --git a/x-pack/plugins/ml/public/embeddables/common/use_embeddable_execution_context.ts b/x-pack/plugins/ml/public/embeddables/common/use_embeddable_execution_context.ts new file mode 100644 index 0000000000000..68306c54c8590 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/use_embeddable_execution_context.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import useObservable from 'react-use/lib/useObservable'; +import { map } from 'rxjs/operators'; +import { KibanaExecutionContext } from '@kbn/core/types'; +import { useMemo } from 'react'; +import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; +import type { Observable } from 'rxjs'; +import type { EmbeddableInput } from '@kbn/embeddable-plugin/common'; +import { ExecutionContextStart } from '@kbn/core/public'; + +/** + * Use execution context for ML embeddables. + * @param executionContext + * @param embeddableInput$ + * @param embeddableType + * @param id + */ +export function useEmbeddableExecutionContext( + executionContext: ExecutionContextStart, + embeddableInput$: Observable, + embeddableType: string, + id: string +) { + const parentExecutionContext = useObservable( + embeddableInput$.pipe(map((v) => v.executionContext)) + ); + + const embeddableExecutionContext: KibanaExecutionContext = useMemo(() => { + const child: KibanaExecutionContext = { + type: 'visualization', + name: embeddableType, + id, + }; + + return { + ...parentExecutionContext, + child, + }; + }, [parentExecutionContext, id]); + + useExecutionContext(executionContext, embeddableExecutionContext); +} diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index 4c1b1dc729fea..287fe541cc7b6 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -5,6 +5,7 @@ * 2.0. */ +export const enableNewSyntheticsView = 'observability:enableNewSyntheticsView'; export const enableInspectEsQueries = 'observability:enableInspectEsQueries'; export const maxSuggestions = 'observability:maxSuggestions'; export const enableComparisonByDefault = 'observability:enableComparisonByDefault'; diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 19468ef0e2736..00db5b1873980 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -28,6 +28,7 @@ export { enableComparisonByDefault, enableInfrastructureView, enableServiceGroups, + enableNewSyntheticsView, } from '../common/ui_settings_keys'; export { uptimeOverviewLocatorID } from '../common'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/index.ts similarity index 72% rename from x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts rename to x-pack/plugins/observability/public/pages/alerts/components/rule_stats/index.ts index 97f7fb61ae607..b2b4e144952e0 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/index.ts @@ -5,4 +5,5 @@ * 2.0. */ -export { EndpointConsole } from './endpoint_console'; +export { renderRuleStats } from './rule_stats'; +export type { RuleStatsState } from './types'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.test.tsx b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.test.tsx new file mode 100644 index 0000000000000..6f2edf5d0b1b6 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.test.tsx @@ -0,0 +1,199 @@ +/* + * 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 { renderRuleStats } from './rule_stats'; +import { render, screen } from '@testing-library/react'; + +const RULES_PAGE_LINK = '/app/observability/alerts/rules'; +const STAT_CLASS = 'euiStat'; +const STAT_TITLE_PRIMARY_CLASS = 'euiStat__title--primary'; +const STAT_BUTTON_CLASS = 'euiButtonEmpty'; + +describe('Rule stats', () => { + test('renders all rule stats', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + expect(stats.length).toEqual(6); + }); + test('disabled stat is not clickable, when there are no disabled rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { findByText, container } = render(stats[4]); + const disabledElement = await findByText('Disabled'); + expect(disabledElement).toBeInTheDocument(); + expect(container.getElementsByClassName(STAT_CLASS).length).toBe(1); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(0); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(0); + }); + + test('disabled stat is clickable, when there are disabled rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 1, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[4]); + expect(screen.getByText('Disabled').closest('a')).toHaveAttribute( + 'href', + `${RULES_PAGE_LINK}?_a=(lastResponse:!(),status:!(disabled))` + ); + + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); + }); + + test('disabled stat count is link-colored, when there are disabled rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 1, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[4]); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(1); + }); + + test('snoozed stat is not clickable, when there are no snoozed rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { findByText, container } = render(stats[3]); + const snoozedElement = await findByText('Snoozed'); + expect(snoozedElement).toBeInTheDocument(); + expect(container.getElementsByClassName(STAT_CLASS).length).toBe(1); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(0); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(0); + }); + + test('snoozed stat is clickable, when there are snoozed rules', () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 1, + error: 0, + snoozed: 1, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[3]); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); + expect(screen.getByText('Snoozed').closest('a')).toHaveAttribute( + 'href', + `${RULES_PAGE_LINK}?_a=(lastResponse:!(),status:!(snoozed))` + ); + }); + + test('snoozed stat count is link-colored, when there are snoozed rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 1, + error: 0, + snoozed: 1, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[3]); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(1); + }); + + test('errors stat is not clickable, when there are no error rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { findByText, container } = render(stats[2]); + const errorsElement = await findByText('Errors'); + expect(errorsElement).toBeInTheDocument(); + expect(container.getElementsByClassName(STAT_CLASS).length).toBe(1); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(0); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(0); + }); + + test('errors stat is clickable, when there are error rules', () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 2, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[2]); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); + expect(screen.getByText('Errors').closest('a')).toHaveAttribute( + 'href', + `${RULES_PAGE_LINK}?_a=(lastResponse:!(error),status:!())` + ); + }); + + test('errors stat count is link-colored, when there are error rules', () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 2, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[2]); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(1); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx new file mode 100644 index 0000000000000..62c520c7b7442 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx @@ -0,0 +1,156 @@ +/* + * 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 { EuiButtonEmpty, EuiStat } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; + +interface RuleStatsState { + total: number; + disabled: number; + muted: number; + error: number; + snoozed: number; +} +type StatType = 'disabled' | 'snoozed' | 'error'; + +const Divider = euiStyled.div` + border-right: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + height: 100%; +`; + +const StyledStat = euiStyled(EuiStat)` + .euiText { + line-height: 1; + } +`; + +const ConditionalWrap = ({ + condition, + wrap, + children, +}: { + condition: boolean; + wrap: (wrappedChildren: React.ReactNode) => JSX.Element; + children: JSX.Element; +}): JSX.Element => (condition ? wrap(children) : children); + +export const renderRuleStats = ( + ruleStats: RuleStatsState, + manageRulesHref: string, + ruleStatsLoading: boolean +) => { + const createRuleStatsLink = (stats: RuleStatsState, statType: StatType) => { + const count = stats[statType]; + let statsLink = `${manageRulesHref}?_a=(lastResponse:!(),status:!())`; + if (count > 0) { + switch (statType) { + case 'error': + statsLink = `${manageRulesHref}?_a=(lastResponse:!(error),status:!())`; + break; + case 'snoozed': + case 'disabled': + statsLink = `${manageRulesHref}?_a=(lastResponse:!(),status:!(${statType}))`; + break; + default: + break; + } + } + return statsLink; + }; + + const disabledStatsComponent = ( + 0} + wrap={(wrappedChildren) => ( + + {wrappedChildren} + + )} + > + 0 ? 'primary' : ''} + titleSize="xs" + isLoading={ruleStatsLoading} + data-test-subj="statDisabled" + /> + + ); + + const snoozedStatsComponent = ( + 0} + wrap={(wrappedChildren) => ( + + {wrappedChildren} + + )} + > + 0 ? 'primary' : ''} + titleSize="xs" + isLoading={ruleStatsLoading} + data-test-subj="statMuted" + /> + + ); + + const errorStatsComponent = ( + 0} + wrap={(wrappedChildren) => ( + + {wrappedChildren} + + )} + > + 0 ? 'primary' : ''} + titleSize="xs" + isLoading={ruleStatsLoading} + data-test-subj="statErrors" + /> + + ); + return [ + , + disabledStatsComponent, + snoozedStatsComponent, + errorStatsComponent, + , + + {i18n.translate('xpack.observability.alerts.manageRulesButtonLabel', { + defaultMessage: 'Manage Rules', + })} + , + ].reverse(); +}; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/types.ts b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/types.ts new file mode 100644 index 0000000000000..87ff668ebf87f --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface RuleStatsState { + total: number; + disabled: number; + muted: number; + error: number; + snoozed: number; +} + +export type StatType = 'disabled' | 'snoozed' | 'error'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index e99a3195d0f30..2fe114771c329 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -5,15 +5,13 @@ * 2.0. */ -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { DataViewBase } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { ALERT_STATUS, AlertStatus } from '@kbn/rule-data-utils'; - -import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { loadRuleAggregations } from '@kbn/triggers-actions-ui-plugin/public'; import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common/parse_technical_fields'; @@ -38,6 +36,7 @@ import { } from '../state_container'; import './styles.scss'; import { AlertsStatusFilter, AlertsDisclaimer, AlertsSearchBar } from '../../components'; +import { renderRuleStats } from '../../components/rule_stats'; import { ObservabilityAppServices } from '../../../../application/types'; import { OBSERVABILITY_RULE_TYPES } from '../../../rules/config'; @@ -57,11 +56,6 @@ export interface TopAlert { active: boolean; } -const Divider = euiStyled.div` - border-right: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; - height: 100%; -`; - const regExpEscape = (str: string) => str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); const NO_INDEX_PATTERNS: DataViewBase[] = []; const BASE_ALERT_REGEX = new RegExp(`\\s*${regExpEscape(ALERT_STATUS)}\\s*:\\s*"(.*?|\\*?)"`); @@ -251,54 +245,7 @@ function AlertsPage() { ), - rightSideItems: [ - , - , - , - , - , - - {i18n.translate('xpack.observability.alerts.manageRulesButtonLabel', { - defaultMessage: 'Manage Rules', - })} - , - ].reverse(), + rightSideItems: renderRuleStats(ruleStats, manageRulesHref, ruleStatsLoading), }} > diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index 02c519b10d19c..5b21b07d1cea3 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -18,6 +18,7 @@ import { apmProgressiveLoading, enableServiceGroups, apmServiceInventoryOptimizedSorting, + enableNewSyntheticsView, } from '../common/ui_settings_keys'; const technicalPreviewLabel = i18n.translate( @@ -31,6 +32,22 @@ const technicalPreviewLabel = i18n.translate( * uiSettings definitions for Observability. */ export const uiSettings: Record> = { + [enableNewSyntheticsView]: { + category: [observabilityFeatureId], + name: i18n.translate('xpack.observability.enableNewSyntheticsViewExperimentName', { + defaultMessage: 'Enable new synthetic monitoring application', + }), + value: false, + description: i18n.translate( + 'xpack.observability.enableNewSyntheticsViewExperimentDescription', + { + defaultMessage: + 'Enable new synthetic monitoring application in observability. Refresh the page to apply the setting.', + } + ), + schema: schema.boolean(), + requiresPageReload: true, + }, [enableInspectEsQueries]: { category: [observabilityFeatureId], name: i18n.translate('xpack.observability.enableInspectEsQueriesExperimentName', { @@ -71,7 +88,7 @@ export const uiSettings: Record renderErrors ?? []), }; } catch (error) { - logger.error(`Could not generate the PDF buffer!`); + eventLogger.kbnLogger.error(`Could not generate the PDF buffer!`); + eventLogger.error(error, Transactions.PDF); throw error; } diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/index.ts b/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/index.ts index be69ec4c5e141..280b9173c7920 100644 --- a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/index.ts +++ b/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/index.ts @@ -5,17 +5,17 @@ * 2.0. */ -import type { Logger, PackageInfo } from '@kbn/core/server'; -import { PdfMaker } from './pdfmaker'; +import type { PackageInfo } from '@kbn/core/server'; import type { Layout } from '../../../layouts'; -import { getTracker } from './tracker'; import type { CaptureResult } from '../../../screenshots'; +import { Actions, EventLogger, Transactions } from '../../../screenshots/event_logger'; +import { PdfMaker } from './pdfmaker'; interface PngsToPdfArgs { results: CaptureResult['results']; layout: Layout; packageInfo: PackageInfo; - logger: Logger; + eventLogger: EventLogger; logo?: string; title?: string; } @@ -26,37 +26,43 @@ export async function pngsToPdf({ logo, title, packageInfo, - logger, + eventLogger, }: PngsToPdfArgs): Promise<{ buffer: Buffer; pages: number }> { - const pdfMaker = new PdfMaker(layout, logo, packageInfo, logger); - const tracker = getTracker(); - if (title) { - pdfMaker.setTitle(title); - } - results.forEach((result) => { - result.screenshots.forEach((png) => { - tracker.startAddImage(); - pdfMaker.addImage(png.data, { - title: png.title ?? undefined, - description: png.description ?? undefined, - }); - tracker.endAddImage(); - }); - }); + const { kbnLogger } = eventLogger; + const transactionEnd = eventLogger.startTransaction(Transactions.PDF); let buffer: Uint8Array | null = null; + let pdfMaker: PdfMaker | null = null; try { - tracker.startCompile(); + pdfMaker = new PdfMaker(layout, logo, packageInfo, kbnLogger); + if (title) { + pdfMaker.setTitle(title); + } + results.forEach((result) => { + result.screenshots.forEach((png) => { + const spanEnd = eventLogger.logPdfEvent( + 'add image to PDF file', + Actions.ADD_IMAGE, + 'output' + ); + pdfMaker?.addImage(png.data, { + title: png.title ?? undefined, + description: png.description ?? undefined, + }); + spanEnd(); + }); + }); + + const spanEnd = eventLogger.logPdfEvent('compile PDF file', Actions.COMPILE, 'output'); buffer = await pdfMaker.generate(); - tracker.endCompile(); + spanEnd(); const byteLength = buffer?.byteLength ?? 0; - logger.debug(`PDF buffer byte length: ${byteLength}`); - tracker.setByteLength(byteLength); - } catch (err) { - throw err; - } finally { - tracker.end(); + transactionEnd({ labels: { byte_length_pdf: byteLength, pdf_pages: pdfMaker.getPageCount() } }); + } catch (error) { + kbnLogger.error(error); + eventLogger.error(error, Actions.COMPILE); + throw error; } return { buffer: Buffer.from(buffer.buffer), pages: pdfMaker.getPageCount() }; diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/tracker.ts b/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/tracker.ts deleted file mode 100644 index 49576a03d18a3..0000000000000 --- a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/tracker.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import apm from 'elastic-apm-node'; - -interface PdfTracker { - setByteLength: (byteLength: number) => void; - startAddImage: () => void; - endAddImage: () => void; - startCompile: () => void; - endCompile: () => void; - end: () => void; -} - -const TRANSACTION_TYPE = 'reporting'; // TODO: Find out whether we can rename to "screenshotting"; -const SPANTYPE_OUTPUT = 'output'; - -interface ApmSpan { - end: () => void; -} - -export function getTracker(): PdfTracker { - const apmTrans = apm.startTransaction('generate-pdf', TRANSACTION_TYPE); - - let apmAddImage: ApmSpan | null = null; - let apmCompilePdf: ApmSpan | null = null; - - return { - startAddImage() { - apmAddImage = apmTrans?.startSpan('add-pdf-image', SPANTYPE_OUTPUT) || null; - }, - endAddImage() { - apmAddImage?.end(); - }, - startCompile() { - apmCompilePdf = apmTrans?.startSpan('compile-pdf', SPANTYPE_OUTPUT) || null; - }, - endCompile() { - apmCompilePdf?.end(); - }, - setByteLength(byteLength: number) { - apmTrans?.setLabel('byte-length', byteLength, false); - }, - end() { - apmTrans?.end(); - }, - }; -} diff --git a/x-pack/plugins/screenshotting/server/plugin.ts b/x-pack/plugins/screenshotting/server/plugin.ts index 27da8b3430e6d..144b88a2c1c75 100755 --- a/x-pack/plugins/screenshotting/server/plugin.ts +++ b/x-pack/plugins/screenshotting/server/plugin.ts @@ -85,11 +85,9 @@ export class ScreenshottingPlugin implements Plugin { const browserDriverFactory = await this.browserDriverFactory; - const logger = this.logger.get('screenshot'); - return new Screenshots( browserDriverFactory, - logger, + this.logger, this.packageInfo, http, this.config, diff --git a/x-pack/plugins/screenshotting/server/screenshots/__snapshots__/index.test.ts.snap b/x-pack/plugins/screenshotting/server/screenshots/__snapshots__/index.test.ts.snap index c0971c9b95763..1b3826ce9980d 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/__snapshots__/index.test.ts.snap +++ b/x-pack/plugins/screenshotting/server/screenshots/__snapshots__/index.test.ts.snap @@ -20,7 +20,7 @@ Array [ }, }, ], - "error": [Error: The "wait for elements" phase encountered an error: Error: An error occurred when trying to read the page for visualization panel info: Mock error!], + "error": [Error: The "wait for elements" phase encountered an error: Error: Mock error!], "renderErrors": undefined, "screenshots": Array [ Object { @@ -63,7 +63,7 @@ Array [ }, }, ], - "error": [Error: The "wait for elements" phase encountered an error: Error: An error occurred when trying to read the page for visualization panel info: Mock error!], + "error": [Error: The "wait for elements" phase encountered an error: Error: Mock error!], "renderErrors": undefined, "screenshots": Array [ Object { diff --git a/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.test.ts b/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.test.ts new file mode 100644 index 0000000000000..3a20c404ff497 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.test.ts @@ -0,0 +1,261 @@ +/* + * 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 moment from 'moment'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { Actions, EventLogger, ScreenshottingAction, Transactions } from '.'; +import { ElementPosition } from '../get_element_position_data'; +import { ConfigType } from '../../config'; + +jest.mock('uuid', () => ({ + v4: () => 'NEW_UUID', +})); + +type EventLoggerArgs = [message: string, meta: ScreenshottingAction]; +describe('Event Logger', () => { + let eventLogger: EventLogger; + let config: ConfigType; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + const testDate = moment(new Date('2021-04-12T16:00:00.000Z')); + let delaySeconds = 1; + + jest.spyOn(global.Date, 'now').mockImplementation(() => { + return testDate.add(delaySeconds++, 'seconds').valueOf(); + }); + + const logger = loggingSystemMock.createLogger(); + config = { capture: { zoom: 2 } } as ConfigType; + eventLogger = new EventLogger(logger, config); + + logSpy = jest.spyOn(logger, 'debug') as jest.SpyInstance; + }); + + it('creates logs for the events and includes durations and event payload data', () => { + const screenshottingEnd = eventLogger.startTransaction(Transactions.SCREENSHOTTING); + const openUrlEnd = eventLogger.logScreenshottingEvent( + 'open the url to the Kibana application', + Actions.OPEN_URL, + 'wait' + ); + openUrlEnd(); + const getElementPositionsEnd = eventLogger.logScreenshottingEvent( + 'scan the page to find the boundaries of visualization elements', + Actions.GET_ELEMENT_POSITION_DATA, + 'wait' + ); + getElementPositionsEnd(); + screenshottingEnd({ + labels: { + cpu: 12, + cpu_percentage: 0, + memory: 450789, + memory_mb: 449, + byte_length: 14000, + }, + }); + + const pdfEnd = eventLogger.startTransaction(Transactions.PDF); + const addImageEnd = eventLogger.logPdfEvent( + 'add image to the PDF file', + Actions.ADD_IMAGE, + 'output' + ); + addImageEnd(); + pdfEnd({ labels: { pdf_pages: 1, byte_length_pdf: 6666 } }); + + const logs = logSpy.mock.calls.map(([message, data]) => ({ + message, + duration: data?.event?.duration, + screenshotting: data?.kibana?.screenshotting, + })); + + expect(logs.length).toBe(10); + expect(logs).toMatchInlineSnapshot(` + Array [ + Object { + "duration": undefined, + "message": "starting: screenshot-pipeline", + "screenshotting": Object { + "action": "screenshot-pipeline-start", + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": undefined, + "message": "starting: open the url to the Kibana application", + "screenshotting": Object { + "action": "open-url-start", + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": 3000, + "message": "completed: open the url to the Kibana application", + "screenshotting": Object { + "action": "open-url-complete", + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": undefined, + "message": "starting: scan the page to find the boundaries of visualization elements", + "screenshotting": Object { + "action": "get-element-position-data-start", + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": 5000, + "message": "completed: scan the page to find the boundaries of visualization elements", + "screenshotting": Object { + "action": "get-element-position-data-complete", + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": 20000, + "message": "completed: screenshot-pipeline", + "screenshotting": Object { + "action": "screenshot-pipeline-complete", + "byte_length": 14000, + "cpu": 12, + "cpu_percentage": 0, + "memory": 450789, + "memory_mb": 449, + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": undefined, + "message": "starting: generate-pdf", + "screenshotting": Object { + "action": "generate-pdf-start", + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": undefined, + "message": "starting: add image to the PDF file", + "screenshotting": Object { + "action": "add-pdf-image-start", + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": 9000, + "message": "completed: add image to the PDF file", + "screenshotting": Object { + "action": "add-pdf-image-complete", + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": 27000, + "message": "completed: generate-pdf", + "screenshotting": Object { + "action": "generate-pdf-complete", + "byte_length_pdf": 6666, + "pdf_pages": 1, + "session_id": "NEW_UUID", + }, + }, + ] + `); + }); + + it('logs the number of pixels', () => { + const elementPosition = { + boundingClientRect: { width: 1350, height: 2000 }, + scroll: {}, + } as ElementPosition; + const endScreenshot = eventLogger.logScreenshottingEvent( + 'screenshot capture test', + Actions.GET_SCREENSHOT, + 'read', + eventLogger.getPixelsFromElementPosition(elementPosition) + ); + endScreenshot({ byte_length: 4444 }); + + const logData = logSpy.mock.calls.map(([message, data]) => ({ + message, + duration: data.event?.duration, + screenshotting: data.kibana.screenshotting, + })); + + expect(logData).toMatchInlineSnapshot(` + Array [ + Object { + "duration": undefined, + "message": "starting: screenshot capture test", + "screenshotting": Object { + "action": "get-screenshots-start", + "pixels": 10800000, + "session_id": "NEW_UUID", + }, + }, + Object { + "duration": 2000, + "message": "completed: screenshot capture test", + "screenshotting": Object { + "action": "get-screenshots-complete", + "byte_length": 4444, + "pixels": 10800000, + "session_id": "NEW_UUID", + }, + }, + ] + `); + }); + + it('creates helpful error logs', () => { + eventLogger.startTransaction(Transactions.SCREENSHOTTING); + eventLogger.logScreenshottingEvent('opening the url', Actions.OPEN_URL, 'wait'); + eventLogger.error(new Error('Something erroneous happened'), Transactions.SCREENSHOTTING); + + const logData = logSpy.mock.calls.map(([message, data]) => ({ + message, + error: data.error, + screenshotting: data.kibana.screenshotting, + })); + + expect(logData).toMatchInlineSnapshot(` + Array [ + Object { + "error": undefined, + "message": "starting: screenshot-pipeline", + "screenshotting": Object { + "action": "screenshot-pipeline-start", + "session_id": "NEW_UUID", + }, + }, + Object { + "error": undefined, + "message": "starting: opening the url", + "screenshotting": Object { + "action": "open-url-start", + "session_id": "NEW_UUID", + }, + }, + Object { + "error": Object { + "code": undefined, + "message": "Something erroneous happened", + "stack_trace": undefined, + "type": undefined, + }, + "message": "Error: Something erroneous happened", + "screenshotting": Object { + "action": "screenshot-pipeline-error", + "session_id": "NEW_UUID", + }, + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.ts b/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.ts new file mode 100644 index 0000000000000..033fb24c80685 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.ts @@ -0,0 +1,315 @@ +/* + * 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 { Logger, LogMeta } from '@kbn/core/server'; +import apm from 'elastic-apm-node'; +import uuid from 'uuid'; +import { CaptureResult } from '..'; +import { PLUGIN_ID } from '../../../common'; +import { ConfigType } from '../../config'; +import { ElementPosition } from '../get_element_position_data'; +import { Screenshot } from '../get_screenshots'; + +export enum Actions { + OPEN_URL = 'open-url', + GET_ELEMENT_POSITION_DATA = 'get-element-position-data', + GET_NUMBER_OF_ITEMS = 'get-number-of-items', + GET_RENDER_ERRORS = 'get-render-errors', + GET_TIMERANGE = 'get-timerange', + INJECT_CSS = 'inject-css', + REPOSITION = 'position-elements', + WAIT_RENDER = 'wait-for-render', + WAIT_VISUALIZATIONS = 'wait-for-visualizations', + GET_SCREENSHOT = 'get-screenshots', + ADD_IMAGE = 'add-pdf-image', + COMPILE = 'compile-pdf', +} + +export enum Transactions { + SCREENSHOTTING = 'screenshot-pipeline', + PDF = 'generate-pdf', +} + +export type SpanTypes = 'setup' | 'read' | 'wait' | 'correction' | 'output'; + +export interface ScreenshottingAction extends LogMeta { + event?: { + duration?: number; // number of nanoseconds from begin to end of an event + provider: typeof PLUGIN_ID; + }; + + message: string; + kibana: { + screenshotting: { + action: Actions | Transactions; + session_id: string; + + // chromium stats + cpu?: number; + cpu_percentage?: number; + memory?: number; + memory_mb?: number; + + // screenshotting stats + items_count?: number; + pixels?: number; + byte_length?: number; + element_positions?: number; + render_errors?: number; + + // pdf stats + byte_length_pdf?: number; + pdf_pages?: number; + }; + }; +} + +interface ErrorAction { + message: string; + code?: string; + stack_trace?: string; + type?: string; +} + +type SimpleEvent = Omit; + +type LogAdapter = ( + message: string, + suffix: 'start' | 'complete' | 'error', + event: Partial, + startTime?: Date | undefined +) => void; + +type Labels = Record; +type TransactionEndFn = (args: { labels: Partial }) => void; +type LogEndFn = (metricData?: Partial) => void; + +function fillLogData( + message: string, + event: Partial, + suffix: 'start' | 'complete' | 'error', + sessionId: string, + duration: number | undefined +) { + let newMessage = message; + if (suffix !== 'error') { + newMessage = `${suffix === 'start' ? 'starting' : 'completed'}: ${message}`; + } + + let interpretedAction: string; + if (suffix === 'error') { + interpretedAction = event.action + '-error'; + } else { + interpretedAction = event.action + `-${suffix}`; + } + + const logData: ScreenshottingAction = { + message: newMessage, + kibana: { + screenshotting: { + ...event, + action: interpretedAction as Actions, + session_id: sessionId, + }, + }, + event: { duration, provider: PLUGIN_ID }, + }; + return logData; +} + +function logAdapter(logger: Logger, sessionId: string) { + const log: LogAdapter = (message, suffix, event, startTime) => { + let duration: number | undefined; + if (startTime != null) { + const start = startTime.valueOf(); + duration = new Date(Date.now()).valueOf() - start.valueOf(); + } + + const logData = fillLogData(message, event, suffix, sessionId, duration); + logger.debug(logData.message, logData); + }; + return log; +} + +/** + * A class to use internal state properties to log timing between actions in the screenshotting pipeline + */ +export class EventLogger { + private spans = new Map(); + private transactions: Record = { + 'screenshot-pipeline': null, + 'generate-pdf': null, + }; + + private sessionId: string; // identifier to track all logs from one screenshotting flow + private logEvent: LogAdapter; + private timings: Partial> = {}; + + constructor(private readonly logger: Logger, private readonly config: ConfigType) { + this.sessionId = uuid.v4(); + this.logEvent = logAdapter(logger.get('events'), this.sessionId); + } + + private startTiming(a: Actions | Transactions) { + this.timings[a] = new Date(Date.now()); + } + + /** + * @returns Logger - original logger + */ + public get kbnLogger() { + return this.logger; + } + + /** + * General method for logging the beginning of any of this plugin's pipeline + * + * @returns {ScreenshottingEndFn} + */ + public startTransaction( + action: Transactions.SCREENSHOTTING | Transactions.PDF + ): TransactionEndFn { + this.transactions[action] = apm.startTransaction(action, PLUGIN_ID); + const transaction = this.transactions[action]; + + this.startTiming(action); + this.logEvent(action, 'start', { action }); + + return ({ labels }) => { + Object.entries(labels).forEach(([label]) => { + const labelField = label as keyof SimpleEvent; + const labelValue = labels[labelField]; + transaction?.setLabel(label, labelValue, false); + }); + + transaction?.end(); + + this.logEvent(action, 'complete', { ...labels, action }, this.timings[action]); + }; + } + + /** + * General event logging function + * + * @param {string} message + * @param {Actions} action - action type for kibana.screenshotting.action + * @param {TransactionType} transaction - name of the internal APM transaction in which to associate the span + * @param {SpanTypes} type - identifier of the span type + * @param {metricsPre} type - optional metrics to add to the "start" log of the event + * @returns {LogEndFn} - function to log the end of the span + */ + public log( + message: string, + action: Actions, + type: SpanTypes, + metricsPre: Partial = {}, + transaction: Transactions + ): LogEndFn { + const txn = this.transactions[transaction]; + const span = txn?.startSpan(action, type); + + this.spans.set(action, span); + this.startTiming(action); + this.logEvent(message, 'start', { ...metricsPre, action }); + + return (metricData = {}) => { + span?.end(); + this.logEvent( + message, + 'complete', + { ...metricsPre, ...metricData, action }, + this.timings[action] + ); + }; + } + + /** + * Logging helper for screenshotting events + */ + public logScreenshottingEvent( + message: string, + action: Actions, + type: SpanTypes, + metricsPre: Partial = {} + ) { + return this.log(message, action, type, metricsPre, Transactions.SCREENSHOTTING); + } + + /** + * Logging helper for screenshotting events + */ + public logPdfEvent( + message: string, + action: Actions, + type: SpanTypes, + metricsPre: Partial = {} + ) { + return this.log(message, action, type, metricsPre, Transactions.PDF); + } + + /** + * Helper function to calculate the byte length of a set of captured PNG images + */ + public getByteLengthFromCaptureResults( + results: CaptureResult['results'] + ): Pick { + const totalByteLength = results.reduce( + (totals, { screenshots }) => + totals + + screenshots.reduce( + (byteLength: number, screenshot: Screenshot) => byteLength + screenshot.data.byteLength, + 0 + ), + 0 + ); + return { byte_length: totalByteLength }; + } + + /** + * Helper function to create the "metricPre" data needed to log the start + * of a screenshot capture event. + */ + public getPixelsFromElementPosition( + elementPosition: ElementPosition + ): Pick { + const { width, height } = elementPosition.boundingClientRect; + const zoom = this.config.capture.zoom; + const pixels = width * zoom * (height * zoom); + return { pixels }; + } + + /** + * General error logger + * + * @param {ErrorAction} error: The error object that was caught + * @param {Actions} action: The screenshotting action type + * @returns void + */ + public error(error: ErrorAction | string, action: Actions | Transactions) { + const isError = typeof error === 'object'; + const message = `Error: ${isError ? error.message : error}`; + + const errorData = { + ...fillLogData( + message, + { action }, + 'error', + this.sessionId, + undefined // + ), + error: { + message: isError ? error.message : error, + code: isError ? error.code : undefined, + stack_trace: isError ? error.stack_trace : undefined, + type: isError ? error.type : undefined, + }, + }; + + this.logger.get('events').debug(message, errorData); + apm.captureError(error as Error | string); + } +} diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.test.ts index 915b036acf22e..f3a76ca79d85f 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.test.ts @@ -5,20 +5,24 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; import { createMockBrowserDriver } from '../browsers/mock'; +import { ConfigType } from '../config'; import { createMockLayout } from '../layouts/mock'; +import { EventLogger } from './event_logger'; import { getElementPositionAndAttributes } from './get_element_position_data'; describe('getElementPositionAndAttributes', () => { - const logger = {} as jest.Mocked; let browser: ReturnType; let layout: ReturnType; + let eventLogger: EventLogger; + let config = {} as ConfigType; beforeEach(async () => { browser = createMockBrowserDriver(); layout = createMockLayout(); - + config = { capture: { zoom: 2 } } as ConfigType; + eventLogger = new EventLogger(loggingSystemMock.createLogger(), config); browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); // @see https://github.com/jsdom/jsdom/issues/653 @@ -59,7 +63,7 @@ describe('getElementPositionAndAttributes', () => { /> `; - await expect(getElementPositionAndAttributes(browser, logger, layout)).resolves + await expect(getElementPositionAndAttributes(browser, eventLogger, layout)).resolves .toMatchInlineSnapshot(` Array [ Object { @@ -103,6 +107,6 @@ describe('getElementPositionAndAttributes', () => { }); it('should return null when there are no elements matching', async () => { - await expect(getElementPositionAndAttributes(browser, logger, layout)).resolves.toBeNull(); + await expect(getElementPositionAndAttributes(browser, eventLogger, layout)).resolves.toBeNull(); }); }); diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.ts b/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.ts index e3235c6d23253..5018701ce2411 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.ts @@ -5,11 +5,10 @@ * 2.0. */ -import apm from 'elastic-apm-node'; -import type { Logger } from '@kbn/core/server'; import type { HeadlessChromiumDriver } from '../browsers'; import { Layout } from '../layouts'; import { CONTEXT_ELEMENTATTRIBUTES } from './constants'; +import { Actions, EventLogger } from './event_logger'; export interface AttributesMap { [key: string]: string | null; @@ -36,10 +35,17 @@ export interface ElementsPositionAndAttribute { export const getElementPositionAndAttributes = async ( browser: HeadlessChromiumDriver, - logger: Logger, + eventLogger: EventLogger, layout: Layout ): Promise => { - const span = apm.startSpan('get_element_position_data', 'read'); + const { kbnLogger } = eventLogger; + + const spanEnd = eventLogger.logScreenshottingEvent( + 'get element position data', + Actions.GET_ELEMENT_POSITION_DATA, + 'read' + ); + const { screenshot: screenshotSelector } = layout.selectors; // data-shared-items-container let elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; try { @@ -77,7 +83,7 @@ export const getElementPositionAndAttributes = async ( args: [screenshotSelector, { title: 'data-title', description: 'data-description' }], }, { context: CONTEXT_ELEMENTATTRIBUTES }, - logger + kbnLogger ); if (!elementsPositionAndAttributes?.length) { @@ -86,10 +92,13 @@ export const getElementPositionAndAttributes = async ( ); } } catch (err) { + kbnLogger.error(err); + eventLogger.error(err, Actions.GET_ELEMENT_POSITION_DATA); elementsPositionAndAttributes = null; + // no throw } - span?.end(); + spanEnd({ element_positions: elementsPositionAndAttributes?.length }); return elementsPositionAndAttributes; }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.test.ts index 34b8291eb03da..a7c4f27065bcf 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.test.ts @@ -5,22 +5,25 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; import { createMockBrowserDriver } from '../browsers/mock'; +import { ConfigType } from '../config'; import { createMockLayout } from '../layouts/mock'; +import { EventLogger } from './event_logger'; import { getNumberOfItems } from './get_number_of_items'; describe('getNumberOfItems', () => { const timeout = 10; let browser: ReturnType; let layout: ReturnType; - let logger: jest.Mocked; + let eventLogger: EventLogger; + let config = {} as ConfigType; beforeEach(async () => { browser = createMockBrowserDriver(); layout = createMockLayout(); - logger = { debug: jest.fn() } as unknown as jest.Mocked; - + config = { capture: { zoom: 2 } } as ConfigType; + eventLogger = new EventLogger(loggingSystemMock.createLogger(), config); browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); }); @@ -33,7 +36,7 @@ describe('getNumberOfItems', () => {
`; - await expect(getNumberOfItems(browser, logger, timeout, layout)).resolves.toBe(10); + await expect(getNumberOfItems(browser, eventLogger, timeout, layout)).resolves.toBe(10); }); it('should determine the number of items by selector ', async () => { @@ -43,7 +46,7 @@ describe('getNumberOfItems', () => { `; - await expect(getNumberOfItems(browser, logger, timeout, layout)).resolves.toBe(3); + await expect(getNumberOfItems(browser, eventLogger, timeout, layout)).resolves.toBe(3); }); it('should fall back to the selector when the attribute is empty', async () => { @@ -53,6 +56,6 @@ describe('getNumberOfItems', () => { `; - await expect(getNumberOfItems(browser, logger, timeout, layout)).resolves.toBe(2); + await expect(getNumberOfItems(browser, eventLogger, timeout, layout)).resolves.toBe(2); }); }); diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.ts b/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.ts index 9dab001e4730d..0e4da2fe5cf6a 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.ts @@ -5,24 +5,27 @@ * 2.0. */ -import apm from 'elastic-apm-node'; -import type { Logger } from '@kbn/core/server'; import type { HeadlessChromiumDriver } from '../browsers'; import { Layout } from '../layouts'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; +import { Actions, EventLogger } from './event_logger'; export const getNumberOfItems = async ( browser: HeadlessChromiumDriver, - logger: Logger, + eventLogger: EventLogger, timeout: number, layout: Layout ): Promise => { - const span = apm.startSpan('get_number_of_items', 'read'); + const { kbnLogger } = eventLogger; + const spanEnd = eventLogger.logScreenshottingEvent( + 'get the number of visualization items on the page', + Actions.GET_NUMBER_OF_ITEMS, + 'read' + ); + const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; let itemsCount: number; - logger.debug('waiting for elements or items count attribute; or not found to interrupt'); - try { // the dashboard is using the `itemsCountAttribute` attribute to let us // know how many items to expect since gridster incrementally adds panels @@ -31,7 +34,7 @@ export const getNumberOfItems = async ( `${renderCompleteSelector},[${itemsCountAttribute}]`, { timeout }, { context: CONTEXT_READMETADATA }, - logger + kbnLogger ); // returns the value of the `itemsCountAttribute` if it's there, otherwise @@ -52,16 +55,15 @@ export const getNumberOfItems = async ( args: [renderCompleteSelector, itemsCountAttribute], }, { context: CONTEXT_GETNUMBEROFITEMS }, - logger + kbnLogger ); } catch (error) { - logger.error(error); - throw new Error( - `An error occurred when trying to read the page for visualization panel info: ${error.message}` - ); + kbnLogger.error(error); + eventLogger.error(error, Actions.GET_NUMBER_OF_ITEMS); + throw error; } - span?.end(); + spanEnd({ items_count: itemsCount }); return itemsCount; }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.test.ts index a475e3c614c15..ece25b37725c8 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.test.ts @@ -5,21 +5,24 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; import { createMockBrowserDriver } from '../browsers/mock'; +import { ConfigType } from '../config'; import { createMockLayout } from '../layouts/mock'; +import { EventLogger } from './event_logger'; import { getRenderErrors } from './get_render_errors'; describe('getRenderErrors', () => { let browser: ReturnType; let layout: ReturnType; - let logger: jest.Mocked; + let eventLogger: EventLogger; + let config = {} as ConfigType; beforeEach(async () => { browser = createMockBrowserDriver(); layout = createMockLayout(); - logger = { debug: jest.fn(), warn: jest.fn() } as unknown as jest.Mocked; - + config = { capture: { zoom: 2 } } as ConfigType; + eventLogger = new EventLogger(loggingSystemMock.createLogger(), config); browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); }); @@ -35,7 +38,7 @@ describe('getRenderErrors', () => {
`; - await expect(getRenderErrors(browser, logger, layout)).resolves.toEqual([ + await expect(getRenderErrors(browser, eventLogger, layout)).resolves.toEqual([ 'a test error', 'a test error', 'a test error', @@ -48,6 +51,6 @@ describe('getRenderErrors', () => { `; - await expect(getRenderErrors(browser, logger, layout)).resolves.toEqual(undefined); + await expect(getRenderErrors(browser, eventLogger, layout)).resolves.toEqual(undefined); }); }); diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.ts b/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.ts index e8beb18911210..44b92ceddbc8d 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.ts @@ -5,45 +5,59 @@ * 2.0. */ -import apm from 'elastic-apm-node'; -import type { Logger } from '@kbn/core/server'; import type { HeadlessChromiumDriver } from '../browsers'; import type { Layout } from '../layouts'; import { CONTEXT_GETRENDERERRORS } from './constants'; +import { Actions, EventLogger } from './event_logger'; export const getRenderErrors = async ( browser: HeadlessChromiumDriver, - logger: Logger, + eventLogger: EventLogger, layout: Layout ): Promise => { - const span = apm.startSpan('get_render_errors', 'read'); - logger.debug('reading render errors'); - const errorsFound: undefined | string[] = await browser.evaluate( - { - fn: (errorSelector, errorAttribute) => { - const visualizations: Element[] = Array.from(document.querySelectorAll(errorSelector)); - const errors: string[] = []; - - visualizations.forEach((visualization) => { - const errorMessage = visualization.getAttribute(errorAttribute); - if (errorMessage) { - errors.push(errorMessage); - } - }); - - return errors.length ? errors : undefined; - }, - args: [layout.selectors.renderError, layout.selectors.renderErrorAttribute], - }, - { context: CONTEXT_GETRENDERERRORS }, - logger + const { kbnLogger } = eventLogger; + + const spanEnd = eventLogger.logScreenshottingEvent( + 'look for render errors', + Actions.GET_RENDER_ERRORS, + 'read' ); - span?.end(); - if (errorsFound?.length) { - logger.warn( - `Found ${errorsFound.length} error messages. See report object for more information.` + let errorsFound: undefined | string[]; + try { + errorsFound = await browser.evaluate( + { + fn: (errorSelector, errorAttribute) => { + const visualizations: Element[] = Array.from(document.querySelectorAll(errorSelector)); + const errors: string[] = []; + + visualizations.forEach((visualization) => { + const errorMessage = visualization.getAttribute(errorAttribute); + if (errorMessage) { + errors.push(errorMessage); + } + }); + + return errors.length ? errors : undefined; + }, + args: [layout.selectors.renderError, layout.selectors.renderErrorAttribute], + }, + { context: CONTEXT_GETRENDERERRORS }, + kbnLogger ); + + const renderErrors = errorsFound?.length; + if (renderErrors) { + kbnLogger.warn( + `Found ${renderErrors} error messages. See report object for more information.` + ); + } + + spanEnd({ render_errors: renderErrors }); + } catch (error) { + kbnLogger.error(error); + eventLogger.error(error, Actions.GET_RENDER_ERRORS); + throw error; } return errorsFound; diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.test.ts index 1f104b9bf2d80..c2342280aea20 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.test.ts @@ -5,8 +5,10 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; import { createMockBrowserDriver } from '../browsers/mock'; +import { ConfigType } from '../config'; +import { EventLogger } from './event_logger'; import { getScreenshots } from './get_screenshots'; describe('getScreenshots', () => { @@ -27,12 +29,13 @@ describe('getScreenshots', () => { }, ]; let browser: ReturnType; - let logger: jest.Mocked; + let eventLogger: EventLogger; + let config = {} as ConfigType; beforeEach(async () => { browser = createMockBrowserDriver(); - logger = { info: jest.fn() } as unknown as jest.Mocked; - + config = { capture: { zoom: 2 } } as ConfigType; + eventLogger = new EventLogger(loggingSystemMock.createLogger(), config); browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); }); @@ -41,7 +44,7 @@ describe('getScreenshots', () => { }); it('should return screenshots', async () => { - await expect(getScreenshots(browser, logger, elementsPositionAndAttributes)).resolves + await expect(getScreenshots(browser, eventLogger, elementsPositionAndAttributes)).resolves .toMatchInlineSnapshot(` Array [ Object { @@ -87,7 +90,7 @@ describe('getScreenshots', () => { }); it('should forward elements positions', async () => { - await getScreenshots(browser, logger, elementsPositionAndAttributes); + await getScreenshots(browser, eventLogger, elementsPositionAndAttributes); expect(browser.screenshot).toHaveBeenCalledTimes(2); expect(browser.screenshot).toHaveBeenNthCalledWith( @@ -104,7 +107,7 @@ describe('getScreenshots', () => { browser.screenshot.mockResolvedValue(Buffer.from('')); await expect( - getScreenshots(browser, logger, elementsPositionAndAttributes) + getScreenshots(browser, eventLogger, elementsPositionAndAttributes) ).rejects.toBeInstanceOf(Error); }); }); diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts index 53829b098ee8c..f157649bbb848 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts @@ -5,9 +5,8 @@ * 2.0. */ -import apm from 'elastic-apm-node'; -import type { Logger } from '@kbn/core/server'; import type { HeadlessChromiumDriver } from '../browsers'; +import { Actions, EventLogger } from './event_logger'; import type { ElementsPositionAndAttribute } from './get_element_position_data'; export interface Screenshot { @@ -29,33 +28,45 @@ export interface Screenshot { export const getScreenshots = async ( browser: HeadlessChromiumDriver, - logger: Logger, + eventLogger: EventLogger, elementsPositionAndAttributes: ElementsPositionAndAttribute[] ): Promise => { - logger.info(`taking screenshots`); + const { kbnLogger } = eventLogger; + kbnLogger.info(`taking screenshots`); const screenshots: Screenshot[] = []; - for (let i = 0; i < elementsPositionAndAttributes.length; i++) { - const span = apm.startSpan('get_screenshots', 'read'); - const item = elementsPositionAndAttributes[i]; + try { + for (let i = 0; i < elementsPositionAndAttributes.length; i++) { + const item = elementsPositionAndAttributes[i]; + const endScreenshot = eventLogger.logScreenshottingEvent( + 'screenshot capture', + Actions.GET_SCREENSHOT, + 'read', + eventLogger.getPixelsFromElementPosition(item.position) + ); - const data = await browser.screenshot(item.position); + const data = await browser.screenshot(item.position); - if (!data?.byteLength) { - throw new Error(`Failure in getScreenshots! Screenshot data is void`); - } + if (!data?.byteLength) { + throw new Error(`Failure in getScreenshots! Screenshot data is void`); + } - screenshots.push({ - data, - title: item.attributes.title, - description: item.attributes.description, - }); + screenshots.push({ + data, + title: item.attributes.title, + description: item.attributes.description, + }); - span?.end(); + endScreenshot({ byte_length: data.byteLength }); + } + } catch (error) { + kbnLogger.error(error); + eventLogger.error(error, Actions.GET_SCREENSHOT); + throw error; } - logger.info(`screenshots taken: ${screenshots.length}`); + kbnLogger.info(`screenshots taken: ${screenshots.length}`); return screenshots; }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_time_range.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_time_range.test.ts index 8484412f5fd94..a7a7b9295068e 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_time_range.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_time_range.test.ts @@ -5,21 +5,24 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; import { createMockBrowserDriver } from '../browsers/mock'; +import { ConfigType } from '../config'; import { createMockLayout } from '../layouts/mock'; +import { EventLogger } from './event_logger'; import { getTimeRange } from './get_time_range'; describe('getTimeRange', () => { let browser: ReturnType; let layout: ReturnType; - let logger: jest.Mocked; + let eventLogger: EventLogger; + let config = {} as ConfigType; beforeEach(async () => { browser = createMockBrowserDriver(); layout = createMockLayout(); - logger = { debug: jest.fn(), info: jest.fn() } as unknown as jest.Mocked; - + config = { capture: { zoom: 2 } } as ConfigType; + eventLogger = new EventLogger(loggingSystemMock.createLogger(), config); browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); }); @@ -28,7 +31,7 @@ describe('getTimeRange', () => { }); it('should return null when there is no duration element', async () => { - await expect(getTimeRange(browser, logger, layout)).resolves.toBeNull(); + await expect(getTimeRange(browser, eventLogger, layout)).resolves.toBeNull(); }); it('should return null when duration attrbute is empty', async () => { @@ -36,7 +39,7 @@ describe('getTimeRange', () => {
`; - await expect(getTimeRange(browser, logger, layout)).resolves.toBeNull(); + await expect(getTimeRange(browser, eventLogger, layout)).resolves.toBeNull(); }); it('should return duration', async () => { @@ -44,6 +47,6 @@ describe('getTimeRange', () => {
`; - await expect(getTimeRange(browser, logger, layout)).resolves.toBe('10'); + await expect(getTimeRange(browser, eventLogger, layout)).resolves.toBe('10'); }); }); diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_time_range.ts b/x-pack/plugins/screenshotting/server/screenshots/get_time_range.ts index 41d902436d36b..f9272fd27ac95 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_time_range.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_time_range.ts @@ -5,19 +5,21 @@ * 2.0. */ -import apm from 'elastic-apm-node'; -import type { Logger } from '@kbn/core/server'; import type { HeadlessChromiumDriver } from '../browsers'; import { Layout } from '../layouts'; import { CONTEXT_GETTIMERANGE } from './constants'; +import { Actions, EventLogger } from './event_logger'; export const getTimeRange = async ( browser: HeadlessChromiumDriver, - logger: Logger, + eventLogger: EventLogger, layout: Layout ): Promise => { - const span = apm.startSpan('get_time_range', 'read'); - logger.debug('getting timeRange'); + const spanEnd = eventLogger.logScreenshottingEvent( + 'looking for time range', + Actions.GET_TIMERANGE, + 'read' + ); const timeRange = await browser.evaluate( { @@ -38,16 +40,14 @@ export const getTimeRange = async ( args: [layout.selectors.timefilterDurationAttribute], }, { context: CONTEXT_GETTIMERANGE }, - logger + eventLogger.kbnLogger ); if (timeRange) { - logger.info(`timeRange: ${timeRange}`); - } else { - logger.debug('no timeRange'); + eventLogger.kbnLogger.info(`timeRange: ${timeRange}`); } - span?.end(); + spanEnd(); return timeRange; }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.ts b/x-pack/plugins/screenshotting/server/screenshots/index.ts index b98270547dbec..33404bb5fadc2 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.ts @@ -8,8 +8,6 @@ import type { HttpServiceSetup, KibanaRequest, Logger, PackageInfo } from '@kbn/core/server'; import type { ExpressionAstExpression } from '@kbn/expressions-plugin/common'; import type { Optional } from '@kbn/utility-types'; -import type { Transaction } from 'elastic-apm-node'; -import apm from 'elastic-apm-node'; import ipaddr from 'ipaddr.js'; import { defaultsDeep, sum } from 'lodash'; import { from, Observable, of, throwError } from 'rxjs'; @@ -46,6 +44,7 @@ import { } from '../formats'; import type { Layout } from '../layouts'; import { createLayout } from '../layouts'; +import { EventLogger, Transactions } from './event_logger'; import type { ScreenshotObservableOptions, ScreenshotObservableResult } from './observable'; import { ScreenshotObservableHandler, UrlOrUrlWithContext } from './observable'; import { Semaphore } from './semaphore'; @@ -110,21 +109,18 @@ export class Screenshots { this.semaphore = new Semaphore(config.poolSize); } - private createLayout(transaction: Transaction | null, options: CaptureOptions): Layout { - const apmCreateLayout = transaction?.startSpan('create-layout', 'setup'); + private createLayout(options: CaptureOptions): Layout { const layout = createLayout(options.layout ?? {}); this.logger.debug(`Layout: width=${layout.width} height=${layout.height}`); - apmCreateLayout?.end(); return layout; } private captureScreenshots( + eventLogger: EventLogger, layout: Layout, - transaction: Transaction | null, options: ScreenshotObservableOptions ): Observable { - const apmCreatePage = transaction?.startSpan('create-page', 'wait'); const { browserTimezone } = options; return this.browserDriverFactory @@ -139,24 +135,22 @@ export class Screenshots { .pipe( this.semaphore.acquire(), mergeMap(({ driver, unexpectedExit$, close }) => { - apmCreatePage?.end(); - unexpectedExit$.subscribe({ error: () => transaction?.end() }); - const screen = new ScreenshotObservableHandler( driver, this.config, - this.logger, + eventLogger, layout, options ); return from(options.urls).pipe( concatMap((url, index) => - screen.setupPage(index, url, transaction).pipe( + screen.setupPage(index, url).pipe( catchError((error) => { screen.checkPageIsOpen(); // this fails the job if the browser has closed this.logger.error(error); + eventLogger.error(error, Transactions.SCREENSHOTTING); return of({ ...DEFAULT_SETUP_RESULT, error }); // allow failover screenshot capture }), takeUntil(unexpectedExit$), @@ -166,16 +160,8 @@ export class Screenshots { take(options.urls.length), toArray(), mergeMap((results) => - // At this point we no longer need the page, close it. - close().pipe( - tap(({ metrics }) => { - if (metrics) { - transaction?.setLabel('cpu', metrics.cpu, false); - transaction?.setLabel('memory', metrics.memory, false); - } - }), - map(({ metrics }) => ({ metrics, results })) - ) + // At this point we no longer need the page, close it and send out the results + close().pipe(map(({ metrics }) => ({ metrics, results }))) ) ); }), @@ -243,15 +229,28 @@ export class Screenshots { if (this.systemHasInsufficientMemory()) { return throwError(() => new errors.InsufficientMemoryAvailableOnCloudError()); } - const transaction = apm.startTransaction('screenshot-pipeline', 'screenshotting'); - const layout = this.createLayout(transaction, options); + + const eventLogger = new EventLogger(this.logger, this.config); + const transactionEnd = eventLogger.startTransaction(Transactions.SCREENSHOTTING); + + const layout = this.createLayout(options); const captureOptions = this.getCaptureOptions(options); - return this.captureScreenshots(layout, transaction, captureOptions).pipe( + return this.captureScreenshots(eventLogger, layout, captureOptions).pipe( + tap(({ results, metrics }) => { + transactionEnd({ + labels: { + cpu: metrics?.cpu, + memory: metrics?.memory, + memory_mb: metrics?.memoryInMegabytes, + ...eventLogger.getByteLengthFromCaptureResults(results), + }, + }); + }), mergeMap((result) => { switch (options.format) { case 'pdf': - return toPdf(this.logger, this.packageInfo, layout, options, result); + return toPdf(eventLogger, this.packageInfo, layout, options, result); default: return toPng(result); } diff --git a/x-pack/plugins/screenshotting/server/screenshots/inject_css.ts b/x-pack/plugins/screenshotting/server/screenshots/inject_css.ts index a77cfa8c9e8e6..41426e893ce58 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/inject_css.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/inject_css.ts @@ -7,26 +7,31 @@ import fs from 'fs'; import { promisify } from 'util'; -import apm from 'elastic-apm-node'; -import type { Logger } from '@kbn/core/server'; import type { HeadlessChromiumDriver } from '../browsers'; import { Layout } from '../layouts'; import { CONTEXT_INJECTCSS } from './constants'; +import { Actions, EventLogger } from './event_logger'; const fsp = { readFile: promisify(fs.readFile) }; export const injectCustomCss = async ( browser: HeadlessChromiumDriver, - logger: Logger, + eventLogger: EventLogger, layout: Layout ): Promise => { - const span = apm.startSpan('inject_css', 'correction'); - logger.debug('injecting custom css'); - const filePath = layout.getCssOverridesPath(); if (!filePath) { return; } + + const { kbnLogger } = eventLogger; + + const spanEnd = eventLogger.logScreenshottingEvent( + 'inject CSS into the page', + Actions.INJECT_CSS, + 'correction' + ); + const buffer = await fsp.readFile(filePath); try { await browser.evaluate( @@ -40,14 +45,15 @@ export const injectCustomCss = async ( args: [buffer.toString()], }, { context: CONTEXT_INJECTCSS }, - logger + kbnLogger ); } catch (err) { - logger.error(err); + kbnLogger.error(err); + eventLogger.error(err, Actions.INJECT_CSS); throw new Error( `An error occurred when trying to update Kibana CSS for reporting. ${err.message}` ); } - span?.end(); + spanEnd(); }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/observable.test.ts b/x-pack/plugins/screenshotting/server/screenshots/observable.test.ts index d3acc96411dc6..b282cd32bbd80 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/observable.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/observable.test.ts @@ -5,36 +5,33 @@ * 2.0. */ +import { loggingSystemMock } from '@kbn/core/server/mocks'; import { interval, of, throwError } from 'rxjs'; import { map } from 'rxjs/operators'; -import type { Logger } from '@kbn/core/server'; import { createMockBrowserDriver } from '../browsers/mock'; import type { ConfigType } from '../config'; import { createMockLayout } from '../layouts/mock'; +import { EventLogger } from './event_logger'; import { ScreenshotObservableHandler, ScreenshotObservableOptions } from './observable'; describe('ScreenshotObservableHandler', () => { let browser: ReturnType; let config: ConfigType; let layout: ReturnType; - let logger: jest.Mocked; + let eventLogger: EventLogger; let options: ScreenshotObservableOptions; beforeEach(async () => { browser = createMockBrowserDriver(); config = { capture: { - timeouts: { - openUrl: 30000, - waitForElements: 30000, - renderComplete: 30000, - }, + timeouts: { openUrl: 30000, waitForElements: 30000, renderComplete: 30000 }, loadDelay: 5000, zoom: 13, }, } as ConfigType; layout = createMockLayout(); - logger = { error: jest.fn() } as unknown as jest.Mocked; + eventLogger = new EventLogger(loggingSystemMock.createLogger(), config); options = { headers: { testHeader: 'testHeadValue' }, urls: [], @@ -46,7 +43,7 @@ describe('ScreenshotObservableHandler', () => { describe('waitUntil', () => { let screenshots: ScreenshotObservableHandler; beforeEach(() => { - screenshots = new ScreenshotObservableHandler(browser, config, logger, layout, options); + screenshots = new ScreenshotObservableHandler(browser, config, eventLogger, layout, options); }); it('catches TimeoutError and references the timeout config in a custom message', async () => { @@ -79,7 +76,7 @@ describe('ScreenshotObservableHandler', () => { describe('checkPageIsOpen', () => { let screenshots: ScreenshotObservableHandler; beforeEach(() => { - screenshots = new ScreenshotObservableHandler(browser, config, logger, layout, options); + screenshots = new ScreenshotObservableHandler(browser, config, eventLogger, layout, options); }); it('throws a decorated Error when page is not open', async () => { diff --git a/x-pack/plugins/screenshotting/server/screenshots/observable.ts b/x-pack/plugins/screenshotting/server/screenshots/observable.ts index b19f3f254b2a2..5048d3f0a3be6 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/observable.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/observable.ts @@ -5,15 +5,15 @@ * 2.0. */ -import type { Transaction } from 'elastic-apm-node'; +import type { Headers } from '@kbn/core/server'; import { defer, forkJoin, Observable, throwError } from 'rxjs'; import { catchError, mergeMap, switchMapTo, timeoutWith } from 'rxjs/operators'; -import type { Headers, Logger } from '@kbn/core/server'; import { errors } from '../../common'; import type { Context, HeadlessChromiumDriver } from '../browsers'; import { DEFAULT_VIEWPORT, getChromiumDisconnectedError } from '../browsers'; -import { durationToNumber as toNumber, ConfigType } from '../config'; +import { ConfigType, durationToNumber as toNumber } from '../config'; import type { Layout } from '../layouts'; +import { Actions, EventLogger } from './event_logger'; import type { ElementsPositionAndAttribute } from './get_element_position_data'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; @@ -90,7 +90,9 @@ interface PageSetupResults { renderErrors?: string[]; } -const getDefaultElementPosition = (dimensions: { height?: number; width?: number } | null) => { +const getDefaultElementPosition = ( + dimensions: { height?: number; width?: number } | null +): ElementsPositionAndAttribute[] => { const height = dimensions?.height || DEFAULT_VIEWPORT.height; const width = dimensions?.width || DEFAULT_VIEWPORT.width; @@ -118,7 +120,7 @@ export class ScreenshotObservableHandler { constructor( private readonly driver: HeadlessChromiumDriver, private readonly config: ConfigType, - private readonly logger: Logger, + private readonly eventLogger: EventLogger, private readonly layout: Layout, private options: ScreenshotObservableOptions ) {} @@ -154,7 +156,7 @@ export class ScreenshotObservableHandler { return openUrl( this.driver, - this.logger, + this.eventLogger, toNumber(this.config.capture.timeouts.openUrl), index, url, @@ -168,52 +170,70 @@ export class ScreenshotObservableHandler { const driver = this.driver; const waitTimeout = toNumber(this.config.capture.timeouts.waitForElements); - return defer(() => getNumberOfItems(driver, this.logger, waitTimeout, this.layout)).pipe( + return defer(() => getNumberOfItems(driver, this.eventLogger, waitTimeout, this.layout)).pipe( mergeMap(async (itemsCount) => { // set the viewport to the dimensions from the job, to allow elements to flow into the expected layout const viewport = this.layout.getViewport(itemsCount) || getDefaultViewPort(); // Set the viewport allowing time for the browser to handle reflow and redraw // before checking for readiness of visualizations. - await driver.setViewport(viewport, this.logger); - await waitForVisualizations(driver, this.logger, waitTimeout, itemsCount, this.layout); + await driver.setViewport(viewport, this.eventLogger.kbnLogger); + await waitForVisualizations(driver, this.eventLogger, waitTimeout, itemsCount, this.layout); }), this.waitUntil(waitTimeout, 'wait for elements') ); } - private completeRender(apmTrans: Transaction | null) { + private completeRender() { const driver = this.driver; const layout = this.layout; - const logger = this.logger; + const eventLogger = this.eventLogger; return defer(async () => { // Waiting till _after_ elements have rendered before injecting our CSS // allows for them to be displayed properly in many cases - await injectCustomCss(driver, logger, layout); + await injectCustomCss(driver, eventLogger, layout); - const apmPositionElements = apmTrans?.startSpan('position-elements', 'correction'); - // position panel elements for print layout - await layout.positionElements?.(driver, logger); - apmPositionElements?.end(); + const spanEnd = this.eventLogger.logScreenshottingEvent( + 'get positions of visualization elements', + Actions.GET_ELEMENT_POSITION_DATA, + 'read' + ); + try { + // position panel elements for print layout + await layout.positionElements?.(driver, eventLogger.kbnLogger); + spanEnd(); + } catch (error) { + eventLogger.error(error, Actions.GET_ELEMENT_POSITION_DATA); + throw error; + } - await waitForRenderComplete(driver, logger, toNumber(this.config.capture.loadDelay), layout); + await waitForRenderComplete( + driver, + eventLogger, + toNumber(this.config.capture.loadDelay), + layout + ); }).pipe( mergeMap(() => forkJoin({ - timeRange: getTimeRange(driver, logger, layout), - elementsPositionAndAttributes: getElementPositionAndAttributes(driver, logger, layout), - renderErrors: getRenderErrors(driver, logger, layout), + timeRange: getTimeRange(driver, eventLogger, layout), + elementsPositionAndAttributes: getElementPositionAndAttributes( + driver, + eventLogger, + layout + ), + renderErrors: getRenderErrors(driver, eventLogger, layout), }) ), this.waitUntil(toNumber(this.config.capture.timeouts.renderComplete), 'render complete') ); } - public setupPage(index: number, url: UrlOrUrlWithContext, apmTrans: Transaction | null) { + public setupPage(index: number, url: UrlOrUrlWithContext) { return this.openUrl(index, url).pipe( switchMapTo(this.waitForElements()), - switchMapTo(this.completeRender(apmTrans)) + switchMapTo(this.completeRender()) ); } @@ -227,7 +247,7 @@ export class ScreenshotObservableHandler { getDefaultElementPosition(this.layout.getViewport(1)); let screenshots: Screenshot[] = []; try { - screenshots = await getScreenshots(this.driver, this.logger, elements); + screenshots = await getScreenshots(this.driver, this.eventLogger, elements); } catch (e) { throw new errors.FailedToCaptureScreenshot(e.message); } diff --git a/x-pack/plugins/screenshotting/server/screenshots/open_url.ts b/x-pack/plugins/screenshotting/server/screenshots/open_url.ts index c557374ff9876..bdf8c678eb1d2 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/open_url.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/open_url.ts @@ -5,33 +5,39 @@ * 2.0. */ -import apm from 'elastic-apm-node'; -import type { Headers, Logger } from '@kbn/core/server'; -import type { HeadlessChromiumDriver } from '../browsers'; -import type { Context } from '../browsers'; +import type { Headers } from '@kbn/core/server'; +import type { Context, HeadlessChromiumDriver } from '../browsers'; import { DEFAULT_PAGELOAD_SELECTOR } from './constants'; +import { Actions, EventLogger } from './event_logger'; export const openUrl = async ( browser: HeadlessChromiumDriver, - logger: Logger, + eventLogger: EventLogger, timeout: number, index: number, url: string, context: Context, headers: Headers ): Promise => { + const { kbnLogger } = eventLogger; + const spanEnd = eventLogger.logScreenshottingEvent('open url', Actions.OPEN_URL, 'wait'); + // If we're moving to another page in the app, we'll want to wait for the app to tell us // it's loaded the next page. const page = index + 1; const waitForSelector = page > 1 ? `[data-shared-page="${page}"]` : DEFAULT_PAGELOAD_SELECTOR; - const span = apm.startSpan('open_url', 'wait'); try { - await browser.open(url, { context, headers, waitForSelector, timeout }, logger); + await browser.open(url, { context, headers, waitForSelector, timeout }, kbnLogger); } catch (err) { - logger.error(err); - throw new Error(`An error occurred when trying to open the Kibana URL: ${err.message}`); + kbnLogger.error(err); + + const newError = new Error( + `An error occurred when trying to open the Kibana URL: ${err.message}` + ); + eventLogger.error(newError, Actions.OPEN_URL); + throw newError; } - span?.end(); + spanEnd(); }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/semaphore.test.ts b/x-pack/plugins/screenshotting/server/screenshots/semaphore/index.test.ts similarity index 98% rename from x-pack/plugins/screenshotting/server/screenshots/semaphore.test.ts rename to x-pack/plugins/screenshotting/server/screenshots/semaphore/index.test.ts index 6d6dd21347974..0cc40a83723a9 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/semaphore.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/semaphore/index.test.ts @@ -6,7 +6,7 @@ */ import { TestScheduler } from 'rxjs/testing'; -import { Semaphore } from './semaphore'; +import { Semaphore } from '.'; describe('Semaphore', () => { let testScheduler: TestScheduler; diff --git a/x-pack/plugins/screenshotting/server/screenshots/semaphore.ts b/x-pack/plugins/screenshotting/server/screenshots/semaphore/index.ts similarity index 100% rename from x-pack/plugins/screenshotting/server/screenshots/semaphore.ts rename to x-pack/plugins/screenshotting/server/screenshots/semaphore/index.ts diff --git a/x-pack/plugins/screenshotting/server/screenshots/wait_for_render.ts b/x-pack/plugins/screenshotting/server/screenshots/wait_for_render.ts index cee23616faeac..8cf8174be152f 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/wait_for_render.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/wait_for_render.ts @@ -5,21 +5,22 @@ * 2.0. */ -import apm from 'elastic-apm-node'; -import type { Logger } from '@kbn/core/server'; import type { HeadlessChromiumDriver } from '../browsers'; import { Layout } from '../layouts'; import { CONTEXT_WAITFORRENDER } from './constants'; +import { Actions, EventLogger } from './event_logger'; export const waitForRenderComplete = async ( browser: HeadlessChromiumDriver, - logger: Logger, + eventLogger: EventLogger, loadDelay: number, layout: Layout ) => { - const span = apm.startSpan('wait_for_render', 'wait'); - - logger.debug('waiting for rendering to complete'); + const spanEnd = eventLogger.logScreenshottingEvent( + 'wait for render complete', + Actions.WAIT_RENDER, + 'wait' + ); return await browser .evaluate( @@ -66,11 +67,9 @@ export const waitForRenderComplete = async ( args: [layout.selectors.renderComplete, loadDelay], }, { context: CONTEXT_WAITFORRENDER }, - logger + eventLogger.kbnLogger ) .then(() => { - logger.debug('rendering is complete'); - - span?.end(); + spanEnd(); }); }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/wait_for_visualizations.ts b/x-pack/plugins/screenshotting/server/screenshots/wait_for_visualizations.ts index a7485545cdef0..cf49fbe7dc798 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/wait_for_visualizations.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/wait_for_visualizations.ts @@ -5,11 +5,10 @@ * 2.0. */ -import apm from 'elastic-apm-node'; -import type { Logger } from '@kbn/core/server'; import type { HeadlessChromiumDriver } from '../browsers'; import { Layout } from '../layouts'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; +import { Actions, EventLogger } from './event_logger'; interface CompletedItemsCountParameters { context: string; @@ -37,15 +36,21 @@ const getCompletedItemsCount = ({ */ export const waitForVisualizations = async ( browser: HeadlessChromiumDriver, - logger: Logger, + eventLogger: EventLogger, timeout: number, toEqual: number, layout: Layout ): Promise => { - const span = apm.startSpan('wait_for_visualizations', 'wait'); + const { kbnLogger } = eventLogger; + const spanEnd = eventLogger.logScreenshottingEvent( + 'waiting for each visualization to complete rendering', + Actions.WAIT_VISUALIZATIONS, + 'wait' + ); + const { renderComplete: renderCompleteSelector } = layout.selectors; - logger.debug(`waiting for ${toEqual} rendered elements to be in the DOM`); + kbnLogger.debug(`waiting for ${toEqual} rendered elements to be in the DOM`); try { await browser.waitFor({ @@ -54,13 +59,15 @@ export const waitForVisualizations = async ( timeout, }); - logger.debug(`found ${toEqual} rendered elements in the DOM`); + kbnLogger.debug(`found ${toEqual} rendered elements in the DOM`); } catch (err) { - logger.error(err); - throw new Error( + kbnLogger.error(err); + const newError = new Error( `An error occurred when trying to wait for ${toEqual} visualizations to finish rendering. ${err.message}` ); + eventLogger.error(newError, Actions.WAIT_VISUALIZATIONS); + throw newError; } - span?.end(); + spanEnd(); }; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index caeeaa0c17bee..cb03788aa17ba 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -318,7 +318,7 @@ export enum TimelineId { usersPageExternalAlerts = 'users-page-external-alerts', hostsPageEvents = 'hosts-page-events', hostsPageExternalAlerts = 'hosts-page-external-alerts', - hostsPageSessions = 'hosts-page-sessions', + hostsPageSessions = 'hosts-page-sessions-v2', // the v2 is to cache bust localstorage settings as default columns were reworked. detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', networkPageExternalAlerts = 'network-page-external-alerts', diff --git a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/__snapshots__/index.test.tsx.snap index 32268e2f21e7f..9d32d2c23b18b 100644 --- a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/__snapshots__/index.test.tsx.snap @@ -70,34 +70,28 @@ exports[`SessionsView renders correctly against snapshot 1`] = `
- hosts-page-sessions + hosts-page-sessions-v2
- process.start + Started
- process.end + Executable
- process.executable + User
- user.name + Interactive
- process.interactive + Hostname
- process.pid + Type
- host.hostname -
-
- process.entry_leader.entry_meta.type -
-
- process.entry_leader.entry_meta.source.ip + Source IP
diff --git a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/cell_renderer.tsx b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/cell_renderer.tsx deleted file mode 100644 index 088935b32ce34..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/cell_renderer.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; -import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; -import { getEmptyValue } from '../empty_value'; -import { MAPPED_PROCESS_END_COLUMN } from './default_headers'; - -const hasEcsDataEndEventAction = (ecsData: CellValueElementProps['ecsData']) => { - return ecsData?.event?.action?.includes('end'); -}; - -export const CellRenderer: React.FC = (props: CellValueElementProps) => { - // We only want to render process.end for event.actions of type 'end' - if (props.columnId === MAPPED_PROCESS_END_COLUMN && !hasEcsDataEndEventAction(props.ecsData)) { - return <>{getEmptyValue()}; - } - - return ; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/default_headers.ts b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/default_headers.ts index d73ab1b690f61..4c045e358e1d6 100644 --- a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/default_headers.ts +++ b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/default_headers.ts @@ -10,50 +10,52 @@ import { defaultColumnHeaderType } from '../../../timelines/components/timeline/ import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import { SubsetTimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; - -// Using @timestamp as an way of getting the end time of the process. (Currently endpoint doesn't populate process.end) -// @timestamp of an event.action with value of "end" is what we consider that to be the end time of the process -// Current action are: 'start', 'exec', 'end', so we might have up to three events per process. -export const MAPPED_PROCESS_END_COLUMN = '@timestamp'; +import { + COLUMN_SESSION_START, + COLUMN_EXECUTABLE, + COLUMN_ENTRY_USER, + COLUMN_INTERACTIVE, + COLUMN_HOST_NAME, + COLUMN_ENTRY_TYPE, + COLUMN_ENTRY_IP, +} from './translations'; export const sessionsHeaders: ColumnHeaderOptions[] = [ { columnHeaderType: defaultColumnHeaderType, - id: 'process.start', + id: 'process.entry_leader.start', initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, + display: COLUMN_SESSION_START, }, { columnHeaderType: defaultColumnHeaderType, - id: MAPPED_PROCESS_END_COLUMN, - display: 'process.end', + id: 'process.entry_leader.executable', + display: COLUMN_EXECUTABLE, }, { columnHeaderType: defaultColumnHeaderType, - id: 'process.executable', + id: 'process.entry_leader.user.name', + display: COLUMN_ENTRY_USER, }, { columnHeaderType: defaultColumnHeaderType, - id: 'user.name', - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'process.interactive', - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'process.pid', + id: 'process.entry_leader.interactive', + display: COLUMN_INTERACTIVE, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.hostname', + display: COLUMN_HOST_NAME, }, { columnHeaderType: defaultColumnHeaderType, id: 'process.entry_leader.entry_meta.type', + display: COLUMN_ENTRY_TYPE, }, { - columnHeaderType: defaultColumnHeaderType, id: 'process.entry_leader.entry_meta.source.ip', + columnHeaderType: defaultColumnHeaderType, + display: COLUMN_ENTRY_IP, }, ]; @@ -62,4 +64,11 @@ export const sessionsDefaultModel: SubsetTimelineModel = { columns: sessionsHeaders, defaultColumns: sessionsHeaders, excludedRowRendererIds: Object.values(RowRendererId), + sort: [ + { + columnId: 'process.entry_leader.start', + columnType: 'date', + sortDirection: 'desc', + }, + ], }; diff --git a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx index 043a2aa378427..5280f298ba99e 100644 --- a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx @@ -109,10 +109,11 @@ describe('SessionsView', () => { expect(wrapper.getByTestId(`${TEST_PREFIX}:startDate`)).toHaveTextContent(startDate); expect(wrapper.getByTestId(`${TEST_PREFIX}:endDate`)).toHaveTextContent(endDate); expect(wrapper.getByTestId(`${TEST_PREFIX}:timelineId`)).toHaveTextContent( - 'hosts-page-sessions' + 'hosts-page-sessions-v2' ); }); }); + it('passes in the right filters to TGrid', async () => { render( diff --git a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.tsx index 6834553a5eee8..4d89b969e5c17 100644 --- a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.tsx @@ -12,7 +12,7 @@ import { ESBoolQuery } from '../../../../common/typed_json'; import { StatefulEventsViewer } from '../events_viewer'; import { sessionsDefaultModel } from './default_headers'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; -import { CellRenderer } from './cell_renderer'; +import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import * as i18n from './translations'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; @@ -24,15 +24,8 @@ export const defaultSessionsFilter: Required> = { bool: { filter: [ { - bool: { - should: [ - { - match: { - 'process.entry_leader.same_as_process': true, - }, - }, - ], - minimum_should_match: 1, + exists: { + field: 'process.entry_leader.entity_id', // to exclude any records which have no entry_leader.entity_id }, }, ], @@ -41,10 +34,10 @@ export const defaultSessionsFilter: Required> = { meta: { alias: null, disabled: false, - key: 'process.entry_leader.same_as_process', + key: 'process.entry_leader.entity_id', negate: false, params: {}, - type: 'boolean', + type: 'string', }, }; @@ -95,7 +88,7 @@ const SessionsViewComponent: React.FC = ({ entityType={entityType} id={timelineId} leadingControlColumns={leadingControlColumns} - renderCellValue={CellRenderer} + renderCellValue={DefaultCellRenderer} rowRenderers={defaultRowRenderers} scopeId={SourcererScopeName.default} start={startDate} diff --git a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/translations.ts b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/translations.ts index 606ae2b46fc6a..ea35892f3a2f9 100644 --- a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/translations.ts @@ -20,3 +20,52 @@ export const SINGLE_COUNT_OF_SESSIONS = i18n.translate( defaultMessage: 'session', } ); + +export const COLUMN_SESSION_START = i18n.translate( + 'xpack.securitySolution.sessionsView.columnSessionStart', + { + defaultMessage: 'Started', + } +); + +export const COLUMN_EXECUTABLE = i18n.translate( + 'xpack.securitySolution.sessionsView.columnExecutable', + { + defaultMessage: 'Executable', + } +); + +export const COLUMN_ENTRY_USER = i18n.translate( + 'xpack.securitySolution.sessionsView.columnEntryUser', + { + defaultMessage: 'User', + } +); + +export const COLUMN_INTERACTIVE = i18n.translate( + 'xpack.securitySolution.sessionsView.columnInteractive', + { + defaultMessage: 'Interactive', + } +); + +export const COLUMN_HOST_NAME = i18n.translate( + 'xpack.securitySolution.sessionsView.columnHostName', + { + defaultMessage: 'Hostname', + } +); + +export const COLUMN_ENTRY_TYPE = i18n.translate( + 'xpack.securitySolution.sessionsView.columnEntryType', + { + defaultMessage: 'Type', + } +); + +export const COLUMN_ENTRY_IP = i18n.translate( + 'xpack.securitySolution.sessionsView.columnEntrySourceIp', + { + defaultMessage: 'Source IP', + } +); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx index 8ff4b71668fd4..2f4b4d241f48c 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx @@ -5,11 +5,10 @@ * 2.0. */ -import React, { memo, PropsWithChildren } from 'react'; -import { EuiCallOut, EuiText } from '@elastic/eui'; -import { UserCommandInput } from './user_command_input'; +import React, { memo, PropsWithChildren, useEffect } from 'react'; +import { EuiCallOut } from '@elastic/eui'; import { ParsedCommandInput } from '../service/parsed_command_input'; -import { CommandDefinition } from '../types'; +import { CommandDefinition, CommandExecutionComponentProps } from '../types'; import { CommandInputUsage } from './command_usage'; import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; @@ -19,21 +18,22 @@ export type BadArgumentProps = PropsWithChildren<{ commandDefinition: CommandDefinition; }>; -export const BadArgument = memo( - ({ parsedInput, commandDefinition, children = null }) => { - const getTestId = useTestIdGenerator(useDataTestSubj()); +/** + * Shows a bad argument error. The error message needs to be defined via the Command History Item's + * `state.errorMessage` + */ +export const BadArgument = memo(({ command, setStatus, store }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + useEffect(() => { + setStatus('success'); + }, [setStatus]); - return ( - <> - - - - - {children} - - - - ); - } -); + return ( + + {store.errorMessage} + + + ); +}); BadArgument.displayName = 'BadArgument'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/clear_command.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/clear_command.tsx new file mode 100644 index 0000000000000..bfa06f55d2665 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/clear_command.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { memo, useEffect } from 'react'; +import { useConsoleStateDispatch } from '../../hooks/state_selectors/use_console_state_dispatch'; +import { CommandExecutionComponentProps } from '../../types'; + +export const ClearCommand = memo(({ status, setStatus }) => { + const dispatch = useConsoleStateDispatch(); + + useEffect(() => { + if (status === 'pending') { + dispatch({ type: 'clear' }); + } + setStatus('success'); + }, [status, setStatus, dispatch]); + + return null; +}); +ClearCommand.displayName = 'ClearCommand'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command.tsx new file mode 100644 index 0000000000000..f8c66f31e396d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { memo, useEffect } from 'react'; +import { useWithCustomHelpComponent } from '../../hooks/state_selectors/use_with_custom_help_component'; +import { CommandList } from '../command_list'; +import { useWithCommandList } from '../../hooks/state_selectors/use_with_command_list'; +import type { CommandExecutionComponentProps } from '../../types'; +import { HelpOutput } from '../help_output'; + +export const HelpCommand = memo((props) => { + const commands = useWithCommandList(); + const CustomHelpComponent = useWithCustomHelpComponent(); + + useEffect(() => { + if (!CustomHelpComponent) { + props.setStatus('success'); + } + }, [CustomHelpComponent, props]); + + return CustomHelpComponent ? ( + + ) : ( + + + + ); +}); +HelpCommand.displayName = 'HelpCommand'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command_argument.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command_argument.tsx new file mode 100644 index 0000000000000..f67c44013d059 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command_argument.tsx @@ -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 React, { memo, useEffect } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { CommandUsage } from '../command_usage'; +import { HelpOutput } from '../help_output'; +import { CommandExecutionComponentProps } from '../../types'; + +/** + * Builtin component that handles the output of command's `--help` argument + */ +export const HelpCommandArgument = memo((props) => { + const CustomCommandHelp = props.command.commandDefinition.HelpComponent; + + useEffect(() => { + if (!CustomCommandHelp) { + props.setStatus('success'); + } + }, [CustomCommandHelp, props]); + + return CustomCommandHelp ? ( + + ) : ( + + + + ); +}); +HelpCommandArgument.displayName = 'HelpCommandArgument'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx index 8bb9769980914..8a6611ffbbb18 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx @@ -5,103 +5,74 @@ * 2.0. */ -import React, { memo, ReactNode, useCallback, useEffect, useState } from 'react'; -import { EuiButton, EuiLoadingChart } from '@elastic/eui'; +import React, { memo, useCallback, useMemo } from 'react'; +import { EuiLoadingChart } from '@elastic/eui'; import styled from 'styled-components'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { CommandExecutionFailure } from './command_execution_failure'; +import type { CommandExecutionState, CommandHistoryItem } from './console_state/types'; import { UserCommandInput } from './user_command_input'; -import { Command } from '../types'; -import { useCommandService } from '../hooks/state_selectors/use_command_service'; import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; const CommandOutputContainer = styled.div` position: relative; - - .run-in-background { - position: absolute; - right: 0; - top: 1em; - } `; export interface CommandExecutionOutputProps { - command: Command; + item: CommandHistoryItem; } -export const CommandExecutionOutput = memo(({ command }) => { - const commandService = useCommandService(); - const [isRunning, setIsRunning] = useState(true); - const [output, setOutput] = useState(null); - const dispatch = useConsoleStateDispatch(); - - // FIXME:PT implement the `run in the background` functionality - const [showRunInBackground, setShowRunInTheBackground] = useState(false); - const handleRunInBackgroundClick = useCallback(() => { - setShowRunInTheBackground(false); - }, []); - - useEffect(() => { - (async () => { - const timeoutId = setTimeout(() => { - setShowRunInTheBackground(true); - }, 15000); +export const CommandExecutionOutput = memo( + ({ item: { command, state, id } }) => { + const dispatch = useConsoleStateDispatch(); + const RenderComponent = command.commandDefinition.RenderComponent; - try { - const commandOutput = await commandService.executeCommand(command); - setOutput(commandOutput.result); + const isRunning = useMemo(() => { + return state.status === 'pending'; + }, [state.status]); - // FIXME: PT the console should scroll the bottom as well - } catch (error) { - setOutput(); - } + /** Updates the Command's status */ + const setCommandStatus = useCallback( + (status: CommandExecutionState['status']) => { + dispatch({ + type: 'updateCommandStatusState', + payload: { + id, + value: status, + }, + }); + }, + [dispatch, id] + ); - clearTimeout(timeoutId); - setIsRunning(false); - setShowRunInTheBackground(false); - })(); - }, [command, commandService]); + /** Updates the Command's execution store */ + const setCommandStore = useCallback( + (store) => { + dispatch({ + type: 'updateCommandStoreState', + payload: { + id, + value: store, + }, + }); + }, + [dispatch, id] + ); - useEffect(() => { - if (!isRunning) { - dispatch({ type: 'scrollDown' }); - } - }, [isRunning, dispatch]); - - return ( - - {showRunInBackground && ( -
- - - + return ( + +
+ + {isRunning && } +
+
+
- )} -
- - {isRunning && ( - <> - - - )} -
-
{output}
-
- ); -}); + + ); + } +); CommandExecutionOutput.displayName = 'CommandExecutionOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx index 9d17d83f0266f..68b2aab558d83 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx @@ -79,22 +79,20 @@ export const CommandUsage = memo(({ commandDef }) => { {hasArgs && ( <> -

- - - {commandDef.mustHaveArgs && commandDef.args && hasArgs && ( - - - - )} - -

+ + + {commandDef.mustHaveArgs && commandDef.args && hasArgs && ( + + + + )} + {commandDef.args && ( { it("should persist a console's command output history on hide/show", async () => { await render(); enterConsoleCommand(renderResult, 'help', { dataTestSubj: 'testRunningConsole' }); - enterConsoleCommand(renderResult, 'help', { dataTestSubj: 'testRunningConsole' }); + enterConsoleCommand(renderResult, 'cmd1', { dataTestSubj: 'testRunningConsole' }); await waitFor(() => { expect(renderResult.queryAllByTestId('testRunningConsole-historyItem')).toHaveLength(2); }); + // Hide the console userEvent.click(renderResult.getByTestId('consolePopupHideButton')); await waitFor(() => { expect( @@ -317,6 +318,7 @@ describe('When using ConsoleManager', () => { ).toBe(true); }); + // Open the console back up and ensure prior items still there await openRunningConsole(); await waitFor(() => { @@ -324,6 +326,46 @@ describe('When using ConsoleManager', () => { }); }); + it('should provide console rendering state between show/hide', async () => { + const expectedStoreValue = JSON.stringify({ foo: 'bar' }, null, 2); + await render(); + enterConsoleCommand(renderResult, 'cmd1', { dataTestSubj: 'testRunningConsole' }); + + // Command should have `pending` status and no store values + expect(renderResult.getByTestId('exec-output-statusState').textContent).toEqual( + 'status: pending' + ); + expect(renderResult.getByTestId('exec-output-storeStateJson').textContent).toEqual('{}'); + + // Wait for component to update the status and store values + await waitFor(() => { + expect(renderResult.getByTestId('exec-output-statusState').textContent).toMatch( + 'status: success' + ); + }); + expect(renderResult.getByTestId('exec-output-storeStateJson').textContent).toEqual( + expectedStoreValue + ); + + // Hide the console + userEvent.click(renderResult.getByTestId('consolePopupHideButton')); + await waitFor(() => { + expect( + renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden') + ).toBe(true); + }); + + // Open the console back up and ensure `status` and `store` are the last set of values + await openRunningConsole(); + + expect(renderResult.getByTestId('exec-output-statusState').textContent).toMatch( + 'status: success' + ); + expect(renderResult.getByTestId('exec-output-storeStateJson').textContent).toEqual( + expectedStoreValue + ); + }); + describe('and the terminate confirmation is shown', () => { const clickOnTerminateButton = async () => { userEvent.click(renderResult.getByTestId('consolePopupTerminateButton')); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_manager/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_manager/mocks.tsx index 57ec4246caf41..0b841f4118d1f 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_manager/mocks.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_manager/mocks.tsx @@ -9,7 +9,7 @@ import React, { memo, useCallback } from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { ConsoleRegistrationInterface, RegisteredConsoleClient } from './types'; import { useConsoleManager } from './console_manager'; -import { getCommandServiceMock } from '../../mocks'; +import { getCommandListMock } from '../../mocks'; export const getNewConsoleRegistrationMock = ( overrides: Partial = {} @@ -20,7 +20,7 @@ export const getNewConsoleRegistrationMock = ( meta: { about: 'for unit testing ' }, consoleProps: { 'data-test-subj': 'testRunningConsole', - commandService: getCommandServiceMock(), + commands: getCommandListMock(), }, onBeforeTerminate: jest.fn(), ...overrides, diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx index 852b2b1ab58fe..66c874e4e27a8 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx @@ -17,10 +17,10 @@ type ConsoleStateProviderProps = PropsWithChildren<{}> & InitialStateInterface; * A Console wide data store for internal state management between inner components */ export const ConsoleStateProvider = memo( - ({ commandService, scrollToBottom, dataTestSubj, children }) => { + ({ commands, scrollToBottom, HelpComponent, dataTestSubj, children }) => { const [state, dispatch] = useReducer( stateDataReducer, - { commandService, scrollToBottom, dataTestSubj }, + { commands, scrollToBottom, HelpComponent, dataTestSubj }, initiateState ); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts index 94175d9821ae7..68024aa5b7cfc 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts @@ -5,26 +5,28 @@ * 2.0. */ -import { ConsoleDataState, ConsoleStoreReducer } from './types'; +import { handleUpdateCommandState } from './state_update_handlers/handle_update_command_state'; +import type { ConsoleDataState, ConsoleStoreReducer } from './types'; import { handleExecuteCommand } from './state_update_handlers/handle_execute_command'; -import { ConsoleBuiltinCommandsService } from '../../service/builtin_command_service'; +import { getBuiltinCommands } from '../../service/builtin_commands'; export type InitialStateInterface = Pick< ConsoleDataState, - 'commandService' | 'scrollToBottom' | 'dataTestSubj' + 'commands' | 'scrollToBottom' | 'dataTestSubj' | 'HelpComponent' >; export const initiateState = ({ - commandService, + commands, scrollToBottom, dataTestSubj, + HelpComponent, }: InitialStateInterface): ConsoleDataState => { return { - commandService, + commands: getBuiltinCommands().concat(commands), scrollToBottom, + HelpComponent, dataTestSubj, commandHistory: [], - builtinCommandService: new ConsoleBuiltinCommandsService(), }; }; @@ -36,6 +38,13 @@ export const stateDataReducer: ConsoleStoreReducer = (state, action) => { case 'executeCommand': return handleExecuteCommand(state, action); + + case 'updateCommandStatusState': + case 'updateCommandStoreState': + return handleUpdateCommandState(state, action); + + case 'clear': + return { ...state, commandHistory: [] }; } return state; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx index 06ecc344d5596..d19376395742f 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx @@ -15,13 +15,13 @@ import { ConsoleProps } from '../../../types'; describe('When a Console command is entered by the user', () => { let render: (props?: Partial) => ReturnType; let renderResult: ReturnType; - let commandServiceMock: ConsoleTestSetup['commandServiceMock']; + let commands: ConsoleTestSetup['commands']; let enterCommand: ConsoleTestSetup['enterCommand']; beforeEach(() => { const testSetup = getConsoleTestSetup(); - ({ commandServiceMock, enterCommand } = testSetup); + ({ commands, enterCommand } = testSetup); render = (props = {}) => (renderResult = testSetup.renderConsole(props)); }); @@ -34,18 +34,16 @@ describe('When a Console command is entered by the user', () => { await waitFor(() => { expect(renderResult.getAllByTestId('test-commandList-command')).toHaveLength( // `+2` to account for builtin commands - commandServiceMock.getCommandList().length + 2 + commands.length + 2 ); }); }); it('should display custom help output when Command service has `getHelp()` defined', async () => { - commandServiceMock.getHelp = async () => { - return { - result:
{'help output'}
, - }; + const HelpComponent: React.FunctionComponent = () => { + return
{'help output'}
; }; - render(); + render({ HelpComponent }); enterCommand('help'); await waitFor(() => { @@ -73,11 +71,15 @@ describe('When a Console command is entered by the user', () => { }); it('should should custom command `--help` output when Command service defines `getCommandUsage()`', async () => { - commandServiceMock.getCommandUsage = async () => { - return { - result:
{'command help here'}
, + const cmd2 = commands.find((command) => command.name === 'cmd2'); + + if (cmd2) { + cmd2.HelpComponent = () => { + return
{'command help here'}
; }; - }; + cmd2.HelpComponent.displayName = 'HelpComponent'; + } + render(); enterCommand('cmd2 --help'); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx index 2815ec4605917..c387cf3d90a8f 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx @@ -5,19 +5,19 @@ * 2.0. */ -/* eslint complexity: ["error", 40]*/ -// FIXME:PT remove the complexity - -import React from 'react'; import { i18n } from '@kbn/i18n'; -import { ConsoleDataAction, ConsoleDataState, ConsoleStoreReducer } from '../types'; +import { v4 as uuidV4 } from 'uuid'; +import { HelpCommandArgument } from '../../builtin_commands/help_command_argument'; +import { + CommandHistoryItem, + ConsoleDataAction, + ConsoleDataState, + ConsoleStoreReducer, +} from '../types'; import { parseCommandInput } from '../../../service/parsed_command_input'; -import { HistoryItem } from '../../history_item'; import { UnknownCommand } from '../../unknow_comand'; -import { HelpOutput } from '../../help_output'; import { BadArgument } from '../../bad_argument'; -import { CommandExecutionOutput } from '../../command_execution_output'; -import { CommandDefinition } from '../../../types'; +import { Command, CommandDefinition, CommandExecutionComponentProps } from '../../../types'; const toCliArgumentOption = (argName: string) => `--${argName}`; @@ -41,6 +41,36 @@ const updateStateWithNewCommandHistoryItem = ( }; }; +const UnknownCommandDefinition: CommandDefinition = { + name: 'unknown-command', + about: 'unknown command', + RenderComponent: () => null, +}; + +const createCommandExecutionState = ( + store: CommandExecutionComponentProps['store'] = {} +): CommandHistoryItem['state'] => { + return { + status: 'pending', + store, + }; +}; + +const cloneCommandDefinitionWithNewRenderComponent = ( + command: Command, + RenderComponent: CommandDefinition['RenderComponent'] +): Command => { + return { + ...command, + commandDefinition: { + ...command.commandDefinition, + // We use the original command definition, but replace + // the RenderComponent for this invocation + RenderComponent, + }, + }; +}; + export const handleExecuteCommand: ConsoleStoreReducer< ConsoleDataAction & { type: 'executeCommand' } > = (state, action) => { @@ -50,116 +80,98 @@ export const handleExecuteCommand: ConsoleStoreReducer< return state; } - const { commandService, builtinCommandService } = state; - - // Is it an internal command? - if (builtinCommandService.isBuiltin(parsedInput.name)) { - const commandOutput = builtinCommandService.executeBuiltinCommand(parsedInput, commandService); - - if (commandOutput.clearBuffer) { - return { - ...state, - commandHistory: [], - }; - } - - return updateStateWithNewCommandHistoryItem(state, commandOutput.result); - } - - // ---------------------------------------------------- - // Validate and execute the user defined command - // ---------------------------------------------------- - const commandDefinition = commandService - .getCommandList() - .find((definition) => definition.name === parsedInput.name); + const { commands } = state; + const commandDefinition: CommandDefinition | undefined = commands.find( + (definition) => definition.name === parsedInput.name + ); // Unknown command if (!commandDefinition) { - return updateStateWithNewCommandHistoryItem( - state, - - - - ); + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: { + input: parsedInput.input, + args: parsedInput, + commandDefinition: { + ...UnknownCommandDefinition, + RenderComponent: UnknownCommand, + }, + }, + state: createCommandExecutionState(), + }); } + const command = { + input: parsedInput.input, + args: parsedInput, + commandDefinition, + }; const requiredArgs = getRequiredArguments(commandDefinition.args); // If args were entered, then validate them if (parsedInput.hasArgs()) { // Show command help if (parsedInput.hasArg('help')) { - return updateStateWithNewCommandHistoryItem( - state, - - - {(commandService.getCommandUsage || builtinCommandService.getCommandUsage)( - commandDefinition - )} - - - ); + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: cloneCommandDefinitionWithNewRenderComponent(command, HelpCommandArgument), + state: createCommandExecutionState(), + }); } // Command supports no arguments if (!commandDefinition.args || Object.keys(commandDefinition.args).length === 0) { - return updateStateWithNewCommandHistoryItem( - state, - - - {i18n.translate( - 'xpack.securitySolution.console.commandValidation.noArgumentsSupported', - { - defaultMessage: 'command does not support any arguments', - } - )} - - - ); + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: cloneCommandDefinitionWithNewRenderComponent(command, BadArgument), + state: createCommandExecutionState({ + errorMessage: i18n.translate( + 'xpack.securitySolution.console.commandValidation.noArgumentsSupported', + { + defaultMessage: 'command does not support any arguments', + } + ), + }), + }); } // no unknown arguments allowed? if (parsedInput.unknownArgs && parsedInput.unknownArgs.length) { - return updateStateWithNewCommandHistoryItem( - state, - - - {i18n.translate('xpack.securitySolution.console.commandValidation.unknownArgument', { + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: cloneCommandDefinitionWithNewRenderComponent(command, BadArgument), + state: createCommandExecutionState({ + errorMessage: i18n.translate( + 'xpack.securitySolution.console.commandValidation.unknownArgument', + { defaultMessage: 'unknown argument(s): {unknownArgs}', values: { unknownArgs: parsedInput.unknownArgs.join(', '), }, - })} - - - ); + } + ), + }), + }); } // Missing required Arguments for (const requiredArg of requiredArgs) { if (!parsedInput.args[requiredArg]) { - return updateStateWithNewCommandHistoryItem( - state, - - - {i18n.translate( - 'xpack.securitySolution.console.commandValidation.missingRequiredArg', - { - defaultMessage: 'missing required argument: {argName}', - values: { - argName: toCliArgumentOption(requiredArg), - }, - } - )} - - - ); + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: cloneCommandDefinitionWithNewRenderComponent(command, BadArgument), + state: createCommandExecutionState({ + errorMessage: i18n.translate( + 'xpack.securitySolution.console.commandValidation.missingRequiredArg', + { + defaultMessage: 'missing required argument: {argName}', + values: { + argName: toCliArgumentOption(requiredArg), + }, + } + ), + }), + }); } } @@ -170,17 +182,19 @@ export const handleExecuteCommand: ConsoleStoreReducer< // Unknown argument if (!argDefinition) { - return updateStateWithNewCommandHistoryItem( - state, - - - {i18n.translate('xpack.securitySolution.console.commandValidation.unsupportedArg', { + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: cloneCommandDefinitionWithNewRenderComponent(command, BadArgument), + state: createCommandExecutionState({ + errorMessage: i18n.translate( + 'xpack.securitySolution.console.commandValidation.unsupportedArg', + { defaultMessage: 'unsupported argument: {argName}', values: { argName: toCliArgumentOption(argName) }, - })} - - - ); + } + ), + }), + }); } // does not allow multiple values @@ -189,81 +203,76 @@ export const handleExecuteCommand: ConsoleStoreReducer< Array.isArray(argInput.values) && argInput.values.length > 0 ) { - return updateStateWithNewCommandHistoryItem( - state, - - - {i18n.translate( - 'xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce', - { - defaultMessage: 'argument can only be used once: {argName}', - values: { argName: toCliArgumentOption(argName) }, - } - )} - - - ); + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: cloneCommandDefinitionWithNewRenderComponent(command, BadArgument), + state: createCommandExecutionState({ + errorMessage: i18n.translate( + 'xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce', + { + defaultMessage: 'argument can only be used once: {argName}', + values: { argName: toCliArgumentOption(argName) }, + } + ), + }), + }); } if (argDefinition.validate) { const validationResult = argDefinition.validate(argInput); if (validationResult !== true) { - return updateStateWithNewCommandHistoryItem( - state, - - - {i18n.translate( - 'xpack.securitySolution.console.commandValidation.invalidArgValue', - { - defaultMessage: 'invalid argument value: {argName}. {error}', - values: { argName: toCliArgumentOption(argName), error: validationResult }, - } - )} - - - ); + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: cloneCommandDefinitionWithNewRenderComponent(command, BadArgument), + state: createCommandExecutionState({ + errorMessage: i18n.translate( + 'xpack.securitySolution.console.commandValidation.invalidArgValue', + { + defaultMessage: 'invalid argument value: {argName}. {error}', + values: { argName: toCliArgumentOption(argName), error: validationResult }, + } + ), + }), + }); } } } } else if (requiredArgs.length > 0) { - return updateStateWithNewCommandHistoryItem( - state, - - - {i18n.translate('xpack.securitySolution.console.commandValidation.mustHaveArgs', { + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: cloneCommandDefinitionWithNewRenderComponent(command, BadArgument), + state: createCommandExecutionState({ + errorMessage: i18n.translate( + 'xpack.securitySolution.console.commandValidation.mustHaveArgs', + { defaultMessage: 'missing required arguments: {requiredArgs}', values: { requiredArgs: requiredArgs.map((argName) => toCliArgumentOption(argName)).join(', '), }, - })} - - - ); + } + ), + }), + }); } else if (commandDefinition.mustHaveArgs) { - return updateStateWithNewCommandHistoryItem( - state, - - - {i18n.translate('xpack.securitySolution.console.commandValidation.oneArgIsRequired', { + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command: cloneCommandDefinitionWithNewRenderComponent(command, BadArgument), + state: createCommandExecutionState({ + errorMessage: i18n.translate( + 'xpack.securitySolution.console.commandValidation.oneArgIsRequired', + { defaultMessage: 'at least one argument must be used', - })} - - - ); + } + ), + }), + }); } // All is good. Execute the command - return updateStateWithNewCommandHistoryItem( - state, - - - - ); + return updateStateWithNewCommandHistoryItem(state, { + id: uuidV4(), + command, + state: createCommandExecutionState(), + }); }; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_update_command_state.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_update_command_state.ts new file mode 100644 index 0000000000000..8e176019990a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_update_command_state.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + CommandExecutionState, + CommandHistoryItem, + ConsoleDataAction, + ConsoleStoreReducer, +} from '../types'; + +type UpdateCommandStateAction = ConsoleDataAction & { + type: 'updateCommandStoreState' | 'updateCommandStatusState'; +}; + +export const handleUpdateCommandState: ConsoleStoreReducer = ( + state, + { type, payload: { id, value } } +) => { + let foundIt = false; + const updatedCommandHistory = state.commandHistory.map((item) => { + if (foundIt || item.id !== id) { + return item; + } + + foundIt = true; + + const updatedCommandState: CommandHistoryItem = { + ...item, + state: { + ...item.state, + }, + }; + + switch (type) { + case 'updateCommandStoreState': + updatedCommandState.state.store = value as CommandExecutionState['store']; + break; + case 'updateCommandStatusState': + // If the status was not changed, then there is nothing to be done here, so + // instead of triggering a state change (and UI re-render), just return the + // original item; + if (updatedCommandState.state.status === value) { + foundIt = false; + return item; + } + + updatedCommandState.state.status = value as CommandExecutionState['status']; + break; + } + + return updatedCommandState; + }); + + if (foundIt) { + return { + ...state, + commandHistory: updatedCommandHistory, + }; + } + + return state; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts index 72810d31e3248..356033e147c56 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts @@ -5,28 +5,51 @@ * 2.0. */ -import { Dispatch, Reducer } from 'react'; -import { CommandServiceInterface } from '../../types'; -import { HistoryItemComponent } from '../history_item'; -import { BuiltinCommandServiceInterface } from '../../service/types.builtin_command_service'; +import type { Dispatch, Reducer } from 'react'; +import type { Command, CommandDefinition, CommandExecutionComponent } from '../../types'; export interface ConsoleDataState { - /** Command service defined on input to the `Console` component by consumers of the component */ - commandService: CommandServiceInterface; - /** Command service for builtin console commands */ - builtinCommandService: BuiltinCommandServiceInterface; + /** + * Commands available in the console, which includes both the builtin command and the ones + * defined on input to the `Console` component by consumers of the component + */ + commands: CommandDefinition[]; + /** UI function that scrolls the console down to the bottom */ scrollToBottom: () => void; + /** * List of commands entered by the user and being shown in the UI */ - commandHistory: Array>; + commandHistory: CommandHistoryItem[]; + /** Component defined on input to the Console that will handle the `help` command */ + HelpComponent?: CommandExecutionComponent; dataTestSubj?: string; } +export interface CommandHistoryItem { + id: string; + command: Command; + state: CommandExecutionState; +} + +export interface CommandExecutionState { + status: 'pending' | 'success' | 'error'; + store: Record; +} + export type ConsoleDataAction = | { type: 'scrollDown' } - | { type: 'executeCommand'; payload: { input: string } }; + | { type: 'executeCommand'; payload: { input: string } } + | { type: 'clear' } + | { + type: 'updateCommandStoreState'; + payload: { id: string; value: CommandExecutionState['store'] }; + } + | { + type: 'updateCommandStatusState'; + payload: { id: string; value: CommandExecutionState['status'] }; + }; export interface ConsoleStore { state: ConsoleDataState; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx index b0a2217e169c4..597d979e00034 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx @@ -5,55 +5,30 @@ * 2.0. */ -import React, { memo, ReactNode, useEffect, useState } from 'react'; -import { EuiCallOut, EuiCallOutProps, EuiLoadingChart } from '@elastic/eui'; -import { UserCommandInput } from './user_command_input'; -import { CommandExecutionFailure } from './command_execution_failure'; +import React, { memo, PropsWithChildren, ReactNode } from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { MaybeImmutable } from '../../../../../common/endpoint/types'; +import { Command } from '..'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; -export interface HelpOutputProps extends Pick { - input: string; - children: ReactNode | Promise<{ result: ReactNode }>; -} -export const HelpOutput = memo(({ input, children, ...euiCalloutProps }) => { - const [content, setContent] = useState(); +type HelpOutputProps = PropsWithChildren<{ + command: MaybeImmutable; + title?: ReactNode; +}>; +export const HelpOutput = memo(({ title, children }) => { const getTestId = useTestIdGenerator(useDataTestSubj()); - useEffect(() => { - if (children instanceof Promise) { - (async () => { - try { - const response = await (children as Promise<{ - result: ReactNode; - }>); - setContent(response.result); - } catch (error) { - setContent(); - } - })(); - - return; - } - - setContent(children); - }, [children]); - return ( -
-
- -
- - {content} - -
+ + {children} + ); }); HelpOutput.displayName = 'HelpOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx index 088a6fac57ae4..cd03f9d39a39d 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx @@ -5,12 +5,14 @@ * 2.0. */ -import React, { memo, useEffect } from 'react'; +import React, { memo, useEffect, useMemo } from 'react'; import { CommonProps, EuiFlexGroup } from '@elastic/eui'; +import { CommandExecutionOutput } from './command_execution_output'; import { useCommandHistory } from '../hooks/state_selectors/use_command_history'; import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { HistoryItem } from './history_item'; export type OutputHistoryProps = CommonProps; @@ -19,6 +21,16 @@ export const HistoryOutput = memo((commonProps) => { const dispatch = useConsoleStateDispatch(); const getTestId = useTestIdGenerator(useDataTestSubj()); + const historyBody = useMemo(() => { + return historyItems.map((historyItem) => { + return ( + + + + ); + }); + }, [historyItems]); + // Anytime we add a new item to the history // scroll down so that command input remains visible useEffect(() => { @@ -34,7 +46,7 @@ export const HistoryOutput = memo((commonProps) => { alignItems="flexEnd" responsive={false} > - {historyItems} + {historyBody} ); }); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx index 5529457cbb05a..8397c7727de81 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx @@ -5,42 +5,38 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useEffect } from 'react'; import { EuiCallOut, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { UserCommandInput } from './user_command_input'; +import { CommandExecutionComponentProps } from '../types'; import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; -export interface UnknownCommand { - input: string; -} -export const UnknownCommand = memo(({ input }) => { +export const UnknownCommand = memo(({ setStatus }) => { const getTestId = useTestIdGenerator(useDataTestSubj()); + useEffect(() => { + setStatus('success'); + }, [setStatus]); + return ( - <> -
- -
- - - - - - {'help'}, - }} - /> - - - + + + + + + {'help'}, + }} + /> + + ); }); UnknownCommand.displayName = 'UnknownCommand'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.tsx index 0f3645037df02..874dbc2eabae0 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/console.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/console.tsx @@ -45,7 +45,7 @@ const ConsoleWindow = styled.div` `; export const Console = memo( - ({ prompt, commandService, managedKey, ...commonProps }) => { + ({ prompt, commands, HelpComponent, managedKey, ...commonProps }) => { const consoleWindowRef = useRef(null); const inputFocusRef: CommandInputProps['focusRef'] = useRef(null); const getTestId = useTestIdGenerator(commonProps['data-test-subj']); @@ -72,8 +72,9 @@ export const Console = memo( return ( {/* diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_command_list.ts similarity index 58% rename from x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts rename to x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_command_list.ts index 22167d5066743..4f63c55a36098 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_command_list.ts @@ -6,8 +6,11 @@ */ import { useConsoleStore } from '../../components/console_state/console_state'; -import { CommandServiceInterface } from '../../types'; +import type { CommandDefinition } from '../../types'; -export const useCommandService = (): CommandServiceInterface => { - return useConsoleStore().state.builtinCommandService; +/** + * Returns the Command service that the console was provided on input + */ +export const useWithCommandList = (): CommandDefinition[] => { + return useConsoleStore().state.commands; }; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_custom_help_component.ts similarity index 62% rename from x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts rename to x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_custom_help_component.ts index 66ce0c2b5eb43..b90e5166c81d7 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_custom_help_component.ts @@ -6,8 +6,8 @@ */ import { useConsoleStore } from '../../components/console_state/console_state'; -import { CommandServiceInterface } from '../../types'; +import { ConsoleDataState } from '../../components/console_state/types'; -export const useCommandService = (): CommandServiceInterface => { - return useConsoleStore().state.commandService; +export const useWithCustomHelpComponent = (): ConsoleDataState['HelpComponent'] => { + return useConsoleStore().state.HelpComponent; }; diff --git a/x-pack/plugins/security_solution/public/management/components/console/index.ts b/x-pack/plugins/security_solution/public/management/components/console/index.ts index 4264aa5a8f830..1603d4b15f353 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/index.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/index.ts @@ -7,7 +7,7 @@ export { Console } from './console'; export { ConsoleManager, useConsoleManager } from './components/console_manager'; -export type { CommandServiceInterface, CommandDefinition, Command, ConsoleProps } from './types'; +export type { CommandDefinition, Command, ConsoleProps } from './types'; export type { ConsoleRegistrationInterface, RegisteredConsoleClient, diff --git a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx index d89c5f5374d47..ea24a174498dd 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx @@ -7,20 +7,19 @@ /* eslint-disable import/no-extraneous-dependencies */ -import React from 'react'; +import React, { useEffect } from 'react'; import { EuiCode } from '@elastic/eui'; import userEvent from '@testing-library/user-event'; import { act } from '@testing-library/react'; import { Console } from './console'; -import type { Command, CommandServiceInterface, ConsoleProps } from './types'; +import type { ConsoleProps, CommandDefinition, CommandExecutionComponent } from './types'; import type { AppContextTestRender } from '../../../common/mock/endpoint'; import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; -import { CommandDefinition } from './types'; export interface ConsoleTestSetup { renderConsole(props?: Partial): ReturnType; - commandServiceMock: jest.Mocked; + commands: CommandDefinition[]; enterCommand( cmd: string, @@ -74,25 +73,16 @@ export const getConsoleTestSetup = (): ConsoleTestSetup => { let renderResult: ReturnType; - const commandServiceMock = getCommandServiceMock(); + const commandList = getCommandListMock(); const renderConsole: ConsoleTestSetup['renderConsole'] = ({ prompt = '$$>', - commandService = commandServiceMock, + commands = commandList, 'data-test-subj': dataTestSubj = 'test', ...others } = {}) => { - if (commandService !== commandServiceMock) { - throw new Error('Must use CommandService provided by test setup'); - } - return (renderResult = mockedContext.render( - + )); }; @@ -102,91 +92,107 @@ export const getConsoleTestSetup = (): ConsoleTestSetup => { return { renderConsole, - commandServiceMock, + commands: commandList, enterCommand, }; }; -export const getCommandServiceMock = (): jest.Mocked => { - return { - getCommandList: jest.fn(() => { - const commands: CommandDefinition[] = [ - { - name: 'cmd1', - about: 'a command with no options', - }, - { - name: 'cmd2', - about: 'runs cmd 2', - args: { - file: { - about: 'Includes file in the run', - required: true, - allowMultiples: false, - validate: () => { - return true; - }, - }, - ext: { - about: 'optional argument', - required: false, - allowMultiples: false, - }, - bad: { - about: 'will fail validation', - required: false, - allowMultiples: false, - validate: () => 'This is a bad value', - }, +export const getCommandListMock = (): CommandDefinition[] => { + const RenderComponent: CommandExecutionComponent = ({ + command, + status, + setStatus, + setStore, + store, + }) => { + useEffect(() => { + if (status !== 'success') { + new Promise((r) => setTimeout(r, 500)).then(() => { + setStatus('success'); + setStore({ foo: 'bar' }); + }); + } + }, [setStatus, setStore, status]); + + return ( +
+
{`${command.commandDefinition.name}`}
+
{`command input: ${command.input}`}
+ + {JSON.stringify(command.args, null, 2)} + +
{'Command render state:'}
+
{`status: ${status}`}
+ + {JSON.stringify(store, null, 2)} + +
+ ); + }; + + const commands: CommandDefinition[] = [ + { + name: 'cmd1', + about: 'a command with no options', + RenderComponent: jest.fn(RenderComponent), + }, + { + name: 'cmd2', + about: 'runs cmd 2', + RenderComponent: jest.fn(RenderComponent), + args: { + file: { + about: 'Includes file in the run', + required: true, + allowMultiples: false, + validate: () => { + return true; }, }, - { - name: 'cmd3', - about: 'allows argument to be used multiple times', - args: { - foo: { - about: 'foo stuff', - required: true, - allowMultiples: true, - }, - }, + ext: { + about: 'optional argument', + required: false, + allowMultiples: false, }, - { - name: 'cmd4', - about: 'all options optinal, but at least one is required', - mustHaveArgs: true, - args: { - foo: { - about: 'foo stuff', - required: false, - allowMultiples: true, - }, - bar: { - about: 'bar stuff', - required: false, - allowMultiples: true, - }, - }, + bad: { + about: 'will fail validation', + required: false, + allowMultiples: false, + validate: () => 'This is a bad value', }, - ]; - - return commands; - }), - - executeCommand: jest.fn(async (command: Command) => { - await new Promise((r) => setTimeout(r, 1)); - - return { - result: ( -
-
{`${command.commandDefinition.name}`}
-
{`command input: ${command.input}`}
- - {JSON.stringify(command.args, null, 2)} - -
- ), - }; - }), - }; + }, + }, + { + name: 'cmd3', + about: 'allows argument to be used multiple times', + RenderComponent: jest.fn(RenderComponent), + args: { + foo: { + about: 'foo stuff', + required: true, + allowMultiples: true, + }, + }, + }, + { + name: 'cmd4', + about: 'all options optional, but at least one is required', + RenderComponent: jest.fn(RenderComponent), + mustHaveArgs: true, + args: { + foo: { + about: 'foo stuff', + required: false, + allowMultiples: true, + }, + bar: { + about: 'bar stuff', + required: false, + allowMultiples: true, + }, + }, + }, + ]; + + return commands; }; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx deleted file mode 100644 index 6cd8af0dc6eff..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { ReactNode } from 'react'; -import { i18n } from '@kbn/i18n'; -import { HistoryItem, HistoryItemComponent } from '../components/history_item'; -import { HelpOutput } from '../components/help_output'; -import { ParsedCommandInput } from './parsed_command_input'; -import { CommandList } from '../components/command_list'; -import { CommandUsage } from '../components/command_usage'; -import { Command, CommandDefinition, CommandServiceInterface } from '../types'; -import { BuiltinCommandServiceInterface } from './types.builtin_command_service'; - -const builtInCommands = (): CommandDefinition[] => { - return [ - { - name: 'help', - about: i18n.translate('xpack.securitySolution.console.builtInCommands.helpAbout', { - defaultMessage: 'View list of available commands', - }), - }, - { - name: 'clear', - about: i18n.translate('xpack.securitySolution.console.builtInCommands.clearAbout', { - defaultMessage: 'Clear the console buffer', - }), - }, - ]; -}; - -export class ConsoleBuiltinCommandsService implements BuiltinCommandServiceInterface { - constructor(private commandList = builtInCommands()) {} - - getCommandList(): CommandDefinition[] { - return this.commandList; - } - - async executeCommand(command: Command): Promise<{ result: ReactNode }> { - return { - result: null, - }; - } - - executeBuiltinCommand( - parsedInput: ParsedCommandInput, - contextConsoleService: CommandServiceInterface - ): { result: ReturnType | null; clearBuffer?: boolean } { - switch (parsedInput.name) { - case 'help': - return { - result: ( - - - {this.getHelpContent(parsedInput, contextConsoleService)} - - - ), - }; - - case 'clear': - return { - result: null, - clearBuffer: true, - }; - } - - return { result: null }; - } - - async getHelpContent( - parsedInput: ParsedCommandInput, - commandService: CommandServiceInterface - ): Promise<{ result: ReactNode }> { - let helpOutput: ReactNode; - - if (commandService.getHelp) { - helpOutput = (await commandService.getHelp()).result; - } else { - helpOutput = ( - - ); - } - - return { - result: helpOutput, - }; - } - - isBuiltin(name: string): boolean { - return !!this.commandList.find((command) => command.name === name); - } - - async getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }> { - return { - result: , - }; - } -} diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/builtin_commands.tsx b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_commands.tsx new file mode 100644 index 0000000000000..5869e3b4472cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_commands.tsx @@ -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 { i18n } from '@kbn/i18n'; +import { ClearCommand } from '../components/builtin_commands/clear_command'; +import { HelpCommand } from '../components/builtin_commands/help_command'; +import { CommandDefinition } from '../types'; + +export const getBuiltinCommands = (): CommandDefinition[] => { + return [ + { + name: 'help', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.helpAbout', { + defaultMessage: 'View list of available commands', + }), + RenderComponent: HelpCommand, + }, + { + name: 'clear', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.clearAbout', { + defaultMessage: 'Clear the console buffer', + }), + RenderComponent: ClearCommand, + }, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts deleted file mode 100644 index dbd5347ea99c2..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ReactNode } from 'react'; -import { CommandDefinition, CommandServiceInterface } from '../types'; -import { ParsedCommandInput } from './parsed_command_input'; -import { HistoryItemComponent } from '../components/history_item'; - -export interface BuiltinCommandServiceInterface extends CommandServiceInterface { - executeBuiltinCommand( - parsedInput: ParsedCommandInput, - contextConsoleService: CommandServiceInterface - ): { result: ReturnType | null; clearBuffer?: boolean }; - - getHelpContent( - parsedInput: ParsedCommandInput, - commandService: CommandServiceInterface - ): Promise<{ result: ReactNode }>; - - isBuiltin(name: string): boolean; - - getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }>; -} diff --git a/x-pack/plugins/security_solution/public/management/components/console/types.ts b/x-pack/plugins/security_solution/public/management/components/console/types.ts index 6b15f03988313..fec4b2722cc92 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/types.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/types.ts @@ -5,16 +5,34 @@ * 2.0. */ -import { ReactNode } from 'react'; -import { CommonProps } from '@elastic/eui'; -import { ParsedArgData, ParsedCommandInput } from './service/parsed_command_input'; +import type { ComponentType, ComponentProps } from 'react'; +import type { CommonProps } from '@elastic/eui'; +import type { CommandExecutionState } from './components/console_state/types'; +import type { Immutable } from '../../../../common/endpoint/types'; +import type { ParsedArgData, ParsedCommandInput } from './service/parsed_command_input'; export interface CommandDefinition { name: string; about: string; - validator?: () => Promise; + /** + * The Component that will be used to render the Command + */ + RenderComponent: CommandExecutionComponent; + /** + * If defined, this command's use of `--help` will be displayed using this component instead of + * the console's built in output. + */ + HelpComponent?: CommandExecutionComponent; + /** + * A store for any data needed when the command is executed. + * The entire `CommandDefinition` is passed along to the component + * that will handle it, so this data will be available there + */ + meta?: Record; + /** If all args are optional, but at least one must be defined, set to true */ mustHaveArgs?: boolean; + /** The list of arguments supported by this command */ args?: { [longName: string]: { required: boolean; @@ -28,7 +46,7 @@ export interface CommandDefinition { // Selector: Idea is that the schema can plugin in a rich component for the // user to select something (ex. a file) // FIXME: implement selector - selector?: () => unknown; + selector?: ComponentType; }; }; } @@ -46,26 +64,44 @@ export interface Command { commandDefinition: CommandDefinition; } -export interface CommandServiceInterface { - getCommandList(): CommandDefinition[]; - - executeCommand(command: Command): Promise<{ result: ReactNode }>; - +/** + * The component that will handle the Command execution and display the result. + */ +export type CommandExecutionComponent = ComponentType<{ + command: Command; /** - * If defined, then the `help` builtin command will display this output instead of the default one - * which is generated out of the Command list + * A data store for the command execution to store data in, if needed. + * Because the Console could be closed/opened several times, which will cause this component + * to be `mounted`/`unmounted` several times, this data store will be beneficial for + * persisting data (ex. API response with IDs) that the command can use to determine + * if the command has already been executed or if it's a new instance. */ - getHelp?: () => Promise<{ result: ReactNode }>; - + store: Immutable; + /** Sets the `store` data above */ + setStore: (state: CommandExecutionState['store']) => void; /** - * If defined, then the output of this function will be used to display individual - * command help (`--help`) + * The status of the command execution. + * Note that the console's UI will show the command as "busy" while the status here is + * `pending`. Ensure that once the action processing completes, that this is set to + * either `success` or `error`. */ - getCommandUsage?: (command: CommandDefinition) => Promise<{ result: ReactNode }>; -} + status: CommandExecutionState['status']; + /** Set the status of the command execution */ + setStatus: (status: CommandExecutionState['status']) => void; +}>; + +export type CommandExecutionComponentProps = ComponentProps; export interface ConsoleProps extends CommonProps { - commandService: CommandServiceInterface; + /** + * The list of Commands that will be available in the console for the user to execute + */ + commands: CommandDefinition[]; + /** + * If defined, then the `help` builtin command will display this output instead of the default one + * which is generated out of the Command list. + */ + HelpComponent?: CommandExecutionComponent; prompt?: string; /** * For internal use only! diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx deleted file mode 100644 index 28472e123380a..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo } from 'react'; -import { Console } from '../console'; -import { EndpointConsoleCommandService } from './endpoint_console_command_service'; -import type { HostMetadata } from '../../../../common/endpoint/types'; - -export interface EndpointConsoleProps { - endpoint: HostMetadata; -} - -export const EndpointConsole = memo((props) => { - const consoleService = useMemo(() => { - return new EndpointConsoleCommandService(); - }, []); - - return `} commandService={consoleService} />; -}); - -EndpointConsole.displayName = 'EndpointConsole'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx deleted file mode 100644 index 5028879bc1a49..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { ReactNode } from 'react'; -import { CommandServiceInterface, CommandDefinition, Command } from '../console'; - -/** - * Endpoint specific Response Actions (commands) for use with Console. - */ -export class EndpointConsoleCommandService implements CommandServiceInterface { - getCommandList(): CommandDefinition[] { - return []; - } - - async executeCommand(command: Command): Promise<{ result: ReactNode }> { - return { result: <> }; - } -} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx index 6761a32c6fb65..46b2a96faa9c9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx @@ -5,7 +5,15 @@ * 2.0. */ -import React, { memo, useCallback, useMemo } from 'react'; +import React, { + memo, + ReactElement, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { EuiButton, EuiCode, @@ -15,12 +23,11 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; +import { useIsMounted } from '../../../components/hooks/use_is_mounted'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useUrlParams } from '../../../components/hooks/use_url_params'; import { - Command, CommandDefinition, - CommandServiceInterface, Console, RegisteredConsoleClient, useConsoleManager, @@ -28,69 +35,138 @@ import { const delay = async (ms: number = 4000) => new Promise((r) => setTimeout(r, ms)); -class DevCommandService implements CommandServiceInterface { - getCommandList(): CommandDefinition[] { - return [ - { - name: 'cmd1', - about: 'Runs cmd1', - }, - { - name: 'get-file', - about: 'retrieve a file from the endpoint', - args: { - file: { - required: true, - allowMultiples: false, - about: 'the file path for the file to be retrieved', - }, - }, +const getCommandList = (): CommandDefinition[] => { + return [ + { + name: 'cmd1', + about: 'Runs cmd1', + RenderComponent: ({ command, setStatus, store, setStore }) => { + const isMounted = useIsMounted(); + + const [apiResponse, setApiResponse] = useState(null); + const [uiResponse, setUiResponse] = useState(null); + + // Emulate a real action where: + // 1. an api request is done to create the action + // 2. wait for a response + // 3. account for component mount/unmount and prevent duplicate api calls + + useEffect(() => { + (async () => { + // Emulate an api call + if (!store.apiInflight) { + setStore({ + ...store, + apiInflight: true, + }); + + window.console.warn(`${Math.random()} ------> cmd1: doing async work`); + + await delay(6000); + setApiResponse(`API was called at: ${new Date().toLocaleString()}`); + } + })(); + }, [setStore, store]); + + useEffect(() => { + (async () => { + const doUiResponse = () => { + setUiResponse( + + {`${command.commandDefinition.name}`} + {`command input: ${command.input}`} + {'Arguments provided:'} + {JSON.stringify(command.args, null, 2)} + + ); + }; + + if (store.apiResponse) { + doUiResponse(); + } else { + await delay(); + doUiResponse(); + } + })(); + }, [ + command.args, + command.commandDefinition.name, + command.input, + isMounted, + store.apiResponse, + ]); + + useEffect(() => { + if (apiResponse && uiResponse) { + setStatus('success'); + } + }, [apiResponse, setStatus, uiResponse]); + + useEffect(() => { + if (apiResponse && store.apiResponse !== apiResponse) { + setStore({ + ...store, + apiResponse, + }); + } + }, [apiResponse, setStore, store]); + + if (store.apiResponse) { + return ( +
+ {uiResponse} + {store.apiResponse as ReactNode} +
+ ); + } + + return null; }, - { - name: 'cmd2', - about: 'runs cmd 2', - args: { - file: { - required: true, - allowMultiples: false, - about: 'Includes file in the run', - validate: () => { - return true; - }, - }, - bad: { - required: false, - allowMultiples: false, - about: 'will fail validation', - validate: () => 'This is a bad value', - }, + args: { + one: { + required: false, + allowMultiples: false, + about: 'just one', }, }, - { - name: 'cmd-long-delay', - about: 'runs cmd 2', - }, - ]; - } - - async executeCommand(command: Command): Promise<{ result: React.ReactNode }> { - await delay(); - - if (command.commandDefinition.name === 'cmd-long-delay') { - await delay(20000); - } - - return { - result: ( -
-
{`${command.commandDefinition.name}`}
-
{`command input: ${command.input}`}
- {JSON.stringify(command.args, null, 2)} -
- ), - }; - } -} + }, + // { + // name: 'get-file', + // about: 'retrieve a file from the endpoint', + // args: { + // file: { + // required: true, + // allowMultiples: false, + // about: 'the file path for the file to be retrieved', + // }, + // }, + // }, + // { + // name: 'cmd2', + // about: 'runs cmd 2', + // args: { + // file: { + // required: true, + // allowMultiples: false, + // about: 'Includes file in the run', + // validate: () => { + // return true; + // }, + // }, + // bad: { + // required: false, + // allowMultiples: false, + // about: 'will fail validation', + // validate: () => 'This is a bad value', + // }, + // }, + // }, + // { + // name: 'cmd-long-delay', + // about: 'runs cmd 2', + // }, + ]; +}; const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>( ({ registeredConsole }) => { @@ -132,8 +208,8 @@ RunningConsole.displayName = 'RunningConsole'; // ------------------------------------------------------------ export const ShowDevConsole = memo(() => { const consoleManager = useConsoleManager(); - const commandService = useMemo(() => { - return new DevCommandService(); + const commands = useMemo(() => { + return getCommandList(); }, []); const handleRegisterOnClick = useCallback(() => { @@ -146,12 +222,12 @@ export const ShowDevConsole = memo(() => { }, consoleProps: { prompt: '>>', - commandService, + commands, 'data-test-subj': 'dev', }, }) .show(); - }, [commandService, consoleManager]); + }, [commands, consoleManager]); return ( @@ -173,8 +249,8 @@ export const ShowDevConsole = memo(() => {

{'Un-managed console'}

- - + + ); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.test.tsx index 3137862025f15..0439eff39d88c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.test.tsx @@ -6,23 +6,10 @@ */ import React from 'react'; -import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../../utils/testing/rtl_helpers'; import { ActionMenuContent } from './action_menu_content'; describe('ActionMenuContent', () => { - it('renders alerts dropdown', async () => { - const { getByLabelText, getByText } = render(); - - const alertsDropdown = getByLabelText('Open alerts and rules context menu'); - fireEvent.click(alertsDropdown); - - await waitFor(() => { - expect(getByText('Create rule')); - expect(getByText('Manage rules')); - }); - }); - it('renders settings link', () => { const { getByRole, getByText } = render(); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.tsx index 6d3d83146a42c..aaf41dc46bcaf 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/header/action_menu_content.tsx @@ -14,12 +14,9 @@ import { createExploratoryViewUrl } from '@kbn/observability-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useSyntheticsSettingsContext } from '../../../contexts'; import { useGetUrlParams } from '../../../hooks'; -import { ToggleAlertFlyoutButton } from '../../overview/alerts/alerts_containers/toggle_alert_flyout_button'; import { MONITOR_ROUTE, SETTINGS_ROUTE } from '../../../../../../common/constants'; import { stringifyUrlParams } from '../../../utils/url_params'; import { InspectorHeaderLink } from './inspector_header_link'; -// import { monitorStatusSelector } from '../../../state/selectors'; -// import { ManageMonitorsBtn } from './manage_monitors_btn'; const ADD_DATA_LABEL = i18n.translate('xpack.synthetics.addDataButtonLabel', { defaultMessage: 'Add data', @@ -93,8 +90,6 @@ export function ActionMenuContent(): React.ReactElement { /> - - {ANALYZE_MESSAGE}

}> { +jest.mock('../../../../../hooks/use_breakpoints', () => { const down = jest.fn().mockReturnValue(false); return { useBreakpoints: () => ({ down }), diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/pages/synthetics_page_template.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/pages/synthetics_page_template.tsx index 0e6c5565b842e..44b38236fc2a2 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/pages/synthetics_page_template.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/pages/synthetics_page_template.tsx @@ -10,7 +10,6 @@ import styled from 'styled-components'; import { EuiPageHeaderProps, EuiPageTemplateProps } from '@elastic/eui'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useInspectorContext } from '@kbn/observability-plugin/public'; -import { CERTIFICATES_ROUTE, OVERVIEW_ROUTE } from '../../../../../../common/constants'; import { ClientPluginsStart } from '../../../../../plugin'; import { useNoDataConfig } from '../../../hooks/use_no_data_config'; import { EmptyStateLoading } from '../../overview/empty_state/empty_state_loading'; @@ -65,9 +64,7 @@ export const SyntheticsPageTemplateComponent: React.FC; } - const isMainRoute = path === OVERVIEW_ROUTE || path === CERTIFICATES_ROUTE; - - const showLoading = loading && isMainRoute && !data; + const showLoading = loading && !data; return ( <> @@ -75,7 +72,7 @@ export const SyntheticsPageTemplateComponent: React.FC {showLoading && } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/alerts_containers/synthetics_alerts_flyout_wrapper.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/alerts_containers/synthetics_alerts_flyout_wrapper.tsx deleted file mode 100644 index 7271e8cd2e998..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/alerts_containers/synthetics_alerts_flyout_wrapper.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { - selectAlertFlyoutVisibility, - selectAlertFlyoutType, - setAlertFlyoutVisible, -} from '../../../../state'; -import { SyntheticsAlertsFlyoutWrapperComponent } from '../synthetics_alerts_flyout_wrapper'; - -export const SyntheticsAlertsFlyoutWrapper: React.FC = () => { - const dispatch = useDispatch(); - const setAddFlyoutVisibility = (value: React.SetStateAction) => - // @ts-ignore the value here is a boolean, and it works with the action creator function - dispatch(setAlertFlyoutVisible(value)); - - const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); - const alertTypeId = useSelector(selectAlertFlyoutType); - - return ( - - ); -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/alerts_containers/toggle_alert_flyout_button.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/alerts_containers/toggle_alert_flyout_button.tsx deleted file mode 100644 index 2fea38d99d094..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/alerts_containers/toggle_alert_flyout_button.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useDispatch } from 'react-redux'; -import { setAlertFlyoutType, setAlertFlyoutVisible } from '../../../../state'; -import { - ToggleAlertFlyoutButtonComponent, - ToggleAlertFlyoutButtonProps, -} from '../toggle_alert_flyout_button'; - -export const ToggleAlertFlyoutButton: React.FC = (props) => { - const dispatch = useDispatch(); - return ( - { - if (typeof value === 'string') { - dispatch(setAlertFlyoutType(value)); - dispatch(setAlertFlyoutVisible(true)); - } else { - dispatch(setAlertFlyoutVisible(value)); - } - }} - /> - ); -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/synthetics_alerts_flyout_wrapper.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/synthetics_alerts_flyout_wrapper.tsx deleted file mode 100644 index 33c76176787cf..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/synthetics_alerts_flyout_wrapper.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useMemo } from 'react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; - -interface Props { - alertFlyoutVisible: boolean; - alertTypeId?: string; - setAlertFlyoutVisibility: React.Dispatch>; -} - -interface KibanaDeps { - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; -} - -export const SyntheticsAlertsFlyoutWrapperComponent = ({ - alertFlyoutVisible, - alertTypeId, - setAlertFlyoutVisibility, -}: Props) => { - const { triggersActionsUi } = useKibana().services; - const onCloseAlertFlyout = useCallback( - () => setAlertFlyoutVisibility(false), - [setAlertFlyoutVisibility] - ); - const AddAlertFlyout = useMemo( - () => - triggersActionsUi.getAddAlertFlyout({ - consumer: 'synthetics', - onClose: onCloseAlertFlyout, - ruleTypeId: alertTypeId, - canChangeTrigger: !alertTypeId, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [onCloseAlertFlyout, alertTypeId] - ); - - return <>{alertFlyoutVisible && AddAlertFlyout}; -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/toggle_alert_flyout_button.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/toggle_alert_flyout_button.test.tsx deleted file mode 100644 index b185d3897d243..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/toggle_alert_flyout_button.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import userEvent from '@testing-library/user-event'; -import { render, forNearestButton, makeSyntheticsPermissionsCore } from '../../../utils/testing'; -import { ToggleAlertFlyoutButtonComponent } from './toggle_alert_flyout_button'; -import { ToggleFlyoutTranslations } from './translations'; - -describe('ToggleAlertFlyoutButtonComponent', () => { - describe('when users have write access to synthetics', () => { - it('enables the button to create a rule', () => { - const { getByText } = render( - , - { core: makeSyntheticsPermissionsCore({ save: true }) } - ); - userEvent.click(getByText('Alerts and rules')); - expect( - forNearestButton(getByText)(ToggleFlyoutTranslations.openAlertContextPanelLabel) - ).toBeEnabled(); - }); - - it("does not contain a tooltip explaining why the user can't create alerts", async () => { - const { getByText, findByText } = render( - , - { core: makeSyntheticsPermissionsCore({ save: true }) } - ); - userEvent.click(getByText('Alerts and rules')); - userEvent.hover(getByText(ToggleFlyoutTranslations.openAlertContextPanelLabel)); - await expect(() => - findByText('You need read-write access to Synthetics to create alerts in this app.') - ).rejects.toEqual(expect.anything()); - }); - }); - - describe("when users don't have write access to uptime", () => { - it('disables the button to create a rule', () => { - const { getByText } = render( - , - { core: makeSyntheticsPermissionsCore({ save: false }) } - ); - userEvent.click(getByText('Alerts and rules')); - expect( - forNearestButton(getByText)(ToggleFlyoutTranslations.openAlertContextPanelLabel) - ).toBeDisabled(); - }); - - it("contains a tooltip explaining why users can't create rules", async () => { - const { getByText, findByText } = render( - , - { core: makeSyntheticsPermissionsCore({ save: false }) } - ); - userEvent.click(getByText('Alerts and rules')); - userEvent.hover(getByText(ToggleFlyoutTranslations.openAlertContextPanelLabel)); - expect( - await findByText('You need read-write access to Uptime to create alerts in this app.') - ).toBeInTheDocument(); - }); - }); -}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/toggle_alert_flyout_button.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/toggle_alert_flyout_button.tsx deleted file mode 100644 index f29fe0941ee82..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/toggle_alert_flyout_button.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EuiHeaderLink, - EuiContextMenu, - EuiContextMenuPanelDescriptor, - EuiContextMenuPanelItemDescriptor, - EuiLink, - EuiPopover, -} from '@elastic/eui'; -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { CLIENT_ALERT_TYPES } from '../../../../../../common/constants/alerts'; -import { ClientPluginsStart } from '../../../../../plugin'; - -import { ToggleFlyoutTranslations } from './translations'; - -interface ComponentProps { - setAlertFlyoutVisible: (value: boolean | string) => void; -} - -export interface ToggleAlertFlyoutButtonProps { - alertOptions?: string[]; -} - -type Props = ComponentProps & ToggleAlertFlyoutButtonProps; - -const ALERT_CONTEXT_MAIN_PANEL_ID = 0; -const ALERT_CONTEXT_SELECT_TYPE_PANEL_ID = 1; - -const noWritePermissionsTooltipContent = i18n.translate( - 'xpack.synthetics.alertDropdown.noWritePermissions', - { - defaultMessage: 'You need read-write access to Uptime to create alerts in this app.', - } -); - -export const ToggleAlertFlyoutButtonComponent: React.FC = ({ - alertOptions, - setAlertFlyoutVisible, -}) => { - const [isOpen, setIsOpen] = useState(false); - const kibana = useKibana(); - const { - services: { observability }, - } = useKibana(); - const manageRulesUrl = observability.useRulesLink(); - const hasUptimeWrite = kibana.services.application?.capabilities.uptime?.save ?? false; - - const monitorStatusAlertContextMenuItem: EuiContextMenuPanelItemDescriptor = { - 'aria-label': ToggleFlyoutTranslations.toggleMonitorStatusAriaLabel, - 'data-test-subj': 'xpack.synthetics.toggleAlertFlyout', - name: ToggleFlyoutTranslations.toggleMonitorStatusContent, - onClick: () => { - setAlertFlyoutVisible(CLIENT_ALERT_TYPES.MONITOR_STATUS); - setIsOpen(false); - }, - }; - - const tlsAlertContextMenuItem: EuiContextMenuPanelItemDescriptor = { - 'aria-label': ToggleFlyoutTranslations.toggleTlsAriaLabel, - 'data-test-subj': 'xpack.synthetics.toggleTlsAlertFlyout', - name: ToggleFlyoutTranslations.toggleTlsContent, - onClick: () => { - setAlertFlyoutVisible(CLIENT_ALERT_TYPES.TLS); - setIsOpen(false); - }, - }; - - const managementContextItem: EuiContextMenuPanelItemDescriptor = { - 'aria-label': ToggleFlyoutTranslations.navigateToAlertingUIAriaLabel, - 'data-test-subj': 'xpack.synthetics.navigateToAlertingUi', - name: ( - - - - ), - icon: 'tableOfContents', - }; - - let selectionItems: EuiContextMenuPanelItemDescriptor[] = []; - if (!alertOptions) { - selectionItems = [monitorStatusAlertContextMenuItem, tlsAlertContextMenuItem]; - } else { - alertOptions.forEach((option) => { - if (option === CLIENT_ALERT_TYPES.MONITOR_STATUS) - selectionItems.push(monitorStatusAlertContextMenuItem); - else if (option === CLIENT_ALERT_TYPES.TLS) selectionItems.push(tlsAlertContextMenuItem); - }); - } - - if (selectionItems.length === 1) { - selectionItems[0].icon = 'bell'; - } - - let panels: EuiContextMenuPanelDescriptor[]; - if (selectionItems.length === 1) { - panels = [ - { - id: ALERT_CONTEXT_MAIN_PANEL_ID, - items: [...selectionItems, managementContextItem], - }, - ]; - } else { - panels = [ - { - id: ALERT_CONTEXT_MAIN_PANEL_ID, - items: [ - { - 'aria-label': ToggleFlyoutTranslations.openAlertContextPanelAriaLabel, - 'data-test-subj': 'xpack.synthetics.openAlertContextPanel', - name: ToggleFlyoutTranslations.openAlertContextPanelLabel, - icon: 'bell', - panel: ALERT_CONTEXT_SELECT_TYPE_PANEL_ID, - toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null, - disabled: !hasUptimeWrite, - }, - managementContextItem, - ], - }, - { - id: ALERT_CONTEXT_SELECT_TYPE_PANEL_ID, - title: ToggleFlyoutTranslations.toggleAlertFlyoutButtonLabel, - items: selectionItems, - }, - ]; - } - - return ( - setIsOpen(!isOpen)} - > - - - } - closePopover={() => setIsOpen(false)} - isOpen={isOpen} - ownFocus - panelPaddingSize="none" - > - - - ); -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/translations.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/translations.ts deleted file mode 100644 index 0580528b6b38c..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/alerts/translations.ts +++ /dev/null @@ -1,345 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const SECONDS_TIME_RANGE = i18n.translate( - 'xpack.synthetics.alerts.timerangeUnitSelectable.secondsOption.ariaLabel', - { - defaultMessage: '"Seconds" time range select item', - } -); - -export const SECONDS = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.timerangeOption.seconds', - { - defaultMessage: 'seconds', - } -); - -export const MINUTES_TIME_RANGE = i18n.translate( - 'xpack.synthetics.alerts.timerangeUnitSelectable.minutesOption.ariaLabel', - { - defaultMessage: '"Minutes" time range select item', - } -); - -export const MINUTES = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.timerangeOption.minutes', - { - defaultMessage: 'minutes', - } -); - -export const HOURS_TIME_RANGE = i18n.translate( - 'xpack.synthetics.alerts.timerangeUnitSelectable.hoursOption.ariaLabel', - { - defaultMessage: '"Hours" time range select item', - } -); - -export const HOURS = i18n.translate('xpack.synthetics.alerts.monitorStatus.timerangeOption.hours', { - defaultMessage: 'hours', -}); - -export const DAYS_TIME_RANGE = i18n.translate( - 'xpack.synthetics.alerts.timerangeUnitSelectable.daysOption.ariaLabel', - { - defaultMessage: '"Days" time range select item', - } -); - -export const DAYS = i18n.translate('xpack.synthetics.alerts.monitorStatus.timerangeOption.days', { - defaultMessage: 'days', -}); - -export const WEEKS_TIME_RANGE = i18n.translate( - 'xpack.synthetics.alerts.timerangeUnitSelectable.weeksOption.ariaLabel', - { - defaultMessage: '"Weeks" time range select item', - } -); - -export const WEEKS = i18n.translate('xpack.synthetics.alerts.monitorStatus.timerangeOption.weeks', { - defaultMessage: 'weeks', -}); - -export const MONTHS_TIME_RANGE = i18n.translate( - 'xpack.synthetics.alerts.timerangeUnitSelectable.monthsOption.ariaLabel', - { - defaultMessage: '"Months" time range select item', - } -); - -export const MONTHS = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.timerangeOption.months', - { - defaultMessage: 'months', - } -); - -export const YEARS_TIME_RANGE = i18n.translate( - 'xpack.synthetics.alerts.timerangeUnitSelectable.yearsOption.ariaLabel', - { - defaultMessage: '"Years" time range select item', - } -); - -export const YEARS = i18n.translate('xpack.synthetics.alerts.monitorStatus.timerangeOption.years', { - defaultMessage: 'years', -}); - -export const ALERT_KUERY_BAR_ARIA = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.filterBar.ariaLabel', - { - defaultMessage: 'Input that allows filtering criteria for the monitor status alert', - } -); - -export const OPEN_THE_POPOVER_DOWN_COUNT = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.numTimesExpression.ariaLabel', - { - defaultMessage: 'Open the popover for down count input', - } -); - -export const ENTER_NUMBER_OF_DOWN_COUNTS = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.numTimesField.ariaLabel', - { - defaultMessage: 'Enter number of down counts required to trigger the alert', - } -); - -export const MATCHING_MONITORS_DOWN = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.numTimesExpression.matchingMonitors.description', - { - defaultMessage: 'matching monitors are down >', - } -); - -export const ANY_MONITOR_DOWN = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.numTimesExpression.anyMonitors.description', - { - defaultMessage: 'any monitor is down >', - } -); - -export const OPEN_THE_POPOVER_TIME_RANGE_VALUE = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.timerangeValueExpression.ariaLabel', - { - defaultMessage: 'Open the popover for time range value field', - } -); - -export const ENTER_NUMBER_OF_TIME_UNITS = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.timerangeValueField.ariaLabel', - { - defaultMessage: `Enter the number of time units for the alert's range`, - } -); - -export const ENTER_NUMBER_OF_TIME_UNITS_DESCRIPTION = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.timerangeValueField.expression', - { - defaultMessage: 'within', - } -); - -export const ENTER_NUMBER_OF_TIME_UNITS_VALUE = (value: number) => - i18n.translate('xpack.synthetics.alerts.monitorStatus.timerangeValueField.value', { - defaultMessage: 'last {value}', - values: { value }, - }); - -export const ENTER_AVAILABILITY_RANGE_ENABLED = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.isEnabledCheckbox.label', - { - defaultMessage: 'Availability', - } -); - -export const ENTER_AVAILABILITY_RANGE_POPOVER_ARIA_LABEL = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.timerangeValueField.popover.ariaLabel', - { - defaultMessage: 'Specify availability tracking time range', - } -); - -export const ENTER_AVAILABILITY_RANGE_UNITS_ARIA_LABEL = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.timerangeValueField.ariaLabel', - { - defaultMessage: `Enter the number of units for the alert's availability check.`, - } -); - -export const ENTER_AVAILABILITY_RANGE_UNITS_DESCRIPTION = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.timerangeValueField.expression', - { - defaultMessage: 'within the last', - } -); - -export const ENTER_AVAILABILITY_THRESHOLD_ARIA_LABEL = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.threshold.ariaLabel', - { - defaultMessage: 'Specify availability thresholds for this alert', - } -); - -export const ENTER_AVAILABILITY_THRESHOLD_INPUT_ARIA_LABEL = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.threshold.input.ariaLabel', - { - defaultMessage: 'Input an availability threshold to check for this alert', - } -); - -export const ENTER_AVAILABILITY_THRESHOLD_DESCRIPTION = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.threshold.description', - { - defaultMessage: 'matching monitors are up in', - description: - 'This fragment explains that an alert will fire for monitors matching user-specified criteria', - } -); - -export const ENTER_ANY_AVAILABILITY_THRESHOLD_DESCRIPTION = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.threshold.anyMonitorDescription', - { - defaultMessage: 'any monitor is up in', - description: - 'This fragment explains that an alert will fire for monitors matching user-specified criteria', - } -); - -export const ENTER_AVAILABILITY_THRESHOLD_VALUE = (value: string) => - i18n.translate('xpack.synthetics.alerts.monitorStatus.availability.threshold.value', { - defaultMessage: '< {value}% of checks', - description: - 'This fragment specifies criteria that will cause an alert to fire for uptime monitors', - values: { value }, - }); - -export const ENTER_AVAILABILITY_RANGE_SELECT_ARIA = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.unit.selectable', - { - defaultMessage: 'Use this select to set the availability range units for this alert', - } -); - -export const ENTER_AVAILABILITY_RANGE_SELECT_HEADLINE = i18n.translate( - 'xpack.synthetics.alerts.monitorStatus.availability.unit.headline', - { - defaultMessage: 'Select time range unit', - } -); - -export const ADD_FILTER = i18n.translate('xpack.synthetics.alerts.monitorStatus.addFilter', { - defaultMessage: `Add filter`, -}); - -export const LOCATION = i18n.translate('xpack.synthetics.alerts.monitorStatus.addFilter.location', { - defaultMessage: `Location`, -}); - -export const TAG = i18n.translate('xpack.synthetics.alerts.monitorStatus.addFilter.tag', { - defaultMessage: `Tag`, -}); - -export const PORT = i18n.translate('xpack.synthetics.alerts.monitorStatus.addFilter.port', { - defaultMessage: `Port`, -}); - -export const TYPE = i18n.translate('xpack.synthetics.alerts.monitorStatus.addFilter.type', { - defaultMessage: `Type`, -}); - -export const TlsTranslations = { - criteriaAriaLabel: i18n.translate('xpack.synthetics.alerts.tls.criteriaExpression.ariaLabel', { - defaultMessage: - 'An expression displaying the criteria for monitor that are watched by this alert', - }), - criteriaDescription: i18n.translate( - 'xpack.synthetics.alerts.tls.criteriaExpression.description', - { - defaultMessage: 'when', - description: - 'The context of this `when` is in the conditional sense, like "when there are three cookies, eat them all".', - } - ), - criteriaValue: i18n.translate('xpack.synthetics.alerts.tls.criteriaExpression.value', { - defaultMessage: 'any monitor', - }), - expirationAriaLabel: i18n.translate( - 'xpack.synthetics.alerts.tls.expirationExpression.ariaLabel', - { - defaultMessage: - 'An expression displaying the threshold that will trigger the TLS alert for certificate expiration', - } - ), - expirationDescription: i18n.translate( - 'xpack.synthetics.alerts.tls.expirationExpression.description', - { - defaultMessage: 'has a certificate expiring within', - } - ), - expirationValue: (value?: number) => - i18n.translate('xpack.synthetics.alerts.tls.expirationExpression.value', { - defaultMessage: '{value} days', - values: { value }, - }), - ageAriaLabel: i18n.translate('xpack.synthetics.alerts.tls.ageExpression.ariaLabel', { - defaultMessage: - 'An expressing displaying the threshold that will trigger the TLS alert for old certificates', - }), - ageDescription: i18n.translate('xpack.synthetics.alerts.tls.ageExpression.description', { - defaultMessage: 'or older than', - }), - ageValue: (value?: number) => - i18n.translate('xpack.synthetics.alerts.tls.ageExpression.value', { - defaultMessage: '{value} days', - values: { value }, - }), -}; - -export const ToggleFlyoutTranslations = { - toggleButtonAriaLabel: i18n.translate('xpack.synthetics.alertsPopover.toggleButton.ariaLabel', { - defaultMessage: 'Open alerts and rules context menu', - }), - openAlertContextPanelAriaLabel: i18n.translate( - 'xpack.synthetics.openAlertContextPanel.ariaLabel', - { - defaultMessage: 'Open the rule context panel so you can choose a rule type', - } - ), - openAlertContextPanelLabel: i18n.translate('xpack.synthetics.openAlertContextPanel.label', { - defaultMessage: 'Create rule', - }), - toggleTlsAriaLabel: i18n.translate('xpack.synthetics.toggleTlsAlertButton.ariaLabel', { - defaultMessage: 'Open TLS rule flyout', - }), - toggleTlsContent: i18n.translate('xpack.synthetics.toggleTlsAlertButton.content', { - defaultMessage: 'TLS rule', - }), - toggleMonitorStatusAriaLabel: i18n.translate('xpack.synthetics.toggleAlertFlyout.ariaLabel', { - defaultMessage: 'Open add rule flyout', - }), - toggleMonitorStatusContent: i18n.translate('xpack.synthetics.toggleAlertButton.content', { - defaultMessage: 'Monitor status rule', - }), - navigateToAlertingUIAriaLabel: i18n.translate('xpack.synthetics.navigateToAlertingUi', { - defaultMessage: 'Leave Uptime and go to Alerting Management page', - }), - navigateToAlertingButtonContent: i18n.translate( - 'xpack.synthetics.navigateToAlertingButton.content', - { - defaultMessage: 'Manage rules', - } - ), - toggleAlertFlyoutButtonLabel: i18n.translate('xpack.synthetics.alerts.createRulesPanel.title', { - defaultMessage: 'Create rules', - }), -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/filter_group/labels.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/filter_group/labels.ts deleted file mode 100644 index e1cf5e20e14c3..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/overview/filter_group/labels.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const filterLabels = { - LOCATION: i18n.translate('xpack.synthetics.filterBar.options.location.name', { - defaultMessage: 'Location', - }), - - PORT: i18n.translate('xpack.synthetics.filterBar.options.portLabel', { defaultMessage: 'Port' }), - - SCHEME: i18n.translate('xpack.synthetics.filterBar.options.schemeLabel', { - defaultMessage: 'Scheme', - }), - - TAG: i18n.translate('xpack.synthetics.filterBar.options.tagsLabel', { - defaultMessage: 'Tag', - }), -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/index.ts index a7df47d7a0f71..15079dc68823b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/index.ts @@ -6,9 +6,8 @@ */ export * from './use_url_params'; -export * from './use_filter_update'; export * from './use_breadcrumbs'; export * from './use_telemetry'; -export * from './use_breakpoints'; +export * from '../../../hooks/use_breakpoints'; export * from './use_service_allowed'; export * from './use_no_data_config'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_filter_update.test.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_filter_update.test.ts deleted file mode 100644 index da3a25a5fc9df..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_filter_update.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { addUpdatedField } from './use_filter_update'; - -describe('useFilterUpdate', () => { - describe('addUpdatedField', () => { - it('conditionally adds fields if they are new', () => { - const testVal = {}; - addUpdatedField('a val', 'newField', 'a new val', testVal); - expect(testVal).toEqual({ - newField: 'a new val', - }); - }); - - it('will add a field if the value is the same but not the default', () => { - const testVal = {}; - addUpdatedField('a val', 'newField', 'a val', testVal); - expect(testVal).toEqual({ newField: 'a val' }); - }); - - it(`won't add a field if the current value is empty`, () => { - const testVal = {}; - addUpdatedField('', 'newField', '', testVal); - expect(testVal).toEqual({}); - }); - }); -}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_filter_update.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_filter_update.ts deleted file mode 100644 index 5578230ab2cf0..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_filter_update.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect } from 'react'; -import { useUrlParams } from './use_url_params'; - -export const parseFiltersMap = (currentFilters: string): Map => { - try { - return new Map(JSON.parse(currentFilters)); - } catch { - return new Map(); - } -}; - -const getUpdateFilters = ( - filterKueries: Map, - fieldName: string, - values?: string[] -): string => { - // add new term to filter map, toggle it off if already present - const updatedFilterMap = new Map(filterKueries); - updatedFilterMap.set(fieldName, values ?? []); - updatedFilterMap.forEach((value, key) => { - if (typeof value !== 'undefined' && value.length === 0) { - updatedFilterMap.delete(key); - } - }); - - // store the new set of filters - const persistedFilters = Array.from(updatedFilterMap); - return persistedFilters.length === 0 ? '' : JSON.stringify(persistedFilters); -}; - -export function addUpdatedField( - current: string, - key: string, - updated: string, - objToUpdate: { [key: string]: string } -): void { - if (current !== updated || current !== '') { - objToUpdate[key] = updated; - } -} - -export const useFilterUpdate = ( - fieldName: string, - values: string[], - notValues: string[], - shouldUpdateUrl: boolean = true -) => { - const [getUrlParams, updateUrl] = useUrlParams(); - - const { filters, excludedFilters } = getUrlParams(); - - useEffect(() => { - const currentFiltersMap: Map = parseFiltersMap(filters); - const currentExclusionsMap: Map = parseFiltersMap(excludedFilters); - const newFiltersString = getUpdateFilters(currentFiltersMap, fieldName, values); - const newExclusionsString = getUpdateFilters(currentExclusionsMap, fieldName, notValues); - - const update: { [key: string]: string } = {}; - - addUpdatedField(filters, 'filters', newFiltersString, update); - addUpdatedField(excludedFilters, 'excludedFilters', newExclusionsString, update); - - if (shouldUpdateUrl && Object.keys(update).length > 0) { - // reset pagination whenever filters change - updateUrl({ ...update, pagination: '' }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fieldName, values, notValues]); -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_telemetry.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_telemetry.ts index f97e4c4b2be09..64ecabaff5d5a 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_telemetry.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_telemetry.ts @@ -7,7 +7,7 @@ import { useEffect } from 'react'; import { useGetUrlParams } from './use_url_params'; -import { apiService } from '../utils/api_service'; +import { apiService } from '../../../utils/api_service'; // import { API_URLS } from '../../../common/constants'; export enum SyntheticsPage { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx index d20c390c84b59..7f04b3992885b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx @@ -23,7 +23,7 @@ import { OVERVIEW_ROUTE, } from '../../../common/constants'; import { MonitorManagementPage } from './components/monitor_management/monitor_management_page'; -import { apiService } from './utils/api_service'; +import { apiService } from '../../utils/api_service'; import { SyntheticsPage, useSyntheticsTelemetry } from './hooks/use_telemetry'; type RouteProps = { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/index_status/api.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/index_status/api.ts index f2d5e326ba2ab..ba6ded899f9c4 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/index_status/api.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/index_status/api.ts @@ -7,7 +7,7 @@ import { API_URLS } from '../../../../../common/constants'; import { StatesIndexStatus, StatesIndexStatusType } from '../../../../../common/runtime_types'; -import { apiService } from '../../utils/api_service'; +import { apiService } from '../../../../utils/api_service'; export const fetchIndexStatus = async (): Promise => { return await apiService.get(API_URLS.INDEX_STATUS, undefined, StatesIndexStatusType); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx index 614f77ddff5d7..07fb3604abd42 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx @@ -17,7 +17,6 @@ import { } from '@kbn/kibana-react-plugin/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { InspectorContextProvider } from '@kbn/observability-plugin/public'; -import { SyntheticsAlertsFlyoutWrapper } from './components/overview/alerts/alerts_containers/synthetics_alerts_flyout_wrapper'; import { SyntheticsAppProps } from './contexts'; import { @@ -30,7 +29,7 @@ import { import { PageRouter } from './routes'; import { store, storage, setBasePath } from './state'; -import { kibanaService } from './utils/kibana_service'; +import { kibanaService } from '../../utils/kibana_service'; import { ActionMenu } from './components/common/header/action_menu'; const Application = (props: SyntheticsAppProps) => { @@ -99,7 +98,6 @@ const Application = (props: SyntheticsAppProps) => { application={core.application} > - diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/rtl_helpers.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/rtl_helpers.tsx index 51c186c352a5b..71d86cc53a76b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/rtl_helpers.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/rtl_helpers.tsx @@ -36,7 +36,7 @@ import { SyntheticsRefreshContextProvider, SyntheticsStartupPluginsContextProvider, } from '../../contexts'; -import { kibanaService } from '../kibana_service'; +import { kibanaService } from '../../../../utils/kibana_service'; type DeepPartial = { [P in keyof T]?: DeepPartial; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_breakpoints.test.ts b/x-pack/plugins/synthetics/public/hooks/use_breakpoints.test.ts similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_breakpoints.test.ts rename to x-pack/plugins/synthetics/public/hooks/use_breakpoints.test.ts diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_breakpoints.ts b/x-pack/plugins/synthetics/public/hooks/use_breakpoints.ts similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_breakpoints.ts rename to x-pack/plugins/synthetics/public/hooks/use_breakpoints.ts diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/app/uptime_page_template.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/app/uptime_page_template.test.tsx index 57ae3a6514505..4efaf26a7ac11 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/app/uptime_page_template.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/app/uptime_page_template.test.tsx @@ -11,9 +11,9 @@ import 'jest-styled-components'; import { render } from '../lib/helper/rtl_helpers'; import { UptimePageTemplateComponent } from './uptime_page_template'; import { OVERVIEW_ROUTE } from '../../../common/constants'; -import { useBreakpoints } from '../../apps/synthetics/hooks/use_breakpoints'; +import { useBreakpoints } from '../../hooks/use_breakpoints'; -jest.mock('../../apps/synthetics/hooks/use_breakpoints', () => { +jest.mock('../../hooks/use_breakpoints', () => { const down = jest.fn().mockReturnValue(false); return { useBreakpoints: () => ({ down }), diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/app/uptime_page_template.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/app/uptime_page_template.tsx index ade54e1e6f61a..fa3ad7e0805e8 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/app/uptime_page_template.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/app/uptime_page_template.tsx @@ -16,7 +16,7 @@ import { useNoDataConfig } from './use_no_data_config'; import { EmptyStateLoading } from '../components/overview/empty_state/empty_state_loading'; import { EmptyStateError } from '../components/overview/empty_state/empty_state_error'; import { useHasData } from '../components/overview/empty_state/use_has_data'; -import { useBreakpoints } from '../../apps/synthetics/hooks/use_breakpoints'; +import { useBreakpoints } from '../../hooks/use_breakpoints'; interface Props { path: string; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx index 0e4d03e3ce438..73996c4e3a1b7 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx @@ -10,7 +10,7 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { ScreenshotRefImageData } from '../../../../../../../common/runtime_types'; -import { useBreakpoints } from '../../../../../../apps/synthetics/hooks/use_breakpoints'; +import { useBreakpoints } from '../../../../../../hooks/use_breakpoints'; import { nextAriaLabel, prevAriaLabel } from './translations'; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/monitor_list.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/monitor_list.tsx index 0c1d56be587a4..4b9374e991e6b 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/monitor_list.tsx @@ -27,7 +27,7 @@ import { TCPSimpleFields, } from '../../../../../common/runtime_types'; import { UptimeSettingsContext } from '../../../contexts'; -import { useBreakpoints } from '../../../../apps/synthetics/hooks/use_breakpoints'; +import { useBreakpoints } from '../../../../hooks/use_breakpoints'; import { MonitorManagementList as MonitorManagementListState } from '../../../state/reducers/monitor_management'; import * as labels from '../../overview/monitor_list/translations'; import { Actions } from './actions'; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/pages/synthetics/checks_navigation.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/pages/synthetics/checks_navigation.tsx index c09da77a6f559..f9d98b7b640c6 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/pages/synthetics/checks_navigation.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/pages/synthetics/checks_navigation.tsx @@ -12,7 +12,7 @@ import { useHistory } from 'react-router-dom'; import moment from 'moment'; import { SyntheticsJourneyApiResponse } from '../../../../common/runtime_types/ping'; import { getShortTimeStamp } from '../../components/overview/monitor_list/columns/monitor_status_column'; -import { useBreakpoints } from '../../../apps/synthetics/hooks/use_breakpoints'; +import { useBreakpoints } from '../../../hooks/use_breakpoints'; interface Props { timestamp: string; diff --git a/x-pack/plugins/synthetics/public/plugin.ts b/x-pack/plugins/synthetics/public/plugin.ts index 8427f8c3060de..412f9167a8845 100644 --- a/x-pack/plugins/synthetics/public/plugin.ts +++ b/x-pack/plugins/synthetics/public/plugin.ts @@ -30,6 +30,7 @@ import { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/p import { FleetStart } from '@kbn/fleet-plugin/public'; import { + enableNewSyntheticsView, FetchDataParams, ObservabilityPublicSetup, ObservabilityPublicStart, @@ -192,22 +193,26 @@ export class UptimePlugin }, }); - // Register the Synthetics UI plugin - core.application.register({ - id: 'synthetics', - euiIconType: 'logoObservability', - order: 8400, - title: PLUGIN.SYNTHETICS, - category: DEFAULT_APP_CATEGORIES.observability, - keywords: appKeywords, - deepLinks: [], - mount: async (params: AppMountParameters) => { - const [coreStart, corePlugins] = await core.getStartServices(); + const isSyntheticsViewEnabled = core.uiSettings.get(enableNewSyntheticsView); - const { renderApp } = await import('./apps/synthetics/render_app'); - return renderApp(coreStart, plugins, corePlugins, params, this.initContext.env.mode.dev); - }, - }); + if (isSyntheticsViewEnabled) { + // Register the Synthetics UI plugin + core.application.register({ + id: 'synthetics', + euiIconType: 'logoObservability', + order: 8400, + title: PLUGIN.SYNTHETICS, + category: DEFAULT_APP_CATEGORIES.observability, + keywords: appKeywords, + deepLinks: [], + mount: async (params: AppMountParameters) => { + const [coreStart, corePlugins] = await core.getStartServices(); + + const { renderApp } = await import('./apps/synthetics/render_app'); + return renderApp(coreStart, plugins, corePlugins, params, this.initContext.env.mode.dev); + }, + }); + } } public start(start: CoreStart, plugins: ClientPluginsStart): void { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/api_service/api_service.ts b/x-pack/plugins/synthetics/public/utils/api_service/api_service.ts similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/utils/api_service/api_service.ts rename to x-pack/plugins/synthetics/public/utils/api_service/api_service.ts diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/api_service/index.ts b/x-pack/plugins/synthetics/public/utils/api_service/index.ts similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/utils/api_service/index.ts rename to x-pack/plugins/synthetics/public/utils/api_service/index.ts diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/kibana_service/index.ts b/x-pack/plugins/synthetics/public/utils/kibana_service/index.ts similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/utils/kibana_service/index.ts rename to x-pack/plugins/synthetics/public/utils/kibana_service/index.ts diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/kibana_service/kibana_service.ts b/x-pack/plugins/synthetics/public/utils/kibana_service/kibana_service.ts similarity index 100% rename from x-pack/plugins/synthetics/public/apps/synthetics/utils/kibana_service/kibana_service.ts rename to x-pack/plugins/synthetics/public/utils/kibana_service/kibana_service.ts diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index 867264fa81546..528c6e4293cf4 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -314,7 +314,7 @@ export enum TimelineId { usersPageExternalAlerts = 'users-page-external-alerts', hostsPageEvents = 'hosts-page-events', hostsPageExternalAlerts = 'hosts-page-external-alerts', - hostsPageSessions = 'hosts-page-sessions', + hostsPageSessions = 'hosts-page-sessions-v2', detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', networkPageExternalAlerts = 'network-page-external-alerts', diff --git a/x-pack/plugins/timelines/public/store/t_grid/types.ts b/x-pack/plugins/timelines/public/store/t_grid/types.ts index c4627b3accd71..8e0b7e995dbcd 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/types.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/types.ts @@ -46,7 +46,7 @@ export enum TimelineId { usersPageExternalAlerts = 'users-page-external-alerts', hostsPageEvents = 'hosts-page-events', hostsPageExternalAlerts = 'hosts-page-external-alerts', - hostsPageSessions = 'hosts-page-sessions', + hostsPageSessions = 'hosts-page-sessions-v2', detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', networkPageExternalAlerts = 'network-page-external-alerts', diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/index.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/index.ts index 980f19ac2950c..d450daadf4689 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/index.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/index.ts @@ -209,17 +209,13 @@ const timelineSessionsSearchStrategy = ({ }; const collapse = { - field: 'process.entity_id', - inner_hits: { - name: 'last_event', - size: 1, - sort: [{ '@timestamp': 'desc' }], - }, + field: 'process.entry_leader.entity_id', }; + const aggs = { total: { cardinality: { - field: 'process.entity_id', + field: 'process.entry_leader.entity_id', }, }, }; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 5380b97f300ee..f23d9cf64202b 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -23766,13 +23766,10 @@ "xpack.securitySolution.console.builtInCommands.clearAbout": "Purger la mémoire tampon de la console", "xpack.securitySolution.console.builtInCommands.helpAbout": "Afficher la liste des commandes disponibles", "xpack.securitySolution.console.commandList.footerText": "Pour plus d’informations sur les commandes ci-dessus, utilisez l’argument {helpOption}. Exemple : {cmdExample}", - "xpack.securitySolution.console.commandOutput.runInBackgroundButtonLabel": "Exécuter en arrière-plan", - "xpack.securitySolution.console.commandOutput.runInBackgroundMsg": "La réponse à la commande prend un peu de temps. Cliquez ici pour l’exécuter en arrière-plan et être averti lors de la réception de la réponse.", "xpack.securitySolution.console.commandUsage.atLeastOneOptionRequiredMessage": "Remarque : au moins une option doit être utilisée.", "xpack.securitySolution.console.commandUsage.inputUsage": "Utilisation :", "xpack.securitySolution.console.commandUsage.optionsLabel": "Options :", "xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce": "cet argument ne peut être utilisé qu’une fois : {argName}.", - "xpack.securitySolution.console.commandValidation.cmdHelpTitle": "commande {cmdName}", "xpack.securitySolution.console.commandValidation.invalidArgValue": "valeur d’argument non valide : {argName}. {error}", "xpack.securitySolution.console.commandValidation.missingRequiredArg": "argument requis manquant : {argName}", "xpack.securitySolution.console.commandValidation.mustHaveArgs": "arguments requis manquants : {requiredArgs}", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4104c04a6464b..9b42d92c275e9 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23917,13 +23917,10 @@ "xpack.securitySolution.console.builtInCommands.clearAbout": "コンソールバッファーを消去", "xpack.securitySolution.console.builtInCommands.helpAbout": "使用可能なコマンドのリストを表示", "xpack.securitySolution.console.commandList.footerText": "上記のコマンドの詳細については、{helpOption}引数を使用してください。例:{cmdExample}", - "xpack.securitySolution.console.commandOutput.runInBackgroundButtonLabel": "バックグラウンドで実行", - "xpack.securitySolution.console.commandOutput.runInBackgroundMsg": "コマンド応答には時間がかかります。ここをクリックするとバックグラウンドで実行し、応答を受信したときに通知が表示されます", "xpack.securitySolution.console.commandUsage.atLeastOneOptionRequiredMessage": "注記:1つ以上のオプションを使用する必要があります", "xpack.securitySolution.console.commandUsage.inputUsage": "使用方法:", "xpack.securitySolution.console.commandUsage.optionsLabel": "オプション:", "xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce": "引数{argName}は一度だけ使用できます", - "xpack.securitySolution.console.commandValidation.cmdHelpTitle": "{cmdName}コマンド", "xpack.securitySolution.console.commandValidation.invalidArgValue": "無効な引数値:{argName}。{error}", "xpack.securitySolution.console.commandValidation.missingRequiredArg": "不足している必須の引数:{argName}", "xpack.securitySolution.console.commandValidation.mustHaveArgs": "不足している必須の引数:{requiredArgs}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 451a141200391..dd87bbaa23fef 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23950,13 +23950,10 @@ "xpack.securitySolution.console.builtInCommands.clearAbout": "清除控制台缓冲区", "xpack.securitySolution.console.builtInCommands.helpAbout": "查看可用命令列表", "xpack.securitySolution.console.commandList.footerText": "有关上述命令的更多详情,请使用 {helpOption} 参数。示例:{cmdExample}", - "xpack.securitySolution.console.commandOutput.runInBackgroundButtonLabel": "在后台运行", - "xpack.securitySolution.console.commandOutput.runInBackgroundMsg": "命令响应花费的时间略长。单击此处以在后台运行,并在收到响应时发送通知", "xpack.securitySolution.console.commandUsage.atLeastOneOptionRequiredMessage": "注意:必须至少使用一个选项", "xpack.securitySolution.console.commandUsage.inputUsage": "用法:", "xpack.securitySolution.console.commandUsage.optionsLabel": "选项:", "xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce": "参数只能使用一次:{argName}", - "xpack.securitySolution.console.commandValidation.cmdHelpTitle": "{cmdName} 命令", "xpack.securitySolution.console.commandValidation.invalidArgValue": "无效的参数值:{argName}。{error}", "xpack.securitySolution.console.commandValidation.missingRequiredArg": "缺少所需参数:{argName}", "xpack.securitySolution.console.commandValidation.mustHaveArgs": "缺少所需参数:{requiredArgs}", diff --git a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts index 0a0b8cdeab208..33f5fdc44afcd 100644 --- a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts +++ b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts @@ -15,6 +15,7 @@ export const allowedExperimentalValues = Object.freeze({ rulesListDatagrid: true, internalAlertsTable: false, internalShareableComponentsSandbox: false, + ruleTagFilter: false, ruleStatusFilter: false, rulesDetailLogs: true, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index 71bcb2ee7d760..c4c273bd003c5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -31,7 +31,7 @@ import { } from '../types'; import { Section, routeToRuleDetails, legacyRouteToRuleDetails } from './constants'; -import { setSavedObjectsClient } from '../common/lib/data_apis'; +import { setDataViewsService } from '../common/lib/data_apis'; import { KibanaContextProvider } from '../common/lib/kibana'; const TriggersActionsUIHome = lazy(() => import('./home')); @@ -67,12 +67,12 @@ export const renderApp = (deps: TriggersAndActionsUiServices) => { }; export const App = ({ deps }: { deps: TriggersAndActionsUiServices }) => { - const { savedObjects, uiSettings, theme$ } = deps; + const { dataViews, uiSettings, theme$ } = deps; const sections: Section[] = ['rules', 'connectors', 'alerts', '__components_sandbox']; const isDarkMode = useObservable(uiSettings.get$('theme:darkMode')); const sectionsRegex = sections.join('|'); - setSavedObjectsClient(savedObjects.client); + setDataViewsService(dataViews); return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_tag_filter_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_tag_filter_sandbox.tsx new file mode 100644 index 0000000000000..58603fdb8f178 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_tag_filter_sandbox.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, { useState } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { getRuleTagFilterLazy } from '../../../common/get_rule_tag_filter'; + +export const RuleTagFilterSandbox = () => { + const [selectedTags, setSelectedTags] = useState([]); + + return ( +
+ {getRuleTagFilterLazy({ + tags: ['tag1', 'tag2', 'tag3', 'tag4'], + selectedTags, + onChange: setSelectedTags, + })} + +
selected tags: {JSON.stringify(selectedTags)}
+
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx index bedcbb03045a5..668b1ccb5aa69 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { RuleStatusDropdownSandbox } from './rule_status_dropdown_sandbox'; +import { RuleTagFilterSandbox } from './rule_tag_filter_sandbox'; import { RuleStatusFilterSandbox } from './rule_status_filter_sandbox'; import { RuleTagBadgeSandbox } from './rule_tag_badge_sandbox'; @@ -14,6 +15,7 @@ export const InternalShareableComponentsSandbox: React.FC<{}> = () => { return ( <> + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts index 74b8243519428..bc4957b65b1bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts @@ -29,3 +29,5 @@ export function hasAllPrivilege(rule: InitialRule, ruleType?: RuleType): boolean export function hasReadPrivilege(rule: InitialRule, ruleType?: RuleType): boolean { return ruleType?.authorizedConsumers[rule.consumer]?.read ?? false; } +export const hasManageApiKeysCapability = (capabilities: Capabilities) => + capabilities?.management?.security?.api_keys; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts index ab8f1b565c888..5377e4269f46e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.test.ts @@ -6,7 +6,7 @@ */ import { httpServiceMock } from '@kbn/core/public/mocks'; -import { loadRuleAggregations } from './aggregate'; +import { loadRuleAggregations, loadRuleTags } from './aggregate'; const http = httpServiceMock.createStartContract(); @@ -289,4 +289,68 @@ describe('loadRuleAggregations', () => { ] `); }); + + test('should call aggregate API with tagsFilter', async () => { + const resolvedValue = { + rule_execution_status: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadRuleAggregations({ + http, + searchText: 'baz', + tagsFilter: ['a', 'b', 'c'], + }); + + expect(result).toEqual({ + ruleExecutionStatus: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }); + + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.tags:(a or b or c)", + "search": "baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); + + test('loadRuleTags should call the aggregate API with no filters', async () => { + const resolvedValue = { + rule_tags: ['a', 'b', 'c'], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadRuleTags({ + http, + }); + + expect(result).toEqual({ + ruleTags: ['a', 'b', 'c'], + }); + + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + ] + `); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts index 9548445d0df9c..1df6177443657 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts @@ -10,11 +10,16 @@ import { RuleAggregations, RuleStatus } from '../../../types'; import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; import { mapFiltersToKql } from './map_filters_to_kql'; +export interface RuleTagsAggregations { + ruleTags: string[]; +} + const rewriteBodyRes: RewriteRequestCase = ({ rule_execution_status: ruleExecutionStatus, rule_enabled_status: ruleEnabledStatus, rule_muted_status: ruleMutedStatus, rule_snoozed_status: ruleSnoozedStatus, + rule_tags: ruleTags, ...rest }: any) => ({ ...rest, @@ -22,8 +27,23 @@ const rewriteBodyRes: RewriteRequestCase = ({ ruleEnabledStatus, ruleMutedStatus, ruleSnoozedStatus, + ruleTags, +}); + +const rewriteTagsBodyRes: RewriteRequestCase = ({ + rule_tags: ruleTags, +}: any) => ({ + ruleTags, }); +// TODO: https://github.com/elastic/kibana/issues/131682 +export async function loadRuleTags({ http }: { http: HttpSetup }): Promise { + const res = await http.get>( + `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_aggregate` + ); + return rewriteTagsBodyRes(res); +} + export async function loadRuleAggregations({ http, searchText, @@ -31,6 +51,7 @@ export async function loadRuleAggregations({ actionTypesFilter, ruleExecutionStatusesFilter, ruleStatusesFilter, + tagsFilter, }: { http: HttpSetup; searchText?: string; @@ -38,12 +59,14 @@ export async function loadRuleAggregations({ actionTypesFilter?: string[]; ruleExecutionStatusesFilter?: string[]; ruleStatusesFilter?: RuleStatus[]; + tagsFilter?: string[]; }): Promise { const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, ruleExecutionStatusesFilter, ruleStatusesFilter, + tagsFilter, }); const res = await http.get>( `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_aggregate`, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index 89ede79f4a21d..c9834dd140ea4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -7,7 +7,7 @@ export { alertingFrameworkHealth } from './health'; export { mapFiltersToKql } from './map_filters_to_kql'; -export { loadRuleAggregations } from './aggregate'; +export { loadRuleAggregations, loadRuleTags } from './aggregate'; export { createRule } from './create'; export { deleteRules } from './delete'; export { disableRule, disableRules } from './disable'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts index df762d05e0eff..f67a27ef5409c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.test.ts @@ -88,6 +88,14 @@ describe('mapFiltersToKql', () => { ]); }); + test('should handle tagsFilter', () => { + expect( + mapFiltersToKql({ + tagsFilter: ['a', 'b', 'c'], + }) + ).toEqual(['alert.attributes.tags:(a or b or c)']); + }); + test('should handle typesFilter and actionTypesFilter', () => { expect( mapFiltersToKql({ @@ -100,17 +108,19 @@ describe('mapFiltersToKql', () => { ]); }); - test('should handle typesFilter, actionTypesFilter and ruleExecutionStatusesFilter', () => { + test('should handle typesFilter, actionTypesFilter, ruleExecutionStatusesFilter, and tagsFilter', () => { expect( mapFiltersToKql({ typesFilter: ['type', 'filter'], actionTypesFilter: ['action', 'types', 'filter'], ruleExecutionStatusesFilter: ['alert', 'statuses', 'filter'], + tagsFilter: ['a', 'b', 'c'], }) ).toEqual([ 'alert.attributes.alertTypeId:(type or filter)', '(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })', 'alert.attributes.executionStatus.status:(alert or statuses or filter)', + 'alert.attributes.tags:(a or b or c)', ]); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts index 0e64f5500454f..ff2a49e3a5e45 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/map_filters_to_kql.ts @@ -25,9 +25,11 @@ export const mapFiltersToKql = ({ actionTypesFilter, ruleExecutionStatusesFilter, ruleStatusesFilter, + tagsFilter, }: { typesFilter?: string[]; actionTypesFilter?: string[]; + tagsFilter?: string[]; ruleExecutionStatusesFilter?: string[]; ruleStatusesFilter?: RuleStatus[]; }): string[] => { @@ -68,6 +70,9 @@ export const mapFiltersToKql = ({ filters.push(`${enablementFilter} or ${snoozedFilter}`); } } + if (tagsFilter && tagsFilter.length) { + filters.push(`alert.attributes.tags:(${tagsFilter.join(' or ')})`); + } return filters; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts index 8adc92738b7c6..2a20c9d9469f5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.test.ts @@ -336,4 +336,42 @@ describe('loadRules', () => { ] `); }); + + test('should call find API with tagsFilter', async () => { + const resolvedValue = { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + const result = await loadRules({ + http, + tagsFilter: ['a', 'b', 'c'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.tags:(a or b or c)", + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts index bdbdcf2f094b2..6e527989cc91f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts @@ -23,6 +23,7 @@ export async function loadRules({ actionTypesFilter, ruleExecutionStatusesFilter, ruleStatusesFilter, + tagsFilter, sort = { field: 'name', direction: 'asc' }, }: { http: HttpSetup; @@ -30,6 +31,7 @@ export async function loadRules({ searchText?: string; typesFilter?: string[]; actionTypesFilter?: string[]; + tagsFilter?: string[]; ruleExecutionStatusesFilter?: string[]; ruleStatusesFilter?: RuleStatus[]; sort?: Sorting; @@ -42,6 +44,7 @@ export async function loadRules({ const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, + tagsFilter, ruleExecutionStatusesFilter, ruleStatusesFilter, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx index e41c2a73a5124..9ab31ae12402f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx @@ -32,6 +32,9 @@ export const ActionForm = suspendedComponentWithProps( export const RuleStatusDropdown = suspendedComponentWithProps( lazy(() => import('./rules_list/components/rule_status_dropdown')) ); +export const RuleTagFilter = suspendedComponentWithProps( + lazy(() => import('./rules_list/components/rule_tag_filter')) +); export const RuleStatusFilter = suspendedComponentWithProps( lazy(() => import('./rules_list/components/rule_status_filter')) ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index fe17dde8c1282..7857eb172eedb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -48,6 +48,7 @@ jest.mock('../../../lib/capabilities', () => ({ hasAllPrivilege: jest.fn(() => true), hasSaveRulesCapability: jest.fn(() => true), hasExecuteActionsCapability: jest.fn(() => true), + hasManageApiKeysCapability: jest.fn(() => true), })); const useKibanaMock = useKibana as jest.Mocked; const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -100,6 +101,26 @@ describe('rule_details', () => { ).toBeTruthy(); }); + it('renders the API key owner badge when user can manage API keys', () => { + const rule = mockRule(); + expect( + shallow( + + ).find({rule.apiKeyOwner}) + ).toBeTruthy(); + }); + + it(`doesn't render the API key owner badge when user can't manage API keys`, () => { + const { hasManageApiKeysCapability } = jest.requireMock('../../../lib/capabilities'); + hasManageApiKeysCapability.mockReturnValueOnce(false); + const rule = mockRule(); + expect( + shallow() + .find({rule.apiKeyOwner}) + .exists() + ).toBeFalsy(); + }); + it('renders the rule error banner with error message, when rule has a license error', () => { const rule = mockRule({ enabled: true, @@ -871,7 +892,7 @@ function mockRule(overloads: Partial = {}): Rule { updatedBy: null, createdAt: new Date(), updatedAt: new Date(), - apiKeyOwner: null, + apiKeyOwner: 'bob', throttle: null, notifyWhen: null, muteAll: false, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index b3363159851d0..0389e6b0d9b30 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -27,7 +27,11 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { RuleExecutionStatusErrorReasons, parseDuration } from '@kbn/alerting-plugin/common'; -import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; +import { + hasAllPrivilege, + hasExecuteActionsCapability, + hasManageApiKeysCapability, +} from '../../../lib/capabilities'; import { getAlertingSectionBreadcrumb, getRuleDetailsBreadcrumb } from '../../../lib/breadcrumb'; import { getCurrentDocTitle } from '../../../lib/doc_title'; import { @@ -310,6 +314,27 @@ export const RuleDetails: React.FunctionComponent = ({ + {hasManageApiKeysCapability(capabilities) ? ( + + + + +

+ +

+
+
+ + + {rule.apiKeyOwner} + + +
+
+ ) : null} {uniqueActions && uniqueActions.length ? ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.test.tsx new file mode 100644 index 0000000000000..a6b60b1099391 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.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 { mountWithIntl } from '@kbn/test-jest-helpers'; +import { EuiFilterButton, EuiSelectable } from '@elastic/eui'; +import { RuleTagFilter } from './rule_tag_filter'; + +const onChangeMock = jest.fn(); + +const tags = ['a', 'b', 'c', 'd', 'e', 'f']; + +describe('rule_tag_filter', () => { + beforeEach(() => { + onChangeMock.mockReset(); + }); + + it('renders correctly', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiFilterButton).exists()).toBeTruthy(); + expect(wrapper.find('.euiNotificationBadge').text()).toEqual('0'); + }); + + it('can open the popover correctly', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="ruleTagFilterSelectable"]').exists()).toBeFalsy(); + + wrapper.find(EuiFilterButton).simulate('click'); + + expect(wrapper.find('[data-test-subj="ruleTagFilterSelectable"]').exists()).toBeTruthy(); + expect(wrapper.find('li').length).toEqual(tags.length); + }); + + it('can select tags', () => { + const wrapper = mountWithIntl( + + ); + + wrapper.find(EuiFilterButton).simulate('click'); + + wrapper.find('[data-test-subj="ruleTagFilterOption-a"]').at(0).simulate('click'); + expect(onChangeMock).toHaveBeenCalledWith(['a']); + + wrapper.setProps({ + selectedTags: ['a'], + }); + + wrapper.find('[data-test-subj="ruleTagFilterOption-a"]').at(0).simulate('click'); + expect(onChangeMock).toHaveBeenCalledWith([]); + + wrapper.find('[data-test-subj="ruleTagFilterOption-b"]').at(0).simulate('click'); + expect(onChangeMock).toHaveBeenCalledWith(['a', 'b']); + }); + + it('renders selected tags even if they get deleted from the tags array', () => { + const selectedTags = ['g', 'h']; + const wrapper = mountWithIntl( + + ); + + wrapper.find(EuiFilterButton).simulate('click'); + + expect(wrapper.find(EuiSelectable).props().options.length).toEqual( + tags.length + selectedTags.length + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx new file mode 100644 index 0000000000000..6aa8aa8c69213 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useState, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiSelectable, + EuiFilterGroup, + EuiFilterButton, + EuiPopover, + EuiSelectableProps, + EuiSelectableOption, + EuiSpacer, +} from '@elastic/eui'; + +export interface RuleTagFilterProps { + tags: string[]; + selectedTags: string[]; + isLoading?: boolean; + loadingMessage?: EuiSelectableProps['loadingMessage']; + noMatchesMessage?: EuiSelectableProps['noMatchesMessage']; + emptyMessage?: EuiSelectableProps['emptyMessage']; + errorMessage?: EuiSelectableProps['errorMessage']; + dataTestSubj?: string; + selectableDataTestSubj?: string; + optionDataTestSubj?: (tag: string) => string; + buttonDataTestSubj?: string; + onChange: (tags: string[]) => void; +} + +const getOptionDataTestSubj = (tag: string) => `ruleTagFilterOption-${tag}`; + +export const RuleTagFilter = (props: RuleTagFilterProps) => { + const { + tags = [], + selectedTags = [], + isLoading = false, + loadingMessage, + noMatchesMessage, + emptyMessage, + errorMessage, + dataTestSubj = 'ruleTagFilter', + selectableDataTestSubj = 'ruleTagFilterSelectable', + optionDataTestSubj = getOptionDataTestSubj, + buttonDataTestSubj = 'ruleTagFilterButton', + onChange = () => {}, + } = props; + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const allTags = useMemo(() => { + return [...new Set([...tags, ...selectedTags])].sort(); + }, [tags, selectedTags]); + + const options: EuiSelectableOption[] = useMemo( + () => + allTags.map((tag) => ({ + label: tag, + checked: selectedTags.includes(tag) ? 'on' : undefined, + 'data-test-subj': optionDataTestSubj(tag), + })), + [allTags, selectedTags, optionDataTestSubj] + ); + + const onChangeInternal = useCallback( + (newOptions: EuiSelectableOption[]) => { + const newSelectedTags = newOptions.reduce((result, option) => { + if (option.checked === 'on') { + result = [...result, option.label]; + } + return result; + }, []); + + onChange(newSelectedTags); + }, + [onChange] + ); + + const onClosePopover = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + const renderButton = () => { + return ( + 0} + numActiveFilters={selectedTags.length} + numFilters={selectedTags.length} + onClick={onClosePopover} + > + + + ); + }; + + return ( + + + + {(list, search) => ( + <> + {search} + + {list} + + )} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { RuleTagFilter as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 52c6e2d3ed149..12e1b0f1e4a6e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -33,6 +33,7 @@ jest.mock('../../../lib/rule_api', () => ({ loadRules: jest.fn(), loadRuleTypes: jest.fn(), loadRuleAggregations: jest.fn(), + loadRuleTags: jest.fn(), alertingFrameworkHealth: jest.fn(() => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true, @@ -63,7 +64,10 @@ jest.mock('../../../lib/capabilities', () => ({ jest.mock('../../../../common/get_experimental_features', () => ({ getIsExperimentalFeatureEnabled: jest.fn(), })); -const { loadRules, loadRuleTypes, loadRuleAggregations } = + +const ruleTags = ['a', 'b', 'c', 'd']; + +const { loadRules, loadRuleTypes, loadRuleAggregations, loadRuleTags } = jest.requireMock('../../../lib/rule_api'); const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); const actionTypeRegistry = actionTypeRegistryMock.create(); @@ -395,6 +399,10 @@ describe('rules_list component with items', () => { ruleEnabledStatus: { enabled: 2, disabled: 0 }, ruleExecutionStatus: { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 }, ruleMutedStatus: { muted: 0, unmuted: 2 }, + ruleTags, + }); + loadRuleTags.mockResolvedValue({ + ruleTags, }); const ruleTypeMock: RuleTypeModel = { @@ -842,6 +850,40 @@ describe('rules_list component with items', () => { expect(loadRules.mock.calls[3][0].ruleStatusesFilter).toEqual(['enabled']); }); + + it('does not render the tag filter is the feature flag is off', async () => { + await setup(); + expect(wrapper.find('[data-test-subj="ruleTagFilter"]').exists()).toBeFalsy(); + }); + + it('renders the tag filter if the experiment is on', async () => { + (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => true); + await setup(); + expect(wrapper.find('[data-test-subj="ruleTagFilter"]').exists()).toBeTruthy(); + }); + + it('can filter by tags', async () => { + (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => true); + loadRules.mockReset(); + await setup(); + + expect(loadRules.mock.calls[0][0].tagsFilter).toEqual([]); + + wrapper.find('[data-test-subj="ruleTagFilterButton"] button').simulate('click'); + + const tagFilterListItems = wrapper.find( + '[data-test-subj="ruleTagFilterSelectable"] .euiSelectableListItem' + ); + expect(tagFilterListItems.length).toEqual(ruleTags.length); + + tagFilterListItems.at(0).simulate('click'); + + expect(loadRules.mock.calls[1][0].tagsFilter).toEqual(['a']); + + tagFilterListItems.at(1).simulate('click'); + + expect(loadRules.mock.calls[2][0].tagsFilter).toEqual(['a', 'b']); + }); }); describe('rules_list component empty with show only capability', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index b1255600b68de..a5b9661835131 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -73,6 +73,7 @@ import { RuleExecutionStatusFilter, getHealthColor } from './rule_execution_stat import { loadRules, loadRuleAggregations, + loadRuleTags, loadRuleTypes, disableRule, enableRule, @@ -99,6 +100,7 @@ import { RuleDurationFormat } from './rule_duration_format'; import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; +import { RuleTagFilter } from './rule_tag_filter'; import { RuleStatusFilter } from './rule_status_filter'; import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; @@ -158,6 +160,8 @@ export const RulesList: React.FunctionComponent = () => { const [actionTypesFilter, setActionTypesFilter] = useState([]); const [ruleExecutionStatusesFilter, setRuleExecutionStatusesFilter] = useState([]); const [ruleStatusesFilter, setRuleStatusesFilter] = useState([]); + const [tags, setTags] = useState([]); + const [tagsFilter, setTagsFilter] = useState([]); const [ruleFlyoutVisible, setRuleFlyoutVisibility] = useState(false); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); @@ -167,6 +171,7 @@ export const RulesList: React.FunctionComponent = () => { ); const [showErrors, setShowErrors] = useState(false); + const isRuleTagFilterEnabled = getIsExperimentalFeatureEnabled('ruleTagFilter'); const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter'); useEffect(() => { @@ -233,6 +238,7 @@ export const RulesList: React.FunctionComponent = () => { JSON.stringify(actionTypesFilter), JSON.stringify(ruleExecutionStatusesFilter), JSON.stringify(ruleStatusesFilter), + JSON.stringify(tagsFilter), ]); useEffect(() => { @@ -293,8 +299,10 @@ export const RulesList: React.FunctionComponent = () => { actionTypesFilter, ruleExecutionStatusesFilter, ruleStatusesFilter, + tagsFilter, sort, }); + await loadRuleTagsAggs(); await loadRuleAggs(); setRulesState({ isLoading: false, @@ -311,7 +319,8 @@ export const RulesList: React.FunctionComponent = () => { isEmpty(typesFilter) && isEmpty(actionTypesFilter) && isEmpty(ruleExecutionStatusesFilter) && - isEmpty(ruleStatusesFilter) + isEmpty(ruleStatusesFilter) && + isEmpty(tagsFilter) ); setNoData(rulesResponse.data.length === 0 && !isFilterApplied); @@ -339,6 +348,7 @@ export const RulesList: React.FunctionComponent = () => { actionTypesFilter, ruleExecutionStatusesFilter, ruleStatusesFilter, + tagsFilter, }); if (rulesAggs?.ruleExecutionStatus) { setRulesStatusesTotal(rulesAggs.ruleExecutionStatus); @@ -355,6 +365,24 @@ export const RulesList: React.FunctionComponent = () => { } } + async function loadRuleTagsAggs() { + if (!isRuleTagFilterEnabled) { + return; + } + try { + const ruleTagsAggs = await loadRuleTags({ http }); + if (ruleTagsAggs?.ruleTags) { + setTags(ruleTagsAggs.ruleTags); + } + } catch (e) { + toasts.addDanger({ + title: i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', { + defaultMessage: 'Unable to load rule tags', + }), + }); + } + } + const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, item: RuleTableItem) => { return ( { sortable: false, width: '50px', 'data-test-subj': 'rulesTableCell-tagsPopover', - render: (tags: string[], item: RuleTableItem) => { - return tags.length > 0 ? ( + render: (ruleTags: string[], item: RuleTableItem) => { + return ruleTags.length > 0 ? ( setTagPopoverOpenIndex(item.index)} onClose={() => setTagPopoverOpenIndex(-1)} /> @@ -940,6 +968,13 @@ export const RulesList: React.FunctionComponent = () => { ); }; + const getRuleTagFilter = () => { + if (isRuleTagFilterEnabled) { + return []; + } + return []; + }; + const getRuleStatusFilter = () => { if (isRuleStatusFilterEnabled) { return [ @@ -960,6 +995,7 @@ export const RulesList: React.FunctionComponent = () => { }) )} />, + ...getRuleTagFilter(), ...getRuleStatusFilter(), { rulesListDatagrid: true, internalAlertsTable: true, rulesDetailLogs: true, + ruleTagFilter: true, ruleStatusFilter: true, internalShareableComponentsSandbox: true, }, @@ -39,6 +40,10 @@ describe('getIsExperimentalFeatureEnabled', () => { expect(result).toEqual(true); + result = getIsExperimentalFeatureEnabled('ruleTagFilter'); + + expect(result).toEqual(true); + result = getIsExperimentalFeatureEnabled('ruleStatusFilter'); expect(result).toEqual(true); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_rule_tag_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_rule_tag_filter.tsx new file mode 100644 index 0000000000000..ccca277ef10ba --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_rule_tag_filter.tsx @@ -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 React from 'react'; +import { RuleTagFilter } from '../application/sections'; +import type { RuleTagFilterProps } from '../application/sections/rules_list/components/rule_tag_filter'; + +export const getRuleTagFilterLazy = (props: RuleTagFilterProps) => { + return ; +}; 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 index 9b0d122e24d4e..178c891dc3a34 100644 --- 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 @@ -7,7 +7,7 @@ import { loadIndexPatterns, - setSavedObjectsClient, + setDataViewsService, getMatchingIndices, getESIndexFields, } from './data_apis'; @@ -19,10 +19,8 @@ const http = httpServiceMock.createStartContract(); const pattern = 'test-pattern'; const indexes = ['test-index']; -const generateIndexPattern = (title: string) => ({ - attributes: { - title, - }, +const generateDataView = (title: string) => ({ + title, }); const mockIndices = { indices: ['indices1', 'indices2'] }; @@ -67,7 +65,7 @@ describe('Data API', () => { describe('index patterns', () => { beforeEach(() => { - setSavedObjectsClient({ + setDataViewsService({ find: mockFind, }); }); @@ -76,68 +74,15 @@ describe('Data API', () => { }); test('fetches the index patterns', async () => { - mockFind.mockResolvedValueOnce({ - savedObjects: [generateIndexPattern('index-1'), generateIndexPattern('index-2')], - total: 2, - }); + mockFind.mockResolvedValueOnce([generateDataView('index-1'), generateDataView('index-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(mockFind).toBeCalledWith('*test-pattern*', perPage); 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, - }); + test('returns an empty array if find requests fails', async () => { mockFind.mockRejectedValueOnce(500); const results = await loadIndexPatterns(mockPattern); 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 55b1ef4be2c74..90f80dd3dc2f0 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 @@ -6,6 +6,7 @@ */ import { HttpSetup } from '@kbn/core/public'; +import { DataViewsContract, DataView } from '@kbn/data-views-plugin/public'; const DATA_API_ROOT = '/api/triggers_actions_ui/data'; @@ -62,57 +63,25 @@ export async function getESIndexFields({ return fields; } -let savedObjectsClient: any; +type DataViewsService = Pick; +let dataViewsService: DataViewsService; -export const setSavedObjectsClient = (aSavedObjectsClient: any) => { - savedObjectsClient = aSavedObjectsClient; +export const setDataViewsService = (aDataViewsService: DataViewsService) => { + dataViewsService = aDataViewsService; }; -export const getSavedObjectsClient = () => { - return savedObjectsClient; +export const getDataViewsService = () => { + return dataViewsService; }; 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, - }); + const dataViews: DataView[] = await getDataViewsService().find(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); + return dataViews.map((dataView: DataView) => dataView.title); } catch (e) { return []; } diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index cb79a1509a6c1..003748f7d421e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -26,6 +26,7 @@ import { } from './types'; import { getAlertsTableLazy } from './common/get_alerts_table'; import { getRuleStatusDropdownLazy } from './common/get_rule_status_dropdown'; +import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; @@ -66,6 +67,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { getRuleStatusDropdown: (props) => { return getRuleStatusDropdownLazy(props); }, + getRuleTagFilter: (props) => { + return getRuleTagFilterLazy(props); + }, getRuleStatusFilter: (props) => { return getRuleStatusFilterLazy(props); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 1d9c3c07e44ca..c95dd73102fd9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -31,6 +31,7 @@ import { getAddAlertFlyoutLazy } from './common/get_add_alert_flyout'; import { getEditAlertFlyoutLazy } from './common/get_edit_alert_flyout'; import { getAlertsTableLazy } from './common/get_alerts_table'; import { getRuleStatusDropdownLazy } from './common/get_rule_status_dropdown'; +import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { ExperimentalFeaturesService } from './common/experimental_features_service'; @@ -48,6 +49,7 @@ import type { ConnectorEditFlyoutProps, AlertsTableProps, RuleStatusDropdownProps, + RuleTagFilterProps, RuleStatusFilterProps, RuleTagBadgeProps, AlertsTableConfigurationRegistry, @@ -80,6 +82,7 @@ export interface TriggersAndActionsUIPublicPluginStart { ) => ReactElement; getAlertsTable: (props: AlertsTableProps) => ReactElement; getRuleStatusDropdown: (props: RuleStatusDropdownProps) => ReactElement; + getRuleTagFilter: (props: RuleTagFilterProps) => ReactElement; getRuleStatusFilter: (props: RuleStatusFilterProps) => ReactElement; getRuleTagBadge: (props: RuleTagBadgeProps) => ReactElement; } @@ -255,6 +258,9 @@ export class Plugin getRuleStatusDropdown: (props: RuleStatusDropdownProps) => { return getRuleStatusDropdownLazy(props); }, + getRuleTagFilter: (props: RuleTagFilterProps) => { + return getRuleTagFilterLazy(props); + }, getRuleStatusFilter: (props: RuleStatusFilterProps) => { return getRuleStatusFilterLazy(props); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 25efbfb6ecc38..ef7ea7096961b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -48,6 +48,7 @@ import { import { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common'; import { TypeRegistry } from './application/type_registry'; import type { ComponentOpts as RuleStatusDropdownProps } from './application/sections/rules_list/components/rule_status_dropdown'; +import type { RuleTagFilterProps } from './application/sections/rules_list/components/rule_tag_filter'; import type { RuleStatusFilterProps } from './application/sections/rules_list/components/rule_status_filter'; import type { RuleTagBadgeProps } from './application/sections/rules_list/components/rule_tag_badge'; @@ -82,6 +83,7 @@ export type { ResolvedRule, SanitizedRule, RuleStatusDropdownProps, + RuleTagFilterProps, RuleStatusFilterProps, RuleTagBadgeProps, }; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 4525768a0fb42..82516bf4a417d 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -15,6 +15,7 @@ import { ActionType } from '@kbn/actions-plugin/server'; import { initPlugin as initPagerduty } from './pagerduty_simulation'; import { initPlugin as initSwimlane } from './swimlane_simulation'; import { initPlugin as initServiceNow } from './servicenow_simulation'; +import { initPlugin as initServiceNowOAuth } from './servicenow_oauth_simulation'; import { initPlugin as initJira } from './jira_simulation'; import { initPlugin as initResilient } from './resilient_simulation'; import { initPlugin as initSlack } from './slack_simulation'; @@ -49,6 +50,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] { allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.RESILIENT}/rest/orgs/201/incidents`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.MS_EXCHANGE}/users/test@/sendMail`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.MS_EXCHANGE}/1234567/oauth2/v2.0/token`); + allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/oauth_token.do`); return allPaths; } @@ -129,6 +131,10 @@ export class FixturePlugin implements Plugin, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + access_token: 'tokentokentoken', + expires_in: 3660, + token_type: 'Bearer', + }); + } + ); +} + +function jsonResponse( + res: KibanaResponseFactory, + code: number, + object: Record = {} +) { + return res.custom>({ body: object, statusCode: code }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/builtin_action_types/oauth_access_token.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/builtin_action_types/oauth_access_token.ts new file mode 100644 index 0000000000000..6053f78ea76a4 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/builtin_action_types/oauth_access_token.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 fs from 'fs'; +import expect from '@kbn/expect'; +import { promisify } from 'util'; +import httpProxy from 'http-proxy'; +import { KBN_KEY_PATH } from '@kbn/dev-utils'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getHttpProxyServer } from '../../../../../common/lib/get_proxy_server'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function oAuthAccessTokenTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('get oauth access token', () => { + let servicenowSimulatorURL: string = ''; + let proxyServer: httpProxy | undefined; + let testPrivateKey: string; + const configService = getService('config'); + + // need to wait for kibanaServer to settle ... + before(async () => { + testPrivateKey = await promisify(fs.readFile)(KBN_KEY_PATH, 'utf8'); + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + proxyServer = await getHttpProxyServer( + kibanaServer.resolveUrl('/'), + configService.get('kbnTestServer.serverArgs'), + () => {} + ); + }); + + after(() => { + if (proxyServer) { + proxyServer.close(); + } + }); + + it('should return 200 when requesting a JWT access token with OAuth credentials', async () => { + const { body: accessToken } = await supertest + .post('/internal/actions/connector/_oauth_access_token') + .set('kbn-xsrf', 'foo') + .send({ + type: 'jwt', + options: { + tokenUrl: `${servicenowSimulatorURL}/oauth_token.do`, + config: { + clientId: 'abc', + userIdentifierValue: 'elastic', + jwtKeyId: 'def', + }, + secrets: { + clientSecret: 'xyz', + privateKey: testPrivateKey, + }, + }, + }) + .expect(200); + + expect(accessToken).to.eql({ accessToken: 'Bearer tokentokentoken' }); + }); + + it('should return 200 when requesting a Client Credentials access token with OAuth credentials', async () => { + const { body: accessToken } = await supertest + .post('/internal/actions/connector/_oauth_access_token') + .set('kbn-xsrf', 'foo') + .send({ + type: 'client', + options: { + tokenUrl: `${kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.MS_EXCHANGE) + )}/1234567/oauth2/v2.0/token`, + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: '98765', + }, + secrets: { + clientSecret: 'xyz', + }, + }, + }) + .expect(200); + + expect(accessToken).to.eql({ accessToken: 'Bearer asdadasd' }); + }); + + it('should return 400 when given incorrect options for requesting Client Credentials access token with OAuth credentials', async () => { + await supertest + .post('/internal/actions/connector/_oauth_access_token') + .set('kbn-xsrf', 'foo') + .send({ + type: 'client', + options: { + tokenUrl: `${servicenowSimulatorURL}/oauth_token.do`, + config: { + clientId: 'abc', + userIdentifierValue: 'elastic', + jwtKeyId: 'def', + }, + secrets: { + clientSecret: 'xyz', + privateKey: testPrivateKey, + }, + }, + }) + .expect(400); + }); + + it('should return 400 when given incorrect options for requesting JWT access token with OAuth credentials', async () => { + await supertest + .post('/internal/actions/connector/_oauth_access_token') + .set('kbn-xsrf', 'foo') + .send({ + type: 'jwt', + options: { + tokenUrl: `${kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.MS_EXCHANGE) + )}/1234567/oauth2/v2.0/token`, + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: '98765', + }, + secrets: { + clientSecret: 'xyz', + }, + }, + }) + .expect(400); + }); + + it('should return 400 when token url not included in allowlist', async () => { + const { body } = await supertest + .post('/internal/actions/connector/_oauth_access_token') + .set('kbn-xsrf', 'foo') + .send({ + type: 'jwt', + options: { + tokenUrl: `https://servicenow.nonexistent.com/oauth_token.do`, + config: { + clientId: 'abc', + userIdentifierValue: 'elastic', + jwtKeyId: 'def', + }, + secrets: { + clientSecret: 'xyz', + privateKey: testPrivateKey, + }, + }, + }); + + expect(body.statusCode).to.equal(400); + expect(body.message).to.equal( + `target url "https://servicenow.nonexistent.com/oauth_token.do" is not added to the Kibana config xpack.actions.allowedHosts` + ); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 6d1ecdbee566c..9c1b6a4fd8299 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -25,6 +25,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./builtin_action_types/pagerduty')); loadTestFile(require.resolve('./builtin_action_types/swimlane')); loadTestFile(require.resolve('./builtin_action_types/server_log')); + loadTestFile(require.resolve('./builtin_action_types/oauth_access_token')); loadTestFile(require.resolve('./builtin_action_types/servicenow_itsm')); loadTestFile(require.resolve('./builtin_action_types/servicenow_sir')); loadTestFile(require.resolve('./builtin_action_types/servicenow_itom')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts index 24c6427fdf2f6..c6330e660aa24 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts @@ -131,7 +131,7 @@ export default function ({ getService }: FtrProviderContext) { expect(response.body).to.eql({ connector_id: createdAction.id, status: 'error', - message: 'an error occurred while running the action executor', + message: 'an error occurred while running the action', service_message: `expected failure for ${ES_TEST_INDEX_NAME} ${reference}`, retry: false, }); @@ -142,7 +142,7 @@ export default function ({ getService }: FtrProviderContext) { actionTypeId: 'test.failing', outcome: 'failure', message: `action execution failure: test.failing:${createdAction.id}: failing action`, - errorMessage: `an error occurred while running the action executor: expected failure for .kibana-alerting-test-data actions-failure-1:space1`, + errorMessage: `an error occurred while running the action: expected failure for .kibana-alerting-test-data actions-failure-1:space1`, }); }); @@ -325,7 +325,7 @@ export default function ({ getService }: FtrProviderContext) { expect(response.body).to.eql({ actionId: createdAction.id, status: 'error', - message: 'an error occurred while running the action executor', + message: 'an error occurred while running the action', serviceMessage: `expected failure for ${ES_TEST_INDEX_NAME} ${reference}`, retry: false, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts index 588e7132f268c..4424175e36953 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts @@ -44,6 +44,7 @@ export default function createAggregateTests({ getService }: FtrProviderContext) rule_snoozed_status: { snoozed: 0, }, + rule_tags: [], }); }); @@ -122,6 +123,7 @@ export default function createAggregateTests({ getService }: FtrProviderContext) rule_snoozed_status: { snoozed: 0, }, + rule_tags: ['foo'], }); }); @@ -137,6 +139,7 @@ export default function createAggregateTests({ getService }: FtrProviderContext) { rule_type_id: 'test.noop', schedule: { interval: '1s' }, + tags: ['a', 'b'], }, 'ok' ); @@ -153,6 +156,7 @@ export default function createAggregateTests({ getService }: FtrProviderContext) params: { pattern: { instance: new Array(100).fill(true) }, }, + tags: ['a', 'c', 'f'], }, 'active' ); @@ -166,6 +170,7 @@ export default function createAggregateTests({ getService }: FtrProviderContext) { rule_type_id: 'test.throw', schedule: { interval: '1s' }, + tags: ['b', 'c', 'd'], }, 'error' ); @@ -202,6 +207,7 @@ export default function createAggregateTests({ getService }: FtrProviderContext) ruleSnoozedStatus: { snoozed: 0, }, + ruleTags: ['a', 'b', 'c', 'd', 'f'], }); }); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts index 5e9387ad4f0f9..6ae75c71d3bcf 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts @@ -419,7 +419,7 @@ export default function createGetExecutionLogTests({ getService }: FtrProviderCo for (const errors of response.body.errors) { expect(errors.type).to.equal('actions'); expect(errors.message).to.equal( - `action execution failure: test.throw:${createdConnector.id}: connector that throws - an error occurred while running the action executor: this action is intended to fail` + `action execution failure: test.throw:${createdConnector.id}: connector that throws - an error occurred while running the action: this action is intended to fail` ); } }); diff --git a/x-pack/test/functional/config.base.js b/x-pack/test/functional/config.base.js index b5b671a54744e..a4d73d40e2d4d 100644 --- a/x-pack/test/functional/config.base.js +++ b/x-pack/test/functional/config.base.js @@ -51,6 +51,7 @@ export default async function ({ readConfigFile }) { '--xpack.encryptedSavedObjects.encryptionKey="DkdXazszSCYexXqz4YktBGHCRkV6hyNK"', '--xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled=true', '--savedObjects.maxImportPayloadBytes=10485760', // for OSS test management/_import_objects, + '--uiSettings.overrides.observability:enableNewSyntheticsView=true', // for OSS test management/_import_objects, ], }, uiSettings: { diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index 5f6c4501476bf..a036c25e3d657 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -31,8 +31,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('rulesTab'); } - // FLAKY: https://github.com/elastic/kibana/issues/131535 - describe.skip('rules list', function () { + describe('rules list', function () { + const assertRulesLength = async (length: number) => { + return await retry.try(async () => { + const rules = await pageObjects.triggersActionsUI.getAlertsList(); + expect(rules.length).to.equal(length); + }); + }; + before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('rulesTab'); @@ -604,13 +610,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should filter alerts by the rule status', async () => { - const assertRulesLength = async (length: number) => { - return await retry.try(async () => { - const rules = await pageObjects.triggersActionsUI.getAlertsList(); - expect(rules.length).to.equal(length); - }); - }; - // Enabled alert await createAlert({ supertest, @@ -640,25 +639,94 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Select enabled await testSubjects.click('ruleStatusFilterButton'); await testSubjects.click('ruleStatusFilterOption-enabled'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); await assertRulesLength(1); // Select disabled await testSubjects.click('ruleStatusFilterOption-enabled'); await testSubjects.click('ruleStatusFilterOption-disabled'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); await assertRulesLength(1); // Select snoozed await testSubjects.click('ruleStatusFilterOption-disabled'); await testSubjects.click('ruleStatusFilterOption-snoozed'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); await assertRulesLength(1); // Select disabled and snoozed await testSubjects.click('ruleStatusFilterOption-disabled'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); await assertRulesLength(2); // Select all 3 await testSubjects.click('ruleStatusFilterOption-enabled'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); await assertRulesLength(3); }); + + it('should filter alerts by the tag', async () => { + await createAlert({ + supertest, + objectRemover, + overwrites: { + tags: ['a'], + }, + }); + await createAlert({ + supertest, + objectRemover, + overwrites: { + tags: ['b'], + }, + }); + await createAlert({ + supertest, + objectRemover, + overwrites: { + tags: ['a', 'b'], + }, + }); + await createAlert({ + supertest, + objectRemover, + overwrites: { + tags: ['b', 'c'], + }, + }); + await createAlert({ + supertest, + objectRemover, + overwrites: { + tags: ['c'], + }, + }); + + await refreshAlertsList(); + await testSubjects.click('ruleTagFilter'); + + // Select a -> selected: a + await testSubjects.click('ruleTagFilterOption-a'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); + await assertRulesLength(2); + + // Unselect a -> selected: none + await testSubjects.click('ruleTagFilterOption-a'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); + await assertRulesLength(5); + + // Select a, b -> selected: a, b + await testSubjects.click('ruleTagFilterOption-a'); + await testSubjects.click('ruleTagFilterOption-b'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); + await assertRulesLength(4); + + // Unselect a, b, select c -> selected: c + await testSubjects.click('ruleTagFilterOption-a'); + await testSubjects.click('ruleTagFilterOption-b'); + await testSubjects.click('ruleTagFilterOption-c'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); + await assertRulesLength(2); + }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 1127a7423a3aa..56dfa17ef6268 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -168,6 +168,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const ruleType = await pageObjects.ruleDetailsUI.getRuleType(); expect(ruleType).to.be(`Always Firing`); + const owner = await pageObjects.ruleDetailsUI.getAPIKeyOwner(); + expect(owner).to.be('elastic'); + const { connectorType } = await pageObjects.ruleDetailsUI.getActionsLabels(); expect(connectorType).to.be(`Slack`); }); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts index 73b084c2ce0e4..3b2803e17e184 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -16,6 +16,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./connectors')); loadTestFile(require.resolve('./alerts_table')); loadTestFile(require.resolve('./rule_status_dropdown')); + loadTestFile(require.resolve('./rule_tag_filter')); loadTestFile(require.resolve('./rule_status_filter')); loadTestFile(require.resolve('./rule_tag_badge')); }); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts new file mode 100644 index 0000000000000..77d57e2819db5 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const esArchiver = getService('esArchiver'); + + describe('Rule tag filter', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'triggersActions', + '/__components_sandbox' + ); + }); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts'); + }); + + it('shoud load from shareable lazy loader', async () => { + await testSubjects.find('ruleTagFilter'); + const exists = await testSubjects.exists('ruleTagFilter'); + expect(exists).to.be(true); + }); + + it('should allow tag filters to be selected', async () => { + let badge = await find.byCssSelector('.euiFilterButton__notification'); + expect(await badge.getVisibleText()).to.be('0'); + + await testSubjects.click('ruleTagFilter'); + await testSubjects.click('ruleTagFilterOption-tag1'); + + badge = await find.byCssSelector('.euiFilterButton__notification'); + expect(await badge.getVisibleText()).to.be('1'); + + await testSubjects.click('ruleTagFilterOption-tag2'); + + badge = await find.byCssSelector('.euiFilterButton__notification'); + expect(await badge.getVisibleText()).to.be('2'); + + await testSubjects.click('ruleTagFilterOption-tag1'); + expect(await badge.getVisibleText()).to.be('1'); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index 4872d2fd6fa38..62984ace526fb 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -76,6 +76,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.trigger_actions_ui.enableExperimental=${JSON.stringify([ 'internalAlertsTable', 'internalShareableComponentsSandbox', + 'ruleTagFilter', 'ruleStatusFilter', ])}`, `--xpack.alerting.rules.minimumScheduleInterval.value="2s"`, diff --git a/x-pack/test/functional_with_es_ssl/page_objects/rule_details.ts b/x-pack/test/functional_with_es_ssl/page_objects/rule_details.ts index 01d7c24be2f41..cff396276eefd 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/rule_details.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/rule_details.ts @@ -21,6 +21,9 @@ export function RuleDetailsPageProvider({ getService }: FtrProviderContext) { async getRuleType() { return await testSubjects.getVisibleText('ruleTypeLabel'); }, + async getAPIKeyOwner() { + return await testSubjects.getVisibleText('apiKeyOwnerLabel'); + }, async getActionsLabels() { return { connectorType: await testSubjects.getVisibleText('actionTypeLabel'),