diff --git a/.buildkite/pipelines/pipeline.kibana-serverless-release.yaml b/.buildkite/pipelines/pipeline.kibana-serverless-release.yaml index 0eec18471e2be..9c3e235c5564a 100644 --- a/.buildkite/pipelines/pipeline.kibana-serverless-release.yaml +++ b/.buildkite/pipelines/pipeline.kibana-serverless-release.yaml @@ -10,3 +10,6 @@ steps: SERVICE: kibana-controller NAMESPACE: kibana-ci IMAGE_NAME: kibana-serverless + +notify: + - slack: "#kibana-mission-control" diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index e36a1434bee88..60c26c8bce2fb 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -140,7 +140,7 @@ steps: queue: n2-4-spot depends_on: build timeout_in_minutes: 40 - parallelism: 12 + parallelism: 2 soft_fail: true retry: automatic: @@ -166,7 +166,7 @@ steps: queue: n2-4-spot depends_on: build timeout_in_minutes: 40 - parallelism: 6 + parallelism: 2 soft_fail: true retry: automatic: diff --git a/.buildkite/pipelines/serverless.yml b/.buildkite/pipelines/serverless.yml index e7b5dbc299722..0a9cc038088ff 100644 --- a/.buildkite/pipelines/serverless.yml +++ b/.buildkite/pipelines/serverless.yml @@ -105,32 +105,33 @@ steps: queue: n2-4-spot depends_on: build timeout_in_minutes: 40 - parallelism: 12 + parallelism: 2 retry: automatic: - exit_status: '*' limit: 1 - - command: .buildkite/scripts/steps/functional/security_serverless_explore.sh - label: 'Serverless Explore - Security Solution Cypress Tests' + - command: .buildkite/scripts/steps/functional/security_serverless_investigations.sh + label: 'Serverless Security Investigations Cypress Tests' agents: queue: n2-4-spot depends_on: build - timeout_in_minutes: 60 - parallelism: 4 + timeout_in_minutes: 120 + parallelism: 2 retry: automatic: - exit_status: '*' limit: 1 - - command: .buildkite/scripts/steps/functional/security_serverless_investigations.sh - label: 'Serverless Investigations - Security Solution Cypress Tests' + - command: .buildkite/scripts/steps/functional/security_serverless_explore.sh + label: 'Serverless Security Explore Cypress Tests' agents: queue: n2-4-spot depends_on: build - timeout_in_minutes: 120 - parallelism: 6 + timeout_in_minutes: 60 + parallelism: 2 retry: automatic: - exit_status: '*' limit: 1 + diff --git a/.buildkite/scripts/lifecycle/post_command.sh b/.buildkite/scripts/lifecycle/post_command.sh index 77f11120a4abe..01877bedbef8c 100755 --- a/.buildkite/scripts/lifecycle/post_command.sh +++ b/.buildkite/scripts/lifecycle/post_command.sh @@ -31,8 +31,10 @@ if [[ "$IS_TEST_EXECUTION_STEP" == "true" ]]; then buildkite-agent artifact upload '.es/**/*.hprof' buildkite-agent artifact upload 'data/es_debug_*.tar.gz' - echo "--- Run Failed Test Reporter" - node scripts/report_failed_tests --build-url="${BUILDKITE_BUILD_URL}#${BUILDKITE_JOB_ID}" 'target/junit/**/*.xml' + if [[ $BUILDKITE_COMMAND_EXIT_STATUS -ne 0 ]]; then + echo "--- Run Failed Test Reporter" + node scripts/report_failed_tests --build-url="${BUILDKITE_BUILD_URL}#${BUILDKITE_JOB_ID}" 'target/junit/**/*.xml' + fi if [[ -d 'target/test_failures' ]]; then buildkite-agent artifact upload 'target/test_failures/**/*' diff --git a/config/serverless.yml b/config/serverless.yml index 6f5235ffdedd2..31791f5183d4a 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -119,6 +119,7 @@ xpack.alerting.rules.run.ruleTypeOverrides: xpack.alerting.rules.minimumScheduleInterval.enforce: true xpack.alerting.rules.maxScheduledPerMinute: 400 xpack.actions.run.maxAttempts: 10 +xpack.actions.queued.max: 10000 # Disables ESQL in advanced settings (hides it from the UI) uiSettings: diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c863c5d5e837f..a009b8bdc7b41 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -227,6 +227,9 @@ xpack.actions.run: maxAttempts: 5 -- +`xpack.actions.queued.max` {ess-icon}:: +Specifies the maximum number of actions that can be queued. Default: 1000000 + [float] [[preconfigured-connector-settings]] === Preconfigured connector settings diff --git a/docs/user/alerting/create-and-manage-rules.asciidoc b/docs/user/alerting/create-and-manage-rules.asciidoc index 31c43346ef308..aa72b65944462 100644 --- a/docs/user/alerting/create-and-manage-rules.asciidoc +++ b/docs/user/alerting/create-and-manage-rules.asciidoc @@ -1,4 +1,3 @@ -[role="xpack"] [[create-and-manage-rules]] == Create and manage rules :frontmatter-description: Set up alerting in the {kib} {stack-manage-app} app and manage your rules. @@ -52,10 +51,11 @@ For more details, refer to the https://registry.terraform.io/providers/elastic/e Depending on the {kib} app and context, you might be prompted to choose the type of rule to create. Some apps will preselect the type of rule for you. -Each rule type provides its own way of defining the conditions to detect, but an expression formed by a series of clauses is a common pattern. For example, in a metric threshold rule, the `WHEN` clause enables you to select an aggregation operation to apply to a numeric field. +Each rule type provides its own way of defining the conditions to detect, but an expression formed by a series of clauses is a common pattern. +For example, in an {es} query rule, you specify an index, a query, and a threshold, which uses a metric aggregation operation (`count`, `average`, `max`, `min`, or `sum`): [role="screenshot"] -image::images/rule-flyout-rule-conditions.png[UI for defining rule conditions on a metric threshold rule,500] +image::images/es-query-rule-conditions.png[UI for defining rule conditions in an {es} query rule,500] // NOTE: This is an autogenerated screenshot. Do not edit it directly. All rules must have a check interval, which defines how often to evaluate the rule conditions. Checks are queued; they run as close to the defined value as capacity allows. @@ -71,15 +71,16 @@ conditions are met and when they are no longer met. Each action uses a connector, which provides connection information for a {kib} service or third party integration, depending on where you want to send the notifications. If no connectors exist, click **Add connector** to create one. -After you select a connector, set the action frequency. If the rule type supports alert summaries, you can choose to create a summary of alerts on each check interval or on a custom interval. For example, if you create a metrics threshold rule, you can send email notifications that summarize the new, ongoing, and recovered alerts each hour: +After you select a connector, set the action frequency. If the rule type supports alert summaries, you can choose to create a summary of alerts on each check interval or on a custom interval. +For example, if you create an {es} query rule, you can send notifications that summarize the new, ongoing, and recovered alerts on a custom interval: [role="screenshot"] -image::images/action-alert-summary.png[UI for defining rule conditions on a metric threshold rule,500] +image::images/es-query-rule-action-summary.png[UI for defining alert summary action in an {es} query rule,500] // NOTE: This is an autogenerated screenshot. Do not edit it directly. [NOTE] ==== -* The rules that support alert summaries, such as this metric threshold rule, enable you to further refine when actions run by adding time frame and query filters. +* Some rules that support alert summaries, such as metric threshold rules, enable you to further refine when actions run by adding time frame and query filters. * If you choose a custom action interval, it cannot be shorter than the rule's check interval. ==== @@ -87,10 +88,10 @@ Alternatively, you can set the action frequency such that the action runs for ea If the rule type does not support alert summaries, this is your only available option. You must choose when the action runs (for example, at each check interval, only when the alert status changes, or at a custom action interval). You must also choose an action group, which affects whether the action runs. Each rule type has a specific set of valid action groups. -For example, you can set *Run when* to `Alert`, `Warning`, `No data`, or `Recovered` for the metric threshold rule: +For example, you can set *Run when* to `Query matched` or `Recovered` for the {es} query rule: [role="screenshot"] -image::images/rule-flyout-action-details.png[UI for defining an email action,500] +image::images/es-query-rule-recovery-action.png[UI for defining a recovery action,500] // NOTE: This is an autogenerated screenshot. Do not edit it directly. Each connector enables different action properties. For example, an email connector enables you to set the recipients, the subject, and a message body in markdown format. For more information about connectors, refer to <>. @@ -123,7 +124,7 @@ You can pass rule values to an action at the time a condition is detected. To view the list of variables available for your rule, click the "add rule variable" button: [role="screenshot"] -image::images/rule-flyout-action-variables.png[Passing rule values to an action,500] +image::images/es-query-rule-action-variables.png[Passing rule values to an action,500] // NOTE: This is an autogenerated screenshot. Do not edit it directly. For more information about common action variables, refer to <>. diff --git a/docs/user/alerting/images/action-alert-summary.png b/docs/user/alerting/images/action-alert-summary.png deleted file mode 100644 index 038e346a72725..0000000000000 Binary files a/docs/user/alerting/images/action-alert-summary.png and /dev/null differ diff --git a/docs/user/alerting/images/es-query-rule-action-query-matched.png b/docs/user/alerting/images/es-query-rule-action-query-matched.png new file mode 100644 index 0000000000000..cafa6e82e2ab2 Binary files /dev/null and b/docs/user/alerting/images/es-query-rule-action-query-matched.png differ diff --git a/docs/user/alerting/images/es-query-rule-action-summary.png b/docs/user/alerting/images/es-query-rule-action-summary.png new file mode 100644 index 0000000000000..1e098d77fc5f3 Binary files /dev/null and b/docs/user/alerting/images/es-query-rule-action-summary.png differ diff --git a/docs/user/alerting/images/es-query-rule-action-variables.png b/docs/user/alerting/images/es-query-rule-action-variables.png new file mode 100644 index 0000000000000..685f455b986ab Binary files /dev/null and b/docs/user/alerting/images/es-query-rule-action-variables.png differ diff --git a/docs/user/alerting/images/es-query-rule-conditions.png b/docs/user/alerting/images/es-query-rule-conditions.png new file mode 100644 index 0000000000000..c9572afc3dc26 Binary files /dev/null and b/docs/user/alerting/images/es-query-rule-conditions.png differ diff --git a/docs/user/alerting/images/es-query-rule-recovery-action.png b/docs/user/alerting/images/es-query-rule-recovery-action.png new file mode 100644 index 0000000000000..a7c1243c1d0f8 Binary files /dev/null and b/docs/user/alerting/images/es-query-rule-recovery-action.png differ diff --git a/docs/user/alerting/images/rule-flyout-action-details.png b/docs/user/alerting/images/rule-flyout-action-details.png deleted file mode 100644 index b7d7050dd9cab..0000000000000 Binary files a/docs/user/alerting/images/rule-flyout-action-details.png and /dev/null differ diff --git a/docs/user/alerting/images/rule-flyout-action-variables.png b/docs/user/alerting/images/rule-flyout-action-variables.png deleted file mode 100644 index ef74f4dc179d6..0000000000000 Binary files a/docs/user/alerting/images/rule-flyout-action-variables.png and /dev/null differ diff --git a/docs/user/alerting/images/rule-flyout-rule-conditions.png b/docs/user/alerting/images/rule-flyout-rule-conditions.png deleted file mode 100644 index 07a1587ab8683..0000000000000 Binary files a/docs/user/alerting/images/rule-flyout-rule-conditions.png and /dev/null differ diff --git a/docs/user/alerting/images/rule-types-es-query-example-action-variable.png b/docs/user/alerting/images/rule-types-es-query-example-action-variable.png deleted file mode 100644 index 8cb5c07543ddc..0000000000000 Binary files a/docs/user/alerting/images/rule-types-es-query-example-action-variable.png and /dev/null differ diff --git a/docs/user/alerting/rule-types/es-query.asciidoc b/docs/user/alerting/rule-types/es-query.asciidoc index f8310eefa790f..f1a391a9e87be 100644 --- a/docs/user/alerting/rule-types/es-query.asciidoc +++ b/docs/user/alerting/rule-types/es-query.asciidoc @@ -1,7 +1,7 @@ [[rule-type-es-query]] == {es} query -:frontmatter-description: An {es} query rule generates alerts when your query meets a threshold. +:frontmatter-description: Create an {es} query rule, which generates alerts when your query meets a threshold. :frontmatter-tags-products: [kibana,alerting] :frontmatter-tags-content-type: [overview] :frontmatter-tags-user-goals: [analyze] @@ -10,18 +10,15 @@ The {es} query rule type runs a user-configured query, compares the number of matches to a configured threshold, and schedules actions to run when the threshold condition is met. - [float] === Create the rule -Fill in the name and optional tags, then select -*{es} query*. {es} query rule can be defined using KQL/Lucene or Query DSL. +In *{stack-manage-app}* > *{rules-ui}*, click *Create rule*, fill in the name and optional tags, then select *{es} query*. +An {es} query rule can be defined using KQL/Lucene or Query DSL. [float] === Define the conditions -Define properties to detect the condition. - [role="screenshot"] image::user/alerting/images/rule-types-es-query-conditions.png[Define the condition to detect] // NOTE: This is an autogenerated screenshot. Do not edit it directly. @@ -46,13 +43,48 @@ Exclude matches from previous run:: Turn on to avoid alert duplication by excluding documents that have already been detected by the previous rule run. This option is not available when a grouping field is specified. +[float] +=== Add actions + +You can optionally send notifications when the rule conditions are met and when they are no longer met. +In particular, this rule type supports: + +* alert summaries +* actions that run when the query is matched +* recovery actions that run when the rule conditions are no longer met + +For each action, you must choose a connector, which provides connection information for a {kib} service or third party integration. For more information about all the supported connectors, go to <>. + +After you select a connector, you must set the action frequency. +You can choose to create a summary of alerts on each check interval or on a custom interval. +For example, send email notifications that summarize the new, ongoing, and recovered alerts at a custom interval: + +[role="screenshot"] +image::images/es-query-rule-action-summary.png[UI for defining alert summary action in an {es} query rule] +// NOTE: This is an autogenerated screenshot. Do not edit it directly. + +Alternatively, you can set the action frequency such that actions run for each alert. +Choose how often the action runs (at each check interval, only when the alert status changes, or at a custom action interval). +You must also choose an action group, which indicates whether the action runs when the query is matched or when the alert is recovered. +For example: + +[role="screenshot"] +image::images/es-query-rule-action-query-matched.png[UI for defining a recovery action] +// NOTE: This is an autogenerated screenshot. Do not edit it directly. + [float] === Add action variables -<> to run when the rule condition -is met. The following variables are specific to the {es} query rule. You can -also specify -<>. +You can pass rule values to an action to provide contextual details. +To view the list of variables available for each action, click the "add rule variable" button. +For example: + +[role="screenshot"] +image::images/es-query-rule-action-variables.png[Passing rule values to an action] +// NOTE: This is an autogenerated screenshot. Do not edit it directly. + +The following variables are specific to the {es} query rule. +You can also specify <>. `context.title`:: A preconstructed title for the rule. Example: `rule term match alert query matched`. @@ -76,17 +108,26 @@ Example: `2022-02-03T20:29:27.732Z`. `context.hits`:: The most recent documents that matched the query. Using the https://mustache.github.io/[Mustache] template array syntax, you can iterate -over these hits to get values from the ES documents into your actions. -+ -[role="screenshot"] -image::images/rule-types-es-query-example-action-variable.png[Iterate over hits using Mustache template syntax] +over these hits to get values from the {es} documents into your actions. +For example, the message in an email connector action might contain: + +-- +[source,sh] +-------------------------------------------------- +Elasticsearch query rule '{{rule.name}}' is active: + +{{#context.hits}} +Document with {{_id}} and hostname {{_source.host.name}} has +{{_source.system.memory.actual.free}} bytes of memory free +{{/context.hits}} +-------------------------------------------------- + The documents returned by `context.hits` include the {ref}/mapping-source-field.html[`_source`] field. If the {es} query search API's {ref}/search-fields.html#search-fields-param[`fields`] parameter is used, documents will also return the `fields` field, -which can be used to access any runtime fields defined by the {ref}/runtime-search-request.html[`runtime_mappings`] parameter as the following example shows: -+ --- -[source] +which can be used to access any runtime fields defined by the {ref}/runtime-search-request.html[`runtime_mappings`] parameter. +For example: + +[source,sh] -------------------------------------------------- {{#context.hits}} timestamp: {{_source.@timestamp}} @@ -95,13 +136,12 @@ day of the week: {{fields.day_of_week}} <1> -------------------------------------------------- // NOTCONSOLE <1> The `fields` parameter here is used to access the `day_of_week` runtime field. --- -+ + As the {ref}/search-fields.html#search-fields-response[`fields`] response always returns an array of values for each field, -the https://mustache.github.io/[Mustache] template array syntax is used to iterate over these values in your actions as the following example shows: -+ --- -[source] +the https://mustache.github.io/[Mustache] template array syntax is used to iterate over these values in your actions. +For example: + +[source,sh] -------------------------------------------------- {{#context.hits}} Labels: diff --git a/packages/kbn-config-schema/index.ts b/packages/kbn-config-schema/index.ts index 88666aac94c7e..57c61c125ec73 100644 --- a/packages/kbn-config-schema/index.ts +++ b/packages/kbn-config-schema/index.ts @@ -140,6 +140,22 @@ function recordOf( return new RecordOfType(keyType, valueType, options); } +function oneOf( + types: [ + Type, + Type, + Type, + Type, + Type, + Type, + Type, + Type, + Type, + Type, + Type + ], + options?: TypeOptions +): Type; function oneOf( types: [Type, Type, Type, Type, Type, Type, Type, Type, Type, Type], options?: TypeOptions diff --git a/packages/kbn-es/src/utils/docker.test.ts b/packages/kbn-es/src/utils/docker.test.ts index 35696e1f91af8..905f51e2d67c3 100644 --- a/packages/kbn-es/src/utils/docker.test.ts +++ b/packages/kbn-es/src/utils/docker.test.ts @@ -476,15 +476,18 @@ describe('runServerlessCluster()', () => { [baseEsPath]: {}, }); execa.mockImplementation(() => Promise.resolve({ stdout: '' })); - const info = jest.fn(); - jest.requireMock('@elastic/elasticsearch').Client.mockImplementation(() => ({ info })); + const health = jest.fn(); + jest + .requireMock('@elastic/elasticsearch') + .Client.mockImplementation(() => ({ cluster: { health } })); - info.mockImplementationOnce(() => Promise.reject()); // first call fails - info.mockImplementationOnce(() => Promise.resolve()); // then succeeds + health.mockImplementationOnce(() => Promise.reject()); // first call fails + health.mockImplementationOnce(() => Promise.resolve({ status: 'red' })); // second call return wrong status + health.mockImplementationOnce(() => Promise.resolve({ status: 'green' })); // then succeeds await runServerlessCluster(log, { basePath: baseEsPath, waitForReady: true }); - expect(info).toHaveBeenCalledTimes(2); - }); + expect(health).toHaveBeenCalledTimes(3); + }, 10000); }); }); diff --git a/packages/kbn-es/src/utils/docker.ts b/packages/kbn-es/src/utils/docker.ts index 7734cddc3c614..085854e4ecb40 100644 --- a/packages/kbn-es/src/utils/docker.ts +++ b/packages/kbn-es/src/utils/docker.ts @@ -35,6 +35,7 @@ import { ELASTIC_SERVERLESS_SUPERUSER_PASSWORD, } from './ess_file_realm'; import { SYSTEM_INDICES_SUPERUSER } from './native_realm'; +import { waitUntilClusterReady } from './wait_until_cluster_ready'; interface BaseOptions { tag?: string; @@ -560,25 +561,6 @@ function getESClient(clientOptions: ClientOptions): Client { }); } -const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); -async function waitUntilClusterReady( - clientOptions: ClientOptions, - timeoutMs = 60 * 1000 -): Promise { - const started = Date.now(); - const client = getESClient(clientOptions); - - while (started + timeoutMs > Date.now()) { - try { - await client.info(); - break; - } catch (e) { - await delay(1000); - /* trap to continue */ - } - } -} - /** * Runs an ES Serverless Cluster through Docker */ @@ -636,7 +618,7 @@ export async function runServerlessCluster(log: ToolingLog, options: ServerlessO portCmd[1].lastIndexOf(':') )}`; - await waitUntilClusterReady({ + const client = getESClient({ node: esNodeUrl, ...(options.ssl ? { @@ -654,6 +636,7 @@ export async function runServerlessCluster(log: ToolingLog, options: ServerlessO } : {}), }); + await waitUntilClusterReady({ client, log }); log.success('ES is ready'); } diff --git a/packages/kbn-es/src/utils/wait_until_cluster_ready.ts b/packages/kbn-es/src/utils/wait_until_cluster_ready.ts new file mode 100644 index 0000000000000..b8611253f89d3 --- /dev/null +++ b/packages/kbn-es/src/utils/wait_until_cluster_ready.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { Client } from '@elastic/elasticsearch'; +import { ToolingLog } from '@kbn/tooling-log'; +const DEFAULT_READY_TIMEOUT = 60 * 1000; // 1 minute + +export interface WaitOptions { + client: Client; + log: ToolingLog; + readyTimeout?: number; +} + +/** + * General method to wait for the ES cluster status to be green + */ +export async function waitUntilClusterReady({ + client, + log, + readyTimeout = DEFAULT_READY_TIMEOUT, +}: WaitOptions) { + let attempt = 0; + const start = Date.now(); + + log.info('waiting for ES cluster to report a green status'); + + while (true) { + attempt += 1; + + try { + const resp = await client.cluster.health(); + if (resp.status === 'green') { + return; + } + + throw new Error(`not ready, cluster health is ${resp.status}`); + } catch (error) { + const timeSinceStart = Date.now() - start; + if (timeSinceStart > readyTimeout) { + const sec = readyTimeout / 1000; + throw new Error(`ES cluster failed to come online with the ${sec} second timeout`); + } + + if (error?.message?.startsWith('not ready,')) { + if (timeSinceStart > 10_000) { + log.warning(error.message); + } + } else { + log.warning( + `waiting for ES cluster to come online, attempt ${attempt} failed with: ${error?.message}` + ); + } + + const waitSec = attempt * 1.5; + await new Promise((resolve) => setTimeout(resolve, waitSec * 1000)); + } + } +} diff --git a/packages/kbn-search-api-panels/index.tsx b/packages/kbn-search-api-panels/index.tsx index feb26ae501de7..5fa35ac35ef68 100644 --- a/packages/kbn-search-api-panels/index.tsx +++ b/packages/kbn-search-api-panels/index.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSpacer, EuiImage, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { AuthenticatedUser } from '@kbn/security-plugin/common'; export * from './components/code_box'; export * from './components/github_link'; @@ -24,19 +25,14 @@ export * from './types'; export * from './utils'; export interface WelcomeBannerProps { - userProfile: { - user: { - full_name?: string; - username?: string; - }; - }; + user?: AuthenticatedUser; assetBasePath?: string; image?: string; showDescription?: boolean; } export const WelcomeBanner: React.FC = ({ - userProfile, + user, assetBasePath, image, showDescription = true, @@ -54,16 +50,18 @@ export const WelcomeBanner: React.FC = ({ - - -

- {i18n.translate('searchApiPanels.welcomeBanner.header.greeting.title', { - defaultMessage: 'Hi {name}!', - values: { name: userProfile?.user?.full_name || userProfile?.user?.username }, - })} -

-
-
+ {Boolean(user) && ( + + +

+ {i18n.translate('searchApiPanels.welcomeBanner.header.greeting.title', { + defaultMessage: 'Hi {name}!', + values: { name: user?.full_name || user.username }, + })} +

+
+
+ )} {showDescription && ( diff --git a/packages/kbn-search-api-panels/tsconfig.json b/packages/kbn-search-api-panels/tsconfig.json index 82fd44f2cbb32..768b73f8cef46 100644 --- a/packages/kbn-search-api-panels/tsconfig.json +++ b/packages/kbn-search-api-panels/tsconfig.json @@ -20,6 +20,7 @@ "@kbn/core-http-browser", "@kbn/core-application-browser", "@kbn/share-plugin", - "@kbn/i18n-react" + "@kbn/i18n-react", + "@kbn/security-plugin" ] } diff --git a/packages/kbn-securitysolution-list-api/src/api/index.ts b/packages/kbn-securitysolution-list-api/src/api/index.ts index 98b83b6279953..970282080cb89 100644 --- a/packages/kbn-securitysolution-list-api/src/api/index.ts +++ b/packages/kbn-securitysolution-list-api/src/api/index.ts @@ -43,6 +43,8 @@ import { } from '@kbn/securitysolution-list-constants'; import { toError, toPromise } from '../fp_utils'; +const version = '2023-10-31'; + /** * Add new ExceptionList * @@ -62,6 +64,7 @@ const addExceptionList = async ({ body: JSON.stringify(list), method: 'POST', signal, + version, }); const addExceptionListWithValidation = async ({ @@ -105,6 +108,7 @@ const addExceptionListItem = async ({ body: JSON.stringify(listItem), method: 'POST', signal, + version, }); const addExceptionListItemWithValidation = async ({ @@ -148,6 +152,7 @@ const updateExceptionList = async ({ body: JSON.stringify(list), method: 'PUT', signal, + version, }); const updateExceptionListWithValidation = async ({ @@ -191,6 +196,7 @@ const updateExceptionListItem = async ({ body: JSON.stringify(listItem), method: 'PUT', signal, + version, }); const updateExceptionListItemWithValidation = async ({ @@ -247,6 +253,7 @@ const fetchExceptionLists = async ({ method: 'GET', query, signal, + version, }); }; @@ -298,6 +305,7 @@ const fetchExceptionListById = async ({ method: 'GET', query: { id, namespace_type: namespaceType }, signal, + version, }); const fetchExceptionListByIdWithValidation = async ({ @@ -361,6 +369,7 @@ const fetchExceptionListsItemsByListIds = async ({ method: 'GET', query, signal, + version, }); }; @@ -414,6 +423,7 @@ const fetchExceptionListItemById = async ({ method: 'GET', query: { id, namespace_type: namespaceType }, signal, + version, }); const fetchExceptionListItemByIdWithValidation = async ({ @@ -450,6 +460,7 @@ const deleteExceptionListById = async ({ method: 'DELETE', query: { id, namespace_type: namespaceType }, signal, + version, }); const deleteExceptionListByIdWithValidation = async ({ @@ -486,6 +497,7 @@ const deleteExceptionListItemById = async ({ method: 'DELETE', query: { id, namespace_type: namespaceType }, signal, + version, }); const deleteExceptionListItemByIdWithValidation = async ({ @@ -518,6 +530,7 @@ const addEndpointExceptionList = async ({ http.fetch(ENDPOINT_LIST_URL, { method: 'POST', signal, + version, }); const addEndpointExceptionListWithValidation = async ({ @@ -561,6 +574,7 @@ export const exportExceptionList = async ({ include_expired_exceptions: includeExpiredExceptions, }, signal, + version, }); /** @@ -647,4 +661,5 @@ export const duplicateExceptionList = async ({ include_expired_exceptions: includeExpiredExceptions, }, signal, + version, }); diff --git a/packages/kbn-securitysolution-list-api/src/list_api/index.ts b/packages/kbn-securitysolution-list-api/src/list_api/index.ts index 5c280c7959763..59303b9e8abaf 100644 --- a/packages/kbn-securitysolution-list-api/src/list_api/index.ts +++ b/packages/kbn-securitysolution-list-api/src/list_api/index.ts @@ -57,6 +57,8 @@ export type { ImportListParams, } from './types'; +const version = '2023-10-31'; + const findLists = async ({ http, cursor, @@ -79,6 +81,7 @@ const findLists = async ({ sort_order, }, signal, + version, }); }; @@ -167,6 +170,7 @@ const importList = async ({ method: 'POST', query: { list_id, type }, signal, + version, }); }; @@ -207,6 +211,7 @@ const deleteList = async ({ method: 'DELETE', query: { deleteReferences, id, ignoreReferences }, signal, + version, }); const deleteListWithValidation = async ({ @@ -236,6 +241,7 @@ const exportList = async ({ method: 'POST', query: { list_id }, signal, + version, }); const exportListWithValidation = async ({ @@ -256,6 +262,7 @@ const readListIndex = async ({ http, signal }: ApiParams): Promise(LIST_INDEX, { method: 'GET', signal, + version, }); const readListIndexWithValidation = async ({ @@ -273,15 +280,16 @@ export { readListIndexWithValidation as readListIndex }; // TODO add types and validation export const readListPrivileges = async ({ http, signal }: ApiParams): Promise => http.fetch(LIST_PRIVILEGES_URL, { - version: '2023-10-31', method: 'GET', signal, + version, }); const createListIndex = async ({ http, signal }: ApiParams): Promise => http.fetch(LIST_INDEX, { method: 'POST', signal, + version, }); const createListIndexWithValidation = async ({ diff --git a/packages/kbn-storybook/templates/index.ejs b/packages/kbn-storybook/templates/index.ejs index 899c6a5897c56..21e1035627aeb 100644 --- a/packages/kbn-storybook/templates/index.ejs +++ b/packages/kbn-storybook/templates/index.ejs @@ -46,7 +46,7 @@ diff --git a/versions.json b/versions.json index aeaf855d329d7..ba9e6cc338097 100644 --- a/versions.json +++ b/versions.json @@ -14,13 +14,13 @@ "previousMinor": true }, { - "version": "8.9.2", + "version": "8.9.3", "branch": "8.9", "currentMajor": true, "previousMinor": false }, { - "version": "7.17.13", + "version": "7.17.14", "branch": "7.17", "previousMajor": true } diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts index fa7410bb71178..233261ea213f7 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts @@ -3019,6 +3019,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, { id: uuidv4(), @@ -3027,6 +3028,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '456def', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, ]); expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ @@ -3051,6 +3053,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, { id: uuidv4(), @@ -3059,6 +3062,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '456def', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, ]) ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to execute all actions]`); @@ -3081,6 +3085,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, { id: uuidv4(), @@ -3089,6 +3094,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '456def', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, ]); @@ -3112,6 +3118,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, { id: uuidv4(), @@ -3120,6 +3127,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '456def', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, ]; await expect(actionsClient.bulkEnqueueExecution(opts)).resolves.toMatchInlineSnapshot( diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.ts b/x-pack/plugins/actions/server/actions_client/actions_client.ts index 392b5ec7354b6..73a87900db1a7 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.ts @@ -55,6 +55,7 @@ import { ExecutionEnqueuer, ExecuteOptions as EnqueueExecutionOptions, BulkExecutionEnqueuer, + ExecutionResponse, } from '../create_execute_function'; import { ActionsAuthorization } from '../authorization/actions_authorization'; import { @@ -114,7 +115,7 @@ export interface ConstructorOptions { inMemoryConnectors: InMemoryConnector[]; actionExecutor: ActionExecutorContract; ephemeralExecutionEnqueuer: ExecutionEnqueuer; - bulkExecutionEnqueuer: BulkExecutionEnqueuer; + bulkExecutionEnqueuer: BulkExecutionEnqueuer; request: KibanaRequest; authorization: ActionsAuthorization; auditLogger?: AuditLogger; @@ -139,7 +140,7 @@ export interface ActionsClientContext { request: KibanaRequest; authorization: ActionsAuthorization; ephemeralExecutionEnqueuer: ExecutionEnqueuer; - bulkExecutionEnqueuer: BulkExecutionEnqueuer; + bulkExecutionEnqueuer: BulkExecutionEnqueuer; auditLogger?: AuditLogger; usageCounter?: UsageCounter; connectorTokenClient: ConnectorTokenClientContract; @@ -766,7 +767,9 @@ export class ActionsClient { }); } - public async bulkEnqueueExecution(options: EnqueueExecutionOptions[]): Promise { + public async bulkEnqueueExecution( + options: EnqueueExecutionOptions[] + ): Promise { const sources: Array> = []; options.forEach((option) => { if (option.source) { diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 95c6ec1c0cc11..49f1a807bce9f 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -28,6 +28,7 @@ const createActionsConfigMock = () => { validateEmailAddresses: jest.fn().mockReturnValue(undefined), getMaxAttempts: jest.fn().mockReturnValue(3), enableFooterInEmail: jest.fn().mockReturnValue(true), + getMaxQueued: jest.fn().mockReturnValue(1000), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index e1b761ee30001..d19fcbc363b88 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -563,3 +563,20 @@ describe('getMaxAttempts()', () => { expect(maxAttempts).toEqual(3); }); }); + +describe('getMaxQueued()', () => { + test('returns the queued actions max defined in config', () => { + const acu = getActionsConfigurationUtilities({ + ...defaultActionsConfig, + queued: { max: 1 }, + }); + const max = acu.getMaxQueued(); + expect(max).toEqual(1); + }); + + test('returns the default queued actions max', () => { + const acu = getActionsConfigurationUtilities(defaultActionsConfig); + const max = acu.getMaxQueued(); + expect(max).toEqual(1000000); + }); +}); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 78e62684ad898..240a65228b4dc 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -11,7 +11,13 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; +import { + ActionsConfig, + AllowedHosts, + EnabledActionTypes, + CustomHostSettings, + DEFAULT_QUEUED_MAX, +} from './config'; import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; import { ProxySettings, ResponseSettings, SSLSettings } from './types'; @@ -54,6 +60,7 @@ export interface ActionsConfigurationUtilities { options?: ValidateEmailAddressesOptions ): string | undefined; enableFooterInEmail: () => boolean; + getMaxQueued: () => number; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -217,5 +224,6 @@ export function getActionsConfigurationUtilities( ); }, enableFooterInEmail: () => config.enableFooterInEmail, + getMaxQueued: () => config.queued?.max || DEFAULT_QUEUED_MAX, }; } diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index da45fd40cf925..a0b6c23883993 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -19,6 +19,9 @@ export enum EnabledActionTypes { const MAX_MAX_ATTEMPTS = 10; const MIN_MAX_ATTEMPTS = 1; +const MIN_QUEUED_MAX = 1; +export const DEFAULT_QUEUED_MAX = 1000000; + const preconfiguredActionSchema = schema.object({ name: schema.string({ minLength: 1 }), actionTypeId: schema.string({ minLength: 1 }), @@ -130,6 +133,11 @@ export const configSchema = schema.object({ }) ), enableFooterInEmail: schema.boolean({ defaultValue: true }), + queued: schema.maybe( + schema.object({ + max: schema.maybe(schema.number({ min: MIN_QUEUED_MAX, defaultValue: DEFAULT_QUEUED_MAX })), + }) + ), }); export type ActionsConfig = TypeOf; diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index 72903ca433b4e..162297a9a55cf 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -15,12 +15,24 @@ import { asHttpRequestExecutionSource, asSavedObjectExecutionSource, } from './lib/action_execution_source'; +import { actionsConfigMock } from './actions_config.mock'; const mockTaskManager = taskManagerMock.createStart(); const savedObjectsClient = savedObjectsClientMock.create(); const request = {} as KibanaRequest; +const mockActionsConfig = actionsConfigMock.create(); -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => { + jest.resetAllMocks(); + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 0, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValue(10); +}); describe('bulkExecute()', () => { test('schedules the action with all given parameters', async () => { @@ -30,6 +42,7 @@ describe('bulkExecute()', () => { actionTypeRegistry, isESOCanEncrypt: true, inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -63,6 +76,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: Buffer.from('123:abc').toString('base64'), source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -118,6 +132,7 @@ describe('bulkExecute()', () => { actionTypeRegistry, isESOCanEncrypt: true, inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -153,6 +168,7 @@ describe('bulkExecute()', () => { consumer: 'test-consumer', apiKey: Buffer.from('123:abc').toString('base64'), source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -209,6 +225,7 @@ describe('bulkExecute()', () => { actionTypeRegistry, isESOCanEncrypt: true, inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -248,6 +265,7 @@ describe('bulkExecute()', () => { typeId: 'some-typeId', }, ], + actionTypeId: 'mock-action', }, ]); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( @@ -304,6 +322,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); const source = { type: 'alert', id: uuidv4() }; @@ -339,6 +358,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: Buffer.from('123:abc').toString('base64'), source: asSavedObjectExecutionSource(source), + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -401,6 +421,7 @@ describe('bulkExecute()', () => { isSystemAction: true, }, ], + configurationUtilities: mockActionsConfig, }); const source = { type: 'alert', id: uuidv4() }; @@ -436,6 +457,7 @@ describe('bulkExecute()', () => { executionId: 'system-connector-.casesabc', apiKey: Buffer.from('system-connector-test.system-action:abc').toString('base64'), source: asSavedObjectExecutionSource(source), + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -498,6 +520,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); const source = { type: 'alert', id: uuidv4() }; @@ -541,6 +564,7 @@ describe('bulkExecute()', () => { typeId: 'some-typeId', }, ], + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -616,6 +640,7 @@ describe('bulkExecute()', () => { isSystemAction: true, }, ], + configurationUtilities: mockActionsConfig, }); const source = { type: 'alert', id: uuidv4() }; @@ -659,6 +684,7 @@ describe('bulkExecute()', () => { typeId: 'some-typeId', }, ], + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -723,6 +749,7 @@ describe('bulkExecute()', () => { isESOCanEncrypt: false, actionTypeRegistry: actionTypeRegistryMock.create(), inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); await expect( executeFn(savedObjectsClient, [ @@ -733,6 +760,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]) ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -746,6 +774,7 @@ describe('bulkExecute()', () => { isESOCanEncrypt: true, actionTypeRegistry: actionTypeRegistryMock.create(), inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -770,6 +799,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]) ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -784,6 +814,7 @@ describe('bulkExecute()', () => { isESOCanEncrypt: true, actionTypeRegistry: mockedActionTypeRegistry, inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); mockedActionTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => { throw new Error('Fail'); @@ -810,6 +841,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]) ).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`); @@ -833,6 +865,7 @@ describe('bulkExecute()', () => { isSystemAction: false, }, ], + configurationUtilities: mockActionsConfig, }); mockedActionTypeRegistry.isActionExecutable.mockImplementation(() => true); savedObjectsClient.bulkGet.mockResolvedValueOnce({ @@ -868,6 +901,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]); @@ -892,6 +926,7 @@ describe('bulkExecute()', () => { isSystemAction: true, }, ], + configurationUtilities: mockActionsConfig, }); mockedActionTypeRegistry.isActionExecutable.mockImplementation(() => true); savedObjectsClient.bulkGet.mockResolvedValueOnce({ @@ -927,9 +962,64 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]); expect(mockedActionTypeRegistry.ensureActionTypeEnabled).not.toHaveBeenCalled(); }); + + test('returns queuedActionsLimitError response when the max number of queued actions has been reached', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 2, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(2); + const executeFn = createBulkExecutionEnqueuerFunction({ + taskManager: mockTaskManager, + actionTypeRegistry: actionTypeRegistryMock.create(), + isESOCanEncrypt: true, + inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [], + }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [], + }); + expect( + await executeFn(savedObjectsClient, [ + { + id: '123', + params: { baz: false }, + spaceId: 'default', + executionId: '123abc', + apiKey: null, + source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', + }, + ]) + ).toMatchInlineSnapshot(` + Object { + "errors": true, + "items": Array [ + Object { + "actionTypeId": "mock-action", + "id": "123", + "response": "queuedActionsLimitError", + }, + ], + } + `); + expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); + expect(mockTaskManager.bulkSchedule.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [], + ] + `); + }); }); diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index 3b4233ddf5710..04d029f83c577 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -16,12 +16,15 @@ import { import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './constants/saved_objects'; import { ExecuteOptions as ActionExecutorOptions } from './lib/action_executor'; import { extractSavedObjectReferences, isSavedObjectExecutionSource } from './lib'; +import { ActionsConfigurationUtilities } from './actions_config'; +import { hasReachedTheQueuedActionsLimit } from './lib/has_reached_queued_actions_limit'; interface CreateExecuteFunctionOptions { taskManager: TaskManagerStartContract; isESOCanEncrypt: boolean; actionTypeRegistry: ActionTypeRegistryContract; inMemoryConnectors: InMemoryConnector[]; + configurationUtilities: ActionsConfigurationUtilities; } export interface ExecuteOptions @@ -30,6 +33,7 @@ export interface ExecuteOptions spaceId: string; apiKey: string | null; executionId: string; + actionTypeId: string; } interface ActionTaskParams @@ -54,12 +58,29 @@ export type BulkExecutionEnqueuer = ( actionsToExectute: ExecuteOptions[] ) => Promise; +export enum ExecutionResponseType { + SUCCESS = 'success', + QUEUED_ACTIONS_LIMIT_ERROR = 'queuedActionsLimitError', +} + +export interface ExecutionResponse { + errors: boolean; + items: ExecutionResponseItem[]; +} + +export interface ExecutionResponseItem { + id: string; + actionTypeId: string; + response: ExecutionResponseType; +} + export function createBulkExecutionEnqueuerFunction({ taskManager, actionTypeRegistry, isESOCanEncrypt, inMemoryConnectors, -}: CreateExecuteFunctionOptions): BulkExecutionEnqueuer { + configurationUtilities, +}: CreateExecuteFunctionOptions): BulkExecutionEnqueuer { return async function execute( unsecuredSavedObjectsClient: SavedObjectsClientContract, actionsToExecute: ExecuteOptions[] @@ -70,6 +91,19 @@ export function createBulkExecutionEnqueuerFunction({ ); } + const { hasReachedLimit, numberOverLimit } = await hasReachedTheQueuedActionsLimit( + taskManager, + configurationUtilities, + actionsToExecute.length + ); + let actionsOverLimit: ExecuteOptions[] = []; + if (hasReachedLimit) { + actionsOverLimit = actionsToExecute.splice( + actionsToExecute.length - numberOverLimit, + numberOverLimit + ); + } + const actionTypeIds: Record = {}; const spaceIds: Record = {}; const connectorIsInMemory: Record = {}; @@ -144,6 +178,22 @@ export function createBulkExecutionEnqueuerFunction({ }; }); await taskManager.bulkSchedule(taskInstances); + return { + errors: actionsOverLimit.length > 0, + items: actionsToExecute + .map((a) => ({ + id: a.id, + actionTypeId: a.actionTypeId, + response: ExecutionResponseType.SUCCESS, + })) + .concat( + actionsOverLimit.map((a) => ({ + id: a.id, + actionTypeId: a.actionTypeId, + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + })) + ), + }; }; } diff --git a/x-pack/plugins/actions/server/create_unsecured_execute_function.test.ts b/x-pack/plugins/actions/server/create_unsecured_execute_function.test.ts index 2bbfab40b7318..bce0647389396 100644 --- a/x-pack/plugins/actions/server/create_unsecured_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_unsecured_execute_function.test.ts @@ -14,11 +14,23 @@ import { asNotificationExecutionSource, asSavedObjectExecutionSource, } from './lib/action_execution_source'; +import { actionsConfigMock } from './actions_config.mock'; const mockTaskManager = taskManagerMock.createStart(); const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const mockActionsConfig = actionsConfigMock.create(); -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => { + jest.resetAllMocks(); + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 0, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValue(10); +}); describe('bulkExecute()', () => { test.each([ @@ -42,6 +54,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); internalSavedObjectsRepository.bulkCreate.mockResolvedValueOnce({ @@ -154,6 +167,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); internalSavedObjectsRepository.bulkCreate.mockResolvedValueOnce({ @@ -278,6 +292,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); internalSavedObjectsRepository.bulkCreate.mockResolvedValueOnce({ @@ -426,6 +441,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); await expect( executeFn(internalSavedObjectsRepository, [ @@ -468,6 +484,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); mockedConnectorTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => { throw new Error('Fail'); @@ -521,6 +538,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); await expect( executeFn(internalSavedObjectsRepository, [ @@ -540,4 +558,57 @@ describe('bulkExecute()', () => { ); } ); + + test.each([ + [true, false], + [false, true], + ])( + 'returns queuedActionsLimitError response when the max number of queued actions has been reached: %s, isSystemAction: %s', + async (isPreconfigured, isSystemAction) => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 2, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(2); + const executeFn = createBulkUnsecuredExecutionEnqueuerFunction({ + taskManager: mockTaskManager, + connectorTypeRegistry: actionTypeRegistryMock.create(), + inMemoryConnectors: [ + { + id: '123', + actionTypeId: '.email', + config: {}, + isPreconfigured, + isDeprecated: false, + isSystemAction, + name: 'x', + secrets: {}, + }, + ], + configurationUtilities: mockActionsConfig, + }); + + internalSavedObjectsRepository.bulkCreate.mockResolvedValueOnce({ + saved_objects: [], + }); + expect( + await executeFn(internalSavedObjectsRepository, [ + { + id: '123', + params: { baz: false }, + source: asNotificationExecutionSource({ connectorId: 'abc', requesterId: 'foo' }), + }, + ]) + ).toEqual({ errors: true, items: [{ id: '123', response: 'queuedActionsLimitError' }] }); + expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); + expect(mockTaskManager.bulkSchedule.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [], + ] + `); + } + ); }); diff --git a/x-pack/plugins/actions/server/create_unsecured_execute_function.ts b/x-pack/plugins/actions/server/create_unsecured_execute_function.ts index 585f442c68e2f..a64a2494e5077 100644 --- a/x-pack/plugins/actions/server/create_unsecured_execute_function.ts +++ b/x-pack/plugins/actions/server/create_unsecured_execute_function.ts @@ -14,6 +14,9 @@ import { import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './constants/saved_objects'; import { ExecuteOptions as ActionExecutorOptions } from './lib/action_executor'; import { extractSavedObjectReferences, isSavedObjectExecutionSource } from './lib'; +import { ExecutionResponseItem, ExecutionResponseType } from './create_execute_function'; +import { ActionsConfigurationUtilities } from './actions_config'; +import { hasReachedTheQueuedActionsLimit } from './lib/has_reached_queued_actions_limit'; // This allowlist should only contain connector types that don't require API keys for // execution. @@ -22,6 +25,7 @@ interface CreateBulkUnsecuredExecuteFunctionOptions { taskManager: TaskManagerStartContract; connectorTypeRegistry: ConnectorTypeRegistryContract; inMemoryConnectors: InMemoryConnector[]; + configurationUtilities: ActionsConfigurationUtilities; } export interface ExecuteOptions @@ -29,6 +33,11 @@ export interface ExecuteOptions id: string; } +export interface ExecutionResponse { + errors: boolean; + items: ExecutionResponseItem[]; +} + interface ActionTaskParams extends Pick { apiKey: string | null; @@ -43,11 +52,25 @@ export function createBulkUnsecuredExecutionEnqueuerFunction({ taskManager, connectorTypeRegistry, inMemoryConnectors, -}: CreateBulkUnsecuredExecuteFunctionOptions): BulkUnsecuredExecutionEnqueuer { + configurationUtilities, +}: CreateBulkUnsecuredExecuteFunctionOptions): BulkUnsecuredExecutionEnqueuer { return async function execute( internalSavedObjectsRepository: ISavedObjectsRepository, actionsToExecute: ExecuteOptions[] ) { + const { hasReachedLimit, numberOverLimit } = await hasReachedTheQueuedActionsLimit( + taskManager, + configurationUtilities, + actionsToExecute.length + ); + let actionsOverLimit: ExecuteOptions[] = []; + if (hasReachedLimit) { + actionsOverLimit = actionsToExecute.splice( + actionsToExecute.length - numberOverLimit, + numberOverLimit + ); + } + const connectorTypeIds: Record = {}; const connectorIds = [...new Set(actionsToExecute.map((action) => action.id))]; @@ -131,6 +154,23 @@ export function createBulkUnsecuredExecutionEnqueuerFunction({ }; }); await taskManager.bulkSchedule(taskInstances); + + return { + errors: actionsOverLimit.length > 0, + items: actionsToExecute + .map((a) => ({ + id: a.id, + response: ExecutionResponseType.SUCCESS, + actionTypeId: connectorTypeIds[a.id], + })) + .concat( + actionsOverLimit.map((a) => ({ + id: a.id, + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + actionTypeId: connectorTypeIds[a.id], + })) + ), + }; }; } diff --git a/x-pack/plugins/actions/server/lib/has_reached_queued_action_limit.test.ts b/x-pack/plugins/actions/server/lib/has_reached_queued_action_limit.test.ts new file mode 100644 index 0000000000000..67772d33f872e --- /dev/null +++ b/x-pack/plugins/actions/server/lib/has_reached_queued_action_limit.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { hasReachedTheQueuedActionsLimit } from './has_reached_queued_actions_limit'; + +const mockTaskManager = taskManagerMock.createStart(); +const mockActionsConfig = actionsConfigMock.create(); + +beforeEach(() => { + jest.resetAllMocks(); + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 0, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValue(10); +}); + +describe('hasReachedTheQueuedActionsLimit()', () => { + test('returns true if the number of queued actions is greater than the config limit', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 3, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(2); + + expect(await hasReachedTheQueuedActionsLimit(mockTaskManager, mockActionsConfig, 1)).toEqual({ + hasReachedLimit: true, + numberOverLimit: 2, + }); + }); + + test('returns true if the number of queued actions is equal the config limit', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 2, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(3); + + expect(await hasReachedTheQueuedActionsLimit(mockTaskManager, mockActionsConfig, 1)).toEqual({ + hasReachedLimit: true, + numberOverLimit: 0, + }); + }); + + test('returns false if the number of queued actions is less than the config limit', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 1, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(3); + + expect(await hasReachedTheQueuedActionsLimit(mockTaskManager, mockActionsConfig, 1)).toEqual({ + hasReachedLimit: false, + numberOverLimit: 0, + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/has_reached_queued_actions_limit.ts b/x-pack/plugins/actions/server/lib/has_reached_queued_actions_limit.ts new file mode 100644 index 0000000000000..3c88b82712925 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/has_reached_queued_actions_limit.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +export async function hasReachedTheQueuedActionsLimit( + taskManager: TaskManagerStartContract, + configurationUtilities: ActionsConfigurationUtilities, + numberOfActions: number +) { + const limit = configurationUtilities.getMaxQueued(); + const { + hits: { total }, + } = await taskManager.aggregate({ + query: { + bool: { + filter: { + bool: { + must: [ + { + term: { + 'task.scope': 'actions', + }, + }, + ], + }, + }, + }, + }, + aggs: {}, + }); + const tasks = typeof total === 'number' ? total : total?.value ?? 0; + const numberOfTasks = tasks + numberOfActions; + const hasReachedLimit = numberOfTasks >= limit; + return { + hasReachedLimit, + numberOverLimit: hasReachedLimit ? numberOfTasks - limit : 0, + }; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 415a9e36a1c01..ef5de6194d475 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -301,7 +301,7 @@ export class ActionsPlugin implements Plugin( 'actions', - this.createRouteHandlerContext(core) + this.createRouteHandlerContext(core, actionsConfigUtils) ); if (usageCollection) { const eventLogIndex = this.eventLogService.getIndexPattern(); @@ -404,8 +404,11 @@ export class ActionsPlugin implements Plugin + core: CoreSetup, + actionsConfigUtils: ActionsConfigurationUtilities ): IContextProvider => { const { actionTypeRegistry, @@ -687,12 +694,14 @@ export class ActionsPlugin implements Plugin; + executionEnqueuer: BulkUnsecuredExecutionEnqueuer; } export interface IUnsecuredActionsClient { - bulkEnqueueExecution: (requesterId: string, actionsToExecute: ExecuteOptions[]) => Promise; + bulkEnqueueExecution: ( + requesterId: string, + actionsToExecute: ExecuteOptions[] + ) => Promise; } export class UnsecuredActionsClient { private readonly internalSavedObjectsRepository: ISavedObjectsRepository; - private readonly executionEnqueuer: BulkUnsecuredExecutionEnqueuer; + private readonly executionEnqueuer: BulkUnsecuredExecutionEnqueuer; constructor(params: UnsecuredActionsClientOpts) { this.executionEnqueuer = params.executionEnqueuer; @@ -43,7 +47,7 @@ export class UnsecuredActionsClient { public async bulkEnqueueExecution( requesterId: string, actionsToExecute: ExecuteOptions[] - ): Promise { + ): Promise { // Check that requesterId is allowed if (!ALLOWED_REQUESTER_IDS.includes(requesterId)) { throw new Error( diff --git a/x-pack/plugins/alerting/common/routes/rule/response/constants/v1.ts b/x-pack/plugins/alerting/common/routes/rule/response/constants/v1.ts index a241412310482..fbeb08ba6bc7f 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/constants/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/constants/v1.ts @@ -40,6 +40,7 @@ export const ruleExecutionStatusErrorReason = { export const ruleExecutionStatusWarningReason = { MAX_EXECUTABLE_ACTIONS: 'maxExecutableActions', MAX_ALERTS: 'maxAlerts', + MAX_QUEUED_ACTIONS: 'maxQueuedActions', } as const; export type RuleNotifyWhen = typeof ruleNotifyWhen[keyof typeof ruleNotifyWhen]; diff --git a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts index 3aecec56c3a41..2fb82c3558cb7 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts @@ -110,6 +110,7 @@ export const ruleExecutionStatusSchema = schema.object({ reason: schema.oneOf([ schema.literal(ruleExecutionStatusWarningReasonV1.MAX_EXECUTABLE_ACTIONS), schema.literal(ruleExecutionStatusWarningReasonV1.MAX_ALERTS), + schema.literal(ruleExecutionStatusWarningReasonV1.MAX_QUEUED_ACTIONS), ]), message: schema.string(), }) @@ -136,6 +137,7 @@ export const ruleLastRunSchema = schema.object({ schema.literal(ruleExecutionStatusErrorReasonV1.VALIDATE), schema.literal(ruleExecutionStatusWarningReasonV1.MAX_EXECUTABLE_ACTIONS), schema.literal(ruleExecutionStatusWarningReasonV1.MAX_ALERTS), + schema.literal(ruleExecutionStatusWarningReasonV1.MAX_QUEUED_ACTIONS), ]) ) ), diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index 57ef90ed99620..22692a091a38c 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -60,6 +60,7 @@ export enum RuleExecutionStatusErrorReasons { export enum RuleExecutionStatusWarningReasons { MAX_EXECUTABLE_ACTIONS = 'maxExecutableActions', MAX_ALERTS = 'maxAlerts', + MAX_QUEUED_ACTIONS = 'maxQueuedActions', } export type RuleAlertingOutcome = 'failure' | 'success' | 'unknown' | 'warning'; diff --git a/x-pack/plugins/alerting/server/application/rule/constants.ts b/x-pack/plugins/alerting/server/application/rule/constants.ts index 7b0aa82a90ca9..bc75d91375ecb 100644 --- a/x-pack/plugins/alerting/server/application/rule/constants.ts +++ b/x-pack/plugins/alerting/server/application/rule/constants.ts @@ -40,4 +40,5 @@ export const ruleExecutionStatusErrorReason = { export const ruleExecutionStatusWarningReason = { MAX_EXECUTABLE_ACTIONS: 'maxExecutableActions', MAX_ALERTS: 'maxAlerts', + MAX_QUEUED_ACTIONS: 'maxQueuedActions', } as const; diff --git a/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts b/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts index 07efe4793b562..ef8f1dc652bff 100644 --- a/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts +++ b/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts @@ -55,6 +55,7 @@ export const ruleExecutionStatusSchema = schema.object({ reason: schema.oneOf([ schema.literal(ruleExecutionStatusWarningReason.MAX_EXECUTABLE_ACTIONS), schema.literal(ruleExecutionStatusWarningReason.MAX_ALERTS), + schema.literal(ruleExecutionStatusWarningReason.MAX_QUEUED_ACTIONS), ]), message: schema.string(), }) @@ -81,6 +82,7 @@ export const ruleLastRunSchema = schema.object({ schema.literal(ruleExecutionStatusErrorReason.VALIDATE), schema.literal(ruleExecutionStatusWarningReason.MAX_EXECUTABLE_ACTIONS), schema.literal(ruleExecutionStatusWarningReason.MAX_ALERTS), + schema.literal(ruleExecutionStatusWarningReason.MAX_QUEUED_ACTIONS), ]) ) ), diff --git a/x-pack/plugins/alerting/server/constants/translations.ts b/x-pack/plugins/alerting/server/constants/translations.ts index 15442cf8efc57..69fc9a39333b2 100644 --- a/x-pack/plugins/alerting/server/constants/translations.ts +++ b/x-pack/plugins/alerting/server/constants/translations.ts @@ -21,6 +21,10 @@ export const translations = { defaultMessage: 'Rule reported more than the maximum number of alerts in a single run. Alerts may be missed and recovery notifications may be delayed', }), + maxQueuedActions: i18n.translate('xpack.alerting.taskRunner.warning.maxQueuedActions', { + defaultMessage: + 'The maximum number of queued actions was reached; excess actions were not triggered.', + }), }, }, }; diff --git a/x-pack/plugins/alerting/server/data/rule/constants.ts b/x-pack/plugins/alerting/server/data/rule/constants.ts index 63d238a81574e..267864bdfd9e8 100644 --- a/x-pack/plugins/alerting/server/data/rule/constants.ts +++ b/x-pack/plugins/alerting/server/data/rule/constants.ts @@ -40,4 +40,5 @@ export const ruleExecutionStatusErrorReasonAttributes = { export const ruleExecutionStatusWarningReasonAttributes = { MAX_EXECUTABLE_ACTIONS: 'maxExecutableActions', MAX_ALERTS: 'maxAlerts', + MAX_QUEUED_ACTIONS: 'maxQueuedActions', } as const; diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts index 007cd4481bd7e..c8c2e5f1943ec 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts @@ -728,6 +728,7 @@ describe('AlertingEventLogger', () => { totalSearchDurationMs: 10333, hasReachedAlertLimit: false, triggeredActionsStatus: ActionsCompletion.COMPLETE, + hasReachedQueuedActionsLimit: false, }, }); @@ -826,6 +827,7 @@ describe('AlertingEventLogger', () => { totalSearchDurationMs: 10333, hasReachedAlertLimit: false, triggeredActionsStatus: ActionsCompletion.COMPLETE, + hasReachedQueuedActionsLimit: false, }, timings: { [TaskRunnerTimerSpan.StartTaskRun]: 10, diff --git a/x-pack/plugins/alerting/server/lib/last_run_status.test.ts b/x-pack/plugins/alerting/server/lib/last_run_status.test.ts index 33af749fe1e08..c4b1ac0acc3a7 100644 --- a/x-pack/plugins/alerting/server/lib/last_run_status.test.ts +++ b/x-pack/plugins/alerting/server/lib/last_run_status.test.ts @@ -13,6 +13,7 @@ import { RuleResultServiceResults, RuleResultService } from '../monitoring/rule_ const getMetrics = ({ hasReachedAlertLimit = false, triggeredActionsStatus = ActionsCompletion.COMPLETE, + hasReachedQueuedActionsLimit = false, }): RuleRunMetrics => { return { triggeredActionsStatus, @@ -25,6 +26,7 @@ const getMetrics = ({ numberOfTriggeredActions: 5, totalSearchDurationMs: 2, hasReachedAlertLimit, + hasReachedQueuedActionsLimit, }; }; @@ -126,6 +128,31 @@ describe('lastRunFromState', () => { }); }); + it('returns warning if rules actions completition is partial and queued action circuit breaker opens', () => { + const result = lastRunFromState( + { + metrics: getMetrics({ + triggeredActionsStatus: ActionsCompletion.PARTIAL, + hasReachedQueuedActionsLimit: true, + }), + }, + getRuleResultService({}) + ); + + expect(result.lastRun.outcome).toEqual('warning'); + expect(result.lastRun.outcomeMsg).toEqual([ + 'The maximum number of queued actions was reached; excess actions were not triggered.', + ]); + expect(result.lastRun.warning).toEqual('maxQueuedActions'); + + expect(result.lastRun.alertsCount).toEqual({ + active: 10, + new: 12, + recovered: 11, + ignored: 0, + }); + }); + it('overwrites rule execution warning if rule has reached alert limit; outcome messages are merged', () => { const ruleExecutionOutcomeMessage = 'Rule execution reported a warning'; const frameworkOutcomeMessage = @@ -184,6 +211,38 @@ describe('lastRunFromState', () => { }); }); + it('overwrites rule execution warning if rule has reached queued action limit; outcome messages are merged', () => { + const ruleExecutionOutcomeMessage = 'Rule execution reported a warning'; + const frameworkOutcomeMessage = + 'The maximum number of queued actions was reached; excess actions were not triggered.'; + const result = lastRunFromState( + { + metrics: getMetrics({ + triggeredActionsStatus: ActionsCompletion.PARTIAL, + hasReachedQueuedActionsLimit: true, + }), + }, + getRuleResultService({ + warnings: ['MOCK_WARNING'], + outcomeMessage: 'Rule execution reported a warning', + }) + ); + + expect(result.lastRun.outcome).toEqual('warning'); + expect(result.lastRun.outcomeMsg).toEqual([ + frameworkOutcomeMessage, + ruleExecutionOutcomeMessage, + ]); + expect(result.lastRun.warning).toEqual('maxQueuedActions'); + + expect(result.lastRun.alertsCount).toEqual({ + active: 10, + new: 12, + recovered: 11, + ignored: 0, + }); + }); + it('overwrites warning outcome to error if rule execution reports an error', () => { const result = lastRunFromState( { diff --git a/x-pack/plugins/alerting/server/lib/last_run_status.ts b/x-pack/plugins/alerting/server/lib/last_run_status.ts index 56da93f074c27..dedee9a658360 100644 --- a/x-pack/plugins/alerting/server/lib/last_run_status.ts +++ b/x-pack/plugins/alerting/server/lib/last_run_status.ts @@ -48,8 +48,13 @@ export const lastRunFromState = ( outcomeMsg.push(translations.taskRunner.warning.maxAlerts); } else if (metrics.triggeredActionsStatus === ActionsCompletion.PARTIAL) { outcome = RuleLastRunOutcomeValues[1]; - warning = RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS; - outcomeMsg.push(translations.taskRunner.warning.maxExecutableActions); + if (metrics.hasReachedQueuedActionsLimit) { + warning = RuleExecutionStatusWarningReasons.MAX_QUEUED_ACTIONS; + outcomeMsg.push(translations.taskRunner.warning.maxQueuedActions); + } else { + warning = RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS; + outcomeMsg.push(translations.taskRunner.warning.maxExecutableActions); + } } // Overwrite outcome to be error if last run reported any errors diff --git a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts index 0210de56a6b0d..34c831db01a75 100644 --- a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts @@ -30,6 +30,7 @@ const executionMetrics = { numberOfRecoveredAlerts: 13, hasReachedAlertLimit: false, triggeredActionsStatus: ActionsCompletion.COMPLETE, + hasReachedQueuedActionsLimit: false, }; describe('RuleExecutionStatus', () => { @@ -48,6 +49,7 @@ describe('RuleExecutionStatus', () => { expect(received.numberOfNewAlerts).toEqual(expected.numberOfNewAlerts); expect(received.hasReachedAlertLimit).toEqual(expected.hasReachedAlertLimit); expect(received.triggeredActionsStatus).toEqual(expected.triggeredActionsStatus); + expect(received.hasReachedQueuedActionsLimit).toEqual(expected.hasReachedQueuedActionsLimit); } describe('executionStatusFromState()', () => { @@ -107,6 +109,30 @@ describe('RuleExecutionStatus', () => { }); }); + test('task state with max queued actions warning', () => { + const { status, metrics } = executionStatusFromState({ + alertInstances: { a: {} }, + metrics: { + ...executionMetrics, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + hasReachedQueuedActionsLimit: true, + }, + }); + checkDateIsNearNow(status.lastExecutionDate); + expect(status.warning).toEqual({ + message: translations.taskRunner.warning.maxQueuedActions, + reason: RuleExecutionStatusWarningReasons.MAX_QUEUED_ACTIONS, + }); + expect(status.status).toBe('warning'); + expect(status.error).toBe(undefined); + + testExpectedMetrics(metrics!, { + ...executionMetrics, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + hasReachedQueuedActionsLimit: true, + }); + }); + test('task state with max alerts warning', () => { const { status, metrics } = executionStatusFromState({ alertInstances: { a: {} }, diff --git a/x-pack/plugins/alerting/server/lib/rule_execution_status.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts index 43ab9e2153a94..2fea90c2410ff 100644 --- a/x-pack/plugins/alerting/server/lib/rule_execution_status.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts @@ -47,10 +47,17 @@ export function executionStatusFromState( }; } else if (stateWithMetrics.metrics.triggeredActionsStatus === ActionsCompletion.PARTIAL) { status = RuleExecutionStatusValues[5]; - warning = { - reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, - message: translations.taskRunner.warning.maxExecutableActions, - }; + if (stateWithMetrics.metrics.hasReachedQueuedActionsLimit) { + warning = { + reason: RuleExecutionStatusWarningReasons.MAX_QUEUED_ACTIONS, + message: translations.taskRunner.warning.maxQueuedActions, + }; + } else { + warning = { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: translations.taskRunner.warning.maxExecutableActions, + }; + } } return { diff --git a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts index 8f2410480cc6f..e2b7cc61550bd 100644 --- a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts +++ b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts @@ -25,6 +25,7 @@ describe('RuleRunMetricsStore', () => { expect(ruleRunMetricsStore.getNumberOfNewAlerts()).toBe(0); expect(ruleRunMetricsStore.getStatusByConnectorType('any')).toBe(undefined); expect(ruleRunMetricsStore.getHasReachedAlertLimit()).toBe(false); + expect(ruleRunMetricsStore.getHasReachedQueuedActionsLimit()).toBe(false); }); test('sets and returns numSearches', () => { @@ -95,6 +96,11 @@ describe('RuleRunMetricsStore', () => { expect(metricsStore.getEsSearchDurationMs()).toEqual(555); }); + test('sets and returns hasReachedQueuedActionsLimit', () => { + ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); + expect(ruleRunMetricsStore.getHasReachedQueuedActionsLimit()).toBe(true); + }); + test('gets metrics', () => { expect(ruleRunMetricsStore.getMetrics()).toEqual({ triggeredActionsStatus: 'partial', @@ -107,6 +113,7 @@ describe('RuleRunMetricsStore', () => { numberOfTriggeredActions: 5, totalSearchDurationMs: 2, hasReachedAlertLimit: true, + hasReachedQueuedActionsLimit: true, }); }); @@ -150,6 +157,19 @@ describe('RuleRunMetricsStore', () => { ).toBe(1); }); + // decrement + test('decrements numberOfTriggeredActions by 1', () => { + ruleRunMetricsStore.decrementNumberOfTriggeredActions(); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(5); + }); + + test('decrements numberOfTriggeredActionsByConnectorType by 1', () => { + ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType(testConnectorId); + expect( + ruleRunMetricsStore.getStatusByConnectorType(testConnectorId).numberOfTriggeredActions + ).toBe(0); + }); + // Checker test('checks if it has reached the executable actions limit', () => { expect(ruleRunMetricsStore.hasReachedTheExecutableActionsLimit({ default: { max: 10 } })).toBe( diff --git a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts index 14879e1558ba6..80b72e0069bb6 100644 --- a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts +++ b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts @@ -27,6 +27,7 @@ interface State { numberOfGeneratedActions: number; }; }; + hasReachedQueuedActionsLimit: boolean; } export type RuleRunMetrics = Omit & { @@ -44,6 +45,7 @@ export class RuleRunMetricsStore { numberOfNewAlerts: 0, hasReachedAlertLimit: false, connectorTypes: {}, + hasReachedQueuedActionsLimit: false, }; // Getters @@ -90,6 +92,9 @@ export class RuleRunMetricsStore { public getHasReachedAlertLimit = () => { return this.state.hasReachedAlertLimit; }; + public getHasReachedQueuedActionsLimit = () => { + return this.state.hasReachedQueuedActionsLimit; + }; // Setters public setSearchMetrics = (searchMetrics: SearchMetrics[]) => { @@ -135,6 +140,9 @@ export class RuleRunMetricsStore { public setHasReachedAlertLimit = (hasReachedAlertLimit: boolean) => { this.state.hasReachedAlertLimit = hasReachedAlertLimit; }; + public setHasReachedQueuedActionsLimit = (hasReachedQueuedActionsLimit: boolean) => { + this.state.hasReachedQueuedActionsLimit = hasReachedQueuedActionsLimit; + }; // Checkers public hasReachedTheExecutableActionsLimit = (actionsConfigMap: ActionsConfigMap): boolean => @@ -182,4 +190,13 @@ export class RuleRunMetricsStore { const currentVal = this.state.connectorTypes[actionTypeId]?.numberOfGeneratedActions || 0; set(this.state, `connectorTypes["${actionTypeId}"].numberOfGeneratedActions`, currentVal + 1); }; + + // Decrementer + public decrementNumberOfTriggeredActions = () => { + this.state.numberOfTriggeredActions--; + }; + public decrementNumberOfTriggeredActionsByConnectorType = (actionTypeId: string) => { + const currentVal = this.state.connectorTypes[actionTypeId]?.numberOfTriggeredActions || 0; + set(this.state, `connectorTypes["${actionTypeId}"].numberOfTriggeredActions`, currentVal - 1); + }; } diff --git a/x-pack/plugins/alerting/server/raw_rule_schema.ts b/x-pack/plugins/alerting/server/raw_rule_schema.ts index 5843467a4cb46..65d4b48ec7d66 100644 --- a/x-pack/plugins/alerting/server/raw_rule_schema.ts +++ b/x-pack/plugins/alerting/server/raw_rule_schema.ts @@ -10,6 +10,7 @@ import { schema } from '@kbn/config-schema'; const executionStatusWarningReason = schema.oneOf([ schema.literal('maxExecutableActions'), schema.literal('maxAlerts'), + schema.literal('maxQueuedActions'), ]); const executionStatusErrorReason = schema.oneOf([ diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts index ce86dd4756093..5125bc67b90ef 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts @@ -34,6 +34,7 @@ import sinon from 'sinon'; import { mockAAD } from './fixtures'; import { schema } from '@kbn/config-schema'; import { alertsClientMock } from '../alerts_client/alerts_client.mock'; +import { ExecutionResponseType } from '@kbn/actions-plugin/server/create_execute_function'; jest.mock('./inject_action_params', () => ({ injectActionParams: jest.fn(), @@ -137,6 +138,11 @@ const defaultExecutionParams = { alertsClient, }; +const defaultExecutionResponse = { + errors: false, + items: [{ actionTypeId: 'test', id: '1', response: ExecutionResponseType.SUCCESS }], +}; + let ruleRunMetricsStore: RuleRunMetricsStore; let clock: sinon.SinonFakeTimers; type ActiveActionGroup = 'default' | 'other-group'; @@ -223,6 +229,7 @@ describe('Execution Handler', () => { renderActionParameterTemplatesDefault ); ruleRunMetricsStore = new RuleRunMetricsStore(); + actionsClient.bulkEnqueueExecution.mockResolvedValue(defaultExecutionResponse); }); beforeAll(() => { clock = sinon.useFakeTimers(); @@ -238,39 +245,40 @@ describe('Execution Handler', () => { expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { @@ -334,6 +342,7 @@ describe('Execution Handler', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith([ { + actionTypeId: 'test2', consumer: 'rule-consumer', id: '2', params: { @@ -423,39 +432,40 @@ describe('Execution Handler', () => { expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", + "contextVal": "My context-val goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", - "contextVal": "My context-val goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); }); test('state attribute gets parameterized', async () => { @@ -463,39 +473,40 @@ describe('Execution Handler', () => { await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My state-val goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My state-val goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); }); test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { @@ -514,6 +525,21 @@ describe('Execution Handler', () => { }); test('Stops triggering actions when the number of total triggered actions is reached the number of max executable actions', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'test2', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'test2', + id: '2', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); const actions = [ { id: '1', @@ -573,6 +599,27 @@ describe('Execution Handler', () => { }); test('Skips triggering actions for a specific action type when it reaches the limit for that specific action type', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { actionTypeId: 'test', id: '1', response: ExecutionResponseType.SUCCESS }, + { + actionTypeId: 'test-action-type-id', + id: '2', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'another-action-type-id', + id: '4', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'another-action-type-id', + id: '5', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); const actions = [ ...defaultExecutionParams.rule.actions, { @@ -652,6 +699,77 @@ describe('Execution Handler', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); }); + test('Stops triggering actions when the number of total queued actions is reached the number of max queued actions', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: true, + items: [ + { + actionTypeId: 'test', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'test', + id: '2', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'test', + id: '3', + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + }, + ], + }); + const actions = [ + { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '2', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '3', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + ]; + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + actions, + }, + }) + ); + await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(2); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(3); + expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); + expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + }); + test('schedules alerts with recovered actions', async () => { const actions = [ { @@ -680,39 +798,40 @@ describe('Execution Handler', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); }); test('does not schedule alerts with recovered actions that are muted', async () => { @@ -852,6 +971,16 @@ describe('Execution Handler', () => { }); test('triggers summary actions (per rule run)', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 1, @@ -895,36 +1024,37 @@ describe('Execution Handler', () => { }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "testActionTypeId", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "message": "New: 1 Ongoing: 0 Recovered: 0", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "message": "New: 1 Ongoing: 0 Recovered: 0", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); expect(alertingEventLogger.logAction).toBeCalledWith({ alertSummary: { new: 1, ongoing: 0, recovered: 0 }, id: '1', @@ -970,6 +1100,16 @@ describe('Execution Handler', () => { }); test('triggers summary actions (custom interval)', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 1, @@ -1022,36 +1162,37 @@ describe('Execution Handler', () => { }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "testActionTypeId", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "message": "New: 1 Ongoing: 0 Recovered: 0", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "message": "New: 1 Ongoing: 0 Recovered: 0", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); expect(alertingEventLogger.logAction).toBeCalledWith({ alertSummary: { new: 1, ongoing: 0, recovered: 0 }, id: '1', @@ -1206,6 +1347,17 @@ describe('Execution Handler', () => { }); test('schedules alerts with multiple recovered actions', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { actionTypeId: 'test', id: '1', response: ExecutionResponseType.SUCCESS }, + { + actionTypeId: 'test', + id: '2', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); const actions = [ { id: '1', @@ -1245,70 +1397,82 @@ describe('Execution Handler', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "2", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", - "id": "2", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); }); test('does not schedule actions for the summarized alerts that are filtered out (for each alert)', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 0, @@ -1372,6 +1536,16 @@ describe('Execution Handler', () => { }); test('does not schedule actions for the summarized alerts that are filtered out (summary of alerts onThrottleInterval)', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 0, @@ -1432,6 +1606,16 @@ describe('Execution Handler', () => { }); test('does not schedule actions for the for-each type alerts that are filtered out', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 1, @@ -1486,6 +1670,7 @@ describe('Execution Handler', () => { }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith([ { + actionTypeId: 'testActionTypeId', apiKey: 'MTIzOmFiYw==', consumer: 'rule-consumer', executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts index 6214482ec2706..c3002b7efe67a 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -10,7 +10,11 @@ import { Logger } from '@kbn/core/server'; import { getRuleDetailsRoute, triggersActionsRoute } from '@kbn/rule-data-utils'; import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import { isEphemeralTaskRejectedDueToCapacityError } from '@kbn/task-manager-plugin/server'; -import { ExecuteOptions as EnqueueExecutionOptions } from '@kbn/actions-plugin/server/create_execute_function'; +import { + ExecuteOptions as EnqueueExecutionOptions, + ExecutionResponseItem, + ExecutionResponseType, +} from '@kbn/actions-plugin/server/create_execute_function'; import { ActionsCompletion } from '@kbn/alerting-state-types'; import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; import { chunk } from 'lodash'; @@ -49,6 +53,18 @@ enum Reasons { ACTION_GROUP_NOT_CHANGED = 'actionGroupHasNotChanged', } +interface LogAction { + id: string; + typeId: string; + alertId?: string; + alertGroup?: string; + alertSummary?: { + new: number; + ongoing: number; + recovered: number; + }; +} + export interface RunResult { throttledSummaryActions: ThrottledActions; } @@ -176,8 +192,9 @@ export class ExecutionHandler< }, } = this; - const logActions = []; + const logActions: Record = {}; const bulkActions: EnqueueExecutionOptions[] = []; + let bulkActionsResponse: ExecutionResponseItem[] = []; this.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); @@ -262,7 +279,7 @@ export class ExecutionHandler< throttledSummaryActions[action.uuid!] = { date: new Date().toISOString() }; } - logActions.push({ + logActions[action.id] = { id: action.id, typeId: action.actionTypeId, alertSummary: { @@ -270,7 +287,7 @@ export class ExecutionHandler< ongoing: summarizedAlerts.ongoing.count, recovered: summarizedAlerts.recovered.count, }, - }); + }; } else { const ruleUrl = this.buildRuleUrl(spaceId); const actionToRun = { @@ -307,12 +324,12 @@ export class ExecutionHandler< bulkActions, }); - logActions.push({ + logActions[action.id] = { id: action.id, typeId: action.actionTypeId, alertId: alert.getId(), alertGroup: action.group, - }); + }; if (!this.isRecoveredAlert(actionGroup)) { if (isActionOnInterval(action)) { @@ -331,12 +348,40 @@ export class ExecutionHandler< if (!!bulkActions.length) { for (const c of chunk(bulkActions, CHUNK_SIZE)) { - await this.actionsClient!.bulkEnqueueExecution(c); + const response = await this.actionsClient!.bulkEnqueueExecution(c); + if (response.errors) { + bulkActionsResponse = bulkActionsResponse.concat( + response.items.filter( + (i) => i.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR + ) + ); + } + } + } + + if (!!bulkActionsResponse.length) { + for (const r of bulkActionsResponse) { + if (r.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR) { + ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); + ruleRunMetricsStore.decrementNumberOfTriggeredActions(); + ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType(r.actionTypeId); + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId: r.actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + + logger.debug( + `Rule "${this.rule.id}" skipped scheduling action "${r.id}" because the maximum number of queued actions has been reached.` + ); + + delete logActions[r.id]; + } } } - if (!!logActions.length) { - for (const action of logActions) { + const logActionsValues = Object.values(logActions); + if (!!logActionsValues.length) { + for (const action of logActionsValues) { alertingEventLogger.logAction(action); } } @@ -509,6 +554,7 @@ export class ExecutionHandler< typeId: this.ruleType.id, }, ], + actionTypeId: action.actionTypeId, }; } diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 467d7460afc2b..64c798b868db1 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -395,13 +395,16 @@ export const generateEnqueueFunctionInput = ({ isBulk = false, isResolved, foo, + actionTypeId, }: { id: string; isBulk?: boolean; isResolved?: boolean; foo?: boolean; + actionTypeId?: string; }) => { const input = { + actionTypeId: actionTypeId || 'action', apiKey: 'MTIzOmFiYw==', executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', id, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index fcd2464058350..c0d8a1434aa3d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -237,6 +237,8 @@ describe('Task Runner', () => { logger.get.mockImplementation(() => logger); ruleType.executor.mockResolvedValue({ state: {} }); + + actionsClient.bulkEnqueueExecution.mockResolvedValue({ errors: false, items: [] }); }); test('successfully executes the task', async () => { @@ -299,7 +301,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 4, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ status: 'ok' }); @@ -381,7 +383,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 5, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ @@ -469,7 +471,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 6, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ @@ -723,7 +725,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 6, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":2,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":2,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":2,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":2,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } @@ -1168,7 +1170,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 6, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ @@ -1295,7 +1297,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 6, - `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}` + `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}` ); testAlertingEventLogCalls({ @@ -1490,7 +1492,7 @@ describe('Task Runner', () => { expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(enqueueFunction).toHaveBeenCalledWith( - generateEnqueueFunctionInput({ isBulk, id: '1', foo: true }) + generateEnqueueFunctionInput({ isBulk, id: '1', foo: true, actionTypeId: 'slack' }) ); } ); @@ -1562,7 +1564,7 @@ describe('Task Runner', () => { expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(enqueueFunction).toHaveBeenCalledWith( - generateEnqueueFunctionInput({ isBulk, id: '1', foo: true }) + generateEnqueueFunctionInput({ isBulk, id: '1', foo: true, actionTypeId: 'slack' }) ); expect(result.state.summaryActions).toEqual({ '111-111': { date: new Date(DATE_1970).toISOString() }, @@ -2440,7 +2442,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 4, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ @@ -2962,7 +2964,7 @@ describe('Task Runner', () => { status: 'warning', errorReason: `maxExecutableActions`, logAlert: 4, - logAction: 5, + logAction: 3, }); }); @@ -3146,6 +3148,7 @@ describe('Task Runner', () => { logAlert = 0, logAction = 0, hasReachedAlertLimit = false, + hasReachedQueuedActionsLimit = false, }: { status: string; ruleContext?: RuleContextOpts; @@ -3162,6 +3165,7 @@ describe('Task Runner', () => { errorReason?: string; errorMessage?: string; hasReachedAlertLimit?: boolean; + hasReachedQueuedActionsLimit?: boolean; }) { expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); if (status !== 'skip') { @@ -3215,6 +3219,7 @@ describe('Task Runner', () => { totalSearchDurationMs: 23423, hasReachedAlertLimit, triggeredActionsStatus: 'partial', + hasReachedQueuedActionsLimit, }, status: { lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), @@ -3250,6 +3255,7 @@ describe('Task Runner', () => { totalSearchDurationMs: 23423, hasReachedAlertLimit, triggeredActionsStatus: 'complete', + hasReachedQueuedActionsLimit, }, status: { lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts index 3ed2a63feacdc..98e0643abfd50 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts @@ -409,7 +409,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 5, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index 870cad51ed4b7..46783154a6a4a 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -191,6 +191,8 @@ describe('Task Runner Cancel', () => { alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); logger.get.mockImplementation(() => logger); + + actionsClient.bulkEnqueueExecution.mockResolvedValue({ errors: false, items: [] }); }); test('updates rule saved object execution status and writes to event log entry when task is cancelled mid-execution', async () => { @@ -470,7 +472,7 @@ describe('Task Runner Cancel', () => { ); expect(logger.debug).nthCalledWith( 8, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); } @@ -485,6 +487,7 @@ describe('Task Runner Cancel', () => { logAlert = 0, logAction = 0, hasReachedAlertLimit = false, + hasReachedQueuedActionsLimit = false, }: { status: string; ruleContext?: RuleContextOpts; @@ -497,6 +500,7 @@ describe('Task Runner Cancel', () => { logAlert?: number; logAction?: number; hasReachedAlertLimit?: boolean; + hasReachedQueuedActionsLimit?: boolean; }) { expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); expect(alertingEventLogger.start).toHaveBeenCalled(); @@ -515,6 +519,7 @@ describe('Task Runner Cancel', () => { totalSearchDurationMs: 23423, hasReachedAlertLimit, triggeredActionsStatus: 'complete', + hasReachedQueuedActionsLimit, }, status: { lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), diff --git a/x-pack/plugins/apm/kibana.jsonc b/x-pack/plugins/apm/kibana.jsonc index ed7f8fca0a241..906596709b189 100644 --- a/x-pack/plugins/apm/kibana.jsonc +++ b/x-pack/plugins/apm/kibana.jsonc @@ -58,7 +58,6 @@ "kibanaUtils", "ml", "observability", - "esUiShared", "maps", "observabilityAIAssistant" ] diff --git a/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx b/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx index 74b38ea153558..0c072f6fba128 100644 --- a/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_explorer/index.tsx @@ -78,7 +78,7 @@ export function TraceExplorer({ children }: { children: React.ReactElement }) { - + - + diff --git a/x-pack/plugins/apm/public/components/app/trace_explorer/trace_search_box/index.tsx b/x-pack/plugins/apm/public/components/app/trace_explorer/trace_search_box/index.tsx index 84d72000c7b88..6c83b02456b1c 100644 --- a/x-pack/plugins/apm/public/components/app/trace_explorer/trace_search_box/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_explorer/trace_search_box/index.tsx @@ -9,7 +9,6 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, - EuiText, EuiSelect, EuiSelectOption, } from '@elastic/eui'; @@ -23,12 +22,11 @@ import { } from '../../../../../common/trace_explorer'; import { useApmDataView } from '../../../../hooks/use_apm_data_view'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; -import { EQLCodeEditorSuggestionType } from '../../../shared/eql_code_editor/constants'; -import { LazilyLoadedEQLCodeEditor } from '../../../shared/eql_code_editor/lazily_loaded_code_editor'; + +import { EQLCodeEditor } from '../../../shared/monaco_code_editor'; interface Props { query: TraceSearchQuery; - message?: string; error: boolean; onQueryChange: (query: TraceSearchQuery) => void; onQueryCommit: () => void; @@ -54,8 +52,6 @@ export function TraceSearchBox({ query, onQueryChange, onQueryCommit, - message, - error, loading, }: Props) { const { unifiedSearch, core, data, dataViews } = useApmPluginContext(); @@ -67,57 +63,21 @@ export function TraceSearchBox({ const { dataView } = useApmDataView(); return ( - + {query.type === TraceSearchType.eql ? ( - { + onChange={(value: string) => { onQueryChange({ ...query, query: value, }); }} - onBlur={() => { - onQueryCommit(); - }} - getSuggestions={async (request) => { - switch (request.type) { - case EQLCodeEditorSuggestionType.EventType: - return ['transaction', 'span', 'error']; - - case EQLCodeEditorSuggestionType.Field: - return ( - dataView?.fields.map((field) => field.name) ?? [] - ); - - case EQLCodeEditorSuggestionType.Value: - const field = dataView?.getFieldByName(request.field); - - if (!dataView || !field) { - return []; - } - - const suggestions: string[] = - await unifiedSearch.autocomplete.getValueSuggestions( - { - field, - indexPattern: dataView, - query: request.value, - useTimeRange: true, - method: 'terms_agg', - } - ); - - return suggestions.slice(0, 15); - } - }} - width="100%" - height="100px" /> ) : (
@@ -176,33 +136,21 @@ export function TraceSearchBox({ - - + { + onQueryCommit(); + }} + iconType="search" + style={{ width: '100px' }} > - - - {message} - - - - { - onQueryCommit(); - }} - iconType="search" - > - {i18n.translate('xpack.apm.traceSearchBox.refreshButton', { - defaultMessage: 'Search', - })} - - - + {i18n.translate('xpack.apm.traceSearchBox.refreshButton', { + defaultMessage: 'Search', + })} + diff --git a/x-pack/plugins/apm/public/components/shared/date_picker/index.tsx b/x-pack/plugins/apm/public/components/shared/date_picker/index.tsx index 2bcc1c403ec85..4206a4f014472 100644 --- a/x-pack/plugins/apm/public/components/shared/date_picker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/date_picker/index.tsx @@ -105,6 +105,7 @@ export function DatePicker({ }} showUpdateButton={true} commonlyUsedRanges={commonlyUsedRanges} + width={'auto'} /> ); } diff --git a/x-pack/plugins/apm/public/components/shared/monaco_code_editor/index.tsx b/x-pack/plugins/apm/public/components/shared/monaco_code_editor/index.tsx new file mode 100644 index 0000000000000..ce6beaec758ad --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/monaco_code_editor/index.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; +import { monaco } from '@kbn/monaco'; + +interface Props { + value: string; + onChange: (val: string) => void; +} + +export function EQLCodeEditor({ value, onChange }: Props) { + return ( + + ); +} diff --git a/x-pack/plugins/apm/tsconfig.json b/x-pack/plugins/apm/tsconfig.json index 1b06fa44a8dd5..a9b8c332426c5 100644 --- a/x-pack/plugins/apm/tsconfig.json +++ b/x-pack/plugins/apm/tsconfig.json @@ -96,7 +96,8 @@ "@kbn/unified-field-list", "@kbn/discover-plugin", "@kbn/observability-ai-assistant-plugin", - "@kbn/apm-data-access-plugin" + "@kbn/apm-data-access-plugin", + "@kbn/monaco" ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index eb087b257bc1e..b06a6a6cec243 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -333,7 +333,10 @@ export class FleetPlugin implements Plugin( - setupRouteService.getSetupPath() + setupRouteService.getSetupPath(), + { + version: API_VERSIONS.public.v1, + } ); if (!isInitialized) { throw new Error('Unknown setup error'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index 8b6e4b444c135..408428535f6a7 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -136,6 +136,9 @@ const registerHttpRequestMockHelpers = ( error?: ResponseError ) => mockResponse('GET', `${INTERNAL_API_BASE_PATH}/indices/${indexName}`, response, error); + const setCreateIndexResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('PUT', `${INTERNAL_API_BASE_PATH}/indices/create`, response, error); + return { setLoadTemplatesResponse, setLoadIndicesResponse, @@ -155,6 +158,7 @@ const registerHttpRequestMockHelpers = ( setLoadNodesPluginsResponse, setLoadTelemetryResponse, setLoadIndexDetailsResponse, + setCreateIndexResponse, }; }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index 7591ca90f1596..7b3d33f88e3ef 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -64,4 +64,8 @@ export type TestSubjects = | 'updateEditIndexSettingsButton' | 'updateIndexSettingsErrorCallout' | 'viewButton' - | 'detailPanelTabSelected'; + | 'detailPanelTabSelected' + | 'createIndexButton' + | 'createIndexNameFieldText' + | 'createIndexCancelButton' + | 'createIndexSaveButton'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts index 8aaa356ca293e..f3ce7cbe71a95 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts @@ -40,6 +40,9 @@ export interface IndicesTestBed extends TestBed { clickManageContextMenuButton: () => void; clickContextMenuOption: (optionDataTestSubject: string) => void; clickModalConfirm: () => void; + clickCreateIndexButton: () => void; + clickCreateIndexCancelButton: () => void; + clickCreateIndexSaveButton: () => void; }; findDataStreamDetailPanel: () => ReactWrapper; findDataStreamDetailPanelTitle: () => string; @@ -135,6 +138,35 @@ export const setup = async ( return find('dataStreamDetailPanelTitle').text(); }; + const clickCreateIndexButton = async () => { + const { find, component } = testBed; + + await act(async () => { + find('createIndexButton').simulate('click'); + }); + component.update(); + }; + + const clickCreateIndexCancelButton = async () => { + const { find, exists, component } = testBed; + + expect(exists('createIndexCancelButton')).toBe(true); + await act(async () => { + find('createIndexCancelButton').simulate('click'); + }); + component.update(); + }; + + const clickCreateIndexSaveButton = async () => { + const { find, exists, component } = testBed; + + expect(exists('createIndexSaveButton')).toBe(true); + await act(async () => { + find('createIndexSaveButton').simulate('click'); + }); + component.update(); + }; + return { ...testBed, actions: { @@ -146,6 +178,9 @@ export const setup = async ( clickManageContextMenuButton, clickContextMenuOption, clickModalConfirm, + clickCreateIndexButton, + clickCreateIndexCancelButton, + clickCreateIndexSaveButton, }, findDataStreamDetailPanel, findDataStreamDetailPanelTitle, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts index 80c03726c3d40..41f4f3786d1cb 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts @@ -7,7 +7,7 @@ import { act } from 'react-dom/test-utils'; -import { API_BASE_PATH } from '../../../common'; +import { API_BASE_PATH, INTERNAL_API_BASE_PATH } from '../../../common'; import { setupEnvironment, nextTick } from '../helpers'; import { IndicesTestBed, setup } from './indices_tab.helpers'; import { createDataStreamPayload, createNonDataStreamIndex } from './data_streams_tab.helpers'; @@ -521,4 +521,72 @@ describe('', () => { }); }); }); + + describe('Create Index', () => { + const indexNameA = 'test-index-a'; + const indexNameB = 'test-index-b'; + const indexMockA = createNonDataStreamIndex(indexNameA); + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadIndicesResponse([ + { + ...indexMockA, + }, + ]); + + testBed = await setup(httpSetup, { + history: createMemoryHistory(), + }); + + await act(async () => { + const { component } = testBed; + + await nextTick(); + component.update(); + }); + }); + + test('shows the create index button', async () => { + const { exists } = testBed; + + expect(exists('createIndexButton')).toBe(true); + }); + + test('can open & close the create index modal', async () => { + const { exists, actions } = testBed; + + await actions.clickCreateIndexButton(); + + expect(exists('createIndexNameFieldText')).toBe(true); + + await await actions.clickCreateIndexCancelButton(); + + expect(exists('createIndexNameFieldText')).toBe(false); + }); + + test('creating an index', async () => { + const { component, exists, find, actions } = testBed; + + expect(httpSetup.get).toHaveBeenCalledTimes(1); + expect(httpSetup.get).toHaveBeenNthCalledWith(1, '/api/index_management/indices'); + + await actions.clickCreateIndexButton(); + + expect(exists('createIndexNameFieldText')).toBe(true); + await act(async () => { + find('createIndexNameFieldText').simulate('change', { target: { value: indexNameB } }); + }); + component.update(); + + await actions.clickCreateIndexSaveButton(); + + // Saves the index with expected name + expect(httpSetup.put).toHaveBeenCalledWith(`${INTERNAL_API_BASE_PATH}/indices/create`, { + body: '{"indexName":"test-index-b"}', + }); + // It refresh indices after saving + expect(httpSetup.get).toHaveBeenCalledTimes(2); + expect(httpSetup.get).toHaveBeenNthCalledWith(2, '/api/index_management/indices'); + }); + }); }); diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_button.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_button.tsx new file mode 100644 index 0000000000000..746d684f48b75 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_button.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiButton } from '@elastic/eui'; + +import { CreateIndexModal } from './create_index_modal'; + +export const CreateIndexButton = ({ loadIndices }: { loadIndices: () => void }) => { + const [createIndexModalOpen, setCreateIndexModalOpen] = useState(false); + + return ( + <> + setCreateIndexModalOpen(true)} + key="createIndexButton" + data-test-subj="createIndexButton" + data-telemetry-id="idxMgmt-indexList-createIndexButton" + > + + + {createIndexModalOpen && ( + setCreateIndexModalOpen(false)} + loadIndices={loadIndices} + /> + )} + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_modal.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_modal.tsx new file mode 100644 index 0000000000000..c54aaf0b12374 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_modal.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { createIndex } from '../../../../services'; +import { notificationService } from '../../../../services/notification'; + +import { isValidIndexName } from './utils'; + +const INVALID_INDEX_NAME_ERROR = i18n.translate( + 'xpack.idxMgmt.createIndex.modal.invalidName.error', + { defaultMessage: 'Index name is not valid' } +); + +export interface CreateIndexModalProps { + closeModal: () => void; + loadIndices: () => void; +} + +export const CreateIndexModal = ({ closeModal, loadIndices }: CreateIndexModalProps) => { + const [indexName, setIndexName] = useState(''); + const [indexNameError, setIndexNameError] = useState(); + const [isSaving, setIsSaving] = useState(false); + const [createError, setCreateError] = useState(); + + const putCreateIndex = useCallback(async () => { + setIsSaving(true); + try { + const { error } = await createIndex(indexName); + setIsSaving(false); + if (!error) { + loadIndices(); + closeModal(); + notificationService.showSuccessToast( + i18n.translate('xpack.idxMgmt.createIndex.successfullyCreatedIndexMessage', { + defaultMessage: 'Successfully created index: {indexName}', + values: { indexName }, + }) + ); + return; + } + setCreateError(error.message); + } catch (e) { + setIsSaving(false); + setCreateError(e.message); + } + }, [closeModal, indexName, loadIndices]); + + const onSave = () => { + if (isValidIndexName(indexName)) { + putCreateIndex(); + } + }; + + const onNameChange = (name: string) => { + setIndexName(name); + if (!isValidIndexName(name)) { + setIndexNameError(INVALID_INDEX_NAME_ERROR); + } else if (indexNameError) { + setIndexNameError(undefined); + } + }; + + return ( + + + + + + + + {createError && ( + <> + + + + + + + + )} + + + onNameChange(e.target.value)} + data-test-subj="createIndexNameFieldText" + /> + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/utils.test.ts b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/utils.test.ts new file mode 100644 index 0000000000000..f967e7294a35b --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/utils.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isValidIndexName } from './utils'; + +describe('create index utilities', () => { + describe('isValidIndexName', () => { + it('returns undefined for a valid name', () => { + expect(isValidIndexName('my-index')).toBe(true); + }); + it('returns error for empty name', () => { + expect(isValidIndexName('')).toBe(false); + }); + it('returns error name is not lower case', () => { + expect(isValidIndexName('MyIndexName')).toBe(false); + }); + it('returns error for .', () => { + expect(isValidIndexName('.')).toBe(false); + }); + it('returns error for ..', () => { + expect(isValidIndexName('..')).toBe(false); + }); + it('returns error if name starts with -, _,., or +', () => { + expect(isValidIndexName('-index')).toBe(false); + expect(isValidIndexName('_index')).toBe(false); + expect(isValidIndexName('+index')).toBe(false); + expect(isValidIndexName('.index')).toBe(false); + + expect(isValidIndexName('index-name')).toBe(true); + expect(isValidIndexName('index_name')).toBe(true); + expect(isValidIndexName('index+name')).toBe(true); + expect(isValidIndexName('index.name')).toBe(true); + }); + it('returns error if name contains spaces', () => { + expect(isValidIndexName('index name')).toBe(false); + }); + it('returns error if name contains special characters', () => { + expect(isValidIndexName('index/name')).toBe(false); + expect(isValidIndexName('index\\name')).toBe(false); + expect(isValidIndexName('index*name')).toBe(false); + expect(isValidIndexName('index?name')).toBe(false); + expect(isValidIndexName('index"name')).toBe(false); + expect(isValidIndexName('indexname')).toBe(false); + expect(isValidIndexName('index|name')).toBe(false); + expect(isValidIndexName('index,name')).toBe(false); + expect(isValidIndexName('index#name')).toBe(false); + expect(isValidIndexName('index:name')).toBe(false); + }); + it('returns error exceeds 255 bytes', () => { + const indexName = `this-is-a-long-index-name-with-unicode-characters🔥☕️🔥🔥-${'0'.repeat( + 200 + )}`; + + expect(isValidIndexName(indexName)).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/utils.ts b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/utils.ts new file mode 100644 index 0000000000000..cc2661c24faa0 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/utils.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html for the current rules + +export function isValidIndexName(name: string) { + const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; + const reg = new RegExp('[\\\\/:*?"<>|\\s,#]+'); + const indexPatternInvalid = + byteLength > 255 || // name can't be greater than 255 bytes + name !== name.toLowerCase() || // name should be lowercase + name.match(/^[-_+.]/) !== null || // name can't start with these chars + name.match(reg) !== null || // name can't contain these chars + name.length === 0; // name can't be empty + + return !indexPatternInvalid; +} diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js index 5284de7c537cb..94440da220fca 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js @@ -50,6 +50,7 @@ import { AppContextConsumer } from '../../../../app_context'; import { renderBadges } from '../../../../lib/render_badges'; import { NoMatch, DataHealth } from '../../../../components'; import { IndexActionsContextMenu } from '../index_actions_context_menu'; +import { CreateIndexButton } from '../create_index/create_index_button'; const getHeaders = ({ showIndexStats }) => { const headers = {}; @@ -623,6 +624,9 @@ export class IndexTable extends Component { )} + + + {this.renderFilterError()} diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index b209e4632bd3c..0032d852e2827 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -347,3 +347,13 @@ export function useLoadIndexSettings(indexName: string) { method: 'get', }); } + +export function createIndex(indexName: string) { + return sendRequest({ + path: `${INTERNAL_API_BASE_PATH}/indices/create`, + method: 'put', + body: JSON.stringify({ + indexName, + }), + }); +} diff --git a/x-pack/plugins/index_management/public/application/services/index.ts b/x-pack/plugins/index_management/public/application/services/index.ts index 979f4268dc362..058a09d3f15d1 100644 --- a/x-pack/plugins/index_management/public/application/services/index.ts +++ b/x-pack/plugins/index_management/public/application/services/index.ts @@ -28,6 +28,7 @@ export { useLoadIndexMappings, loadIndexStatistics, useLoadIndexSettings, + createIndex, } from './api'; export { sortTable } from './sort_table'; diff --git a/x-pack/plugins/index_management/server/routes/api/indices/register_create_route.ts b/x-pack/plugins/index_management/server/routes/api/indices/register_create_route.ts new file mode 100644 index 0000000000000..b6de9596c77b7 --- /dev/null +++ b/x-pack/plugins/index_management/server/routes/api/indices/register_create_route.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IndicesCreateRequest } from '@elastic/elasticsearch/lib/api/types'; +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../../types'; +import { addInternalBasePath } from '..'; + +const bodySchema = schema.object({ + indexName: schema.string(), +}); + +export function registerCreateRoute({ router, lib: { handleEsError } }: RouteDependencies) { + router.put( + { path: addInternalBasePath('/indices/create'), validate: { body: bodySchema } }, + async (context, request, response) => { + const { client } = (await context.core).elasticsearch; + const { indexName } = request.body as typeof bodySchema.type; + + const params: IndicesCreateRequest = { + index: indexName, + }; + + try { + await client.asCurrentUser.indices.create(params); + return response.ok(); + } catch (error) { + return handleEsError({ error, response }); + } + } + ); +} diff --git a/x-pack/plugins/index_management/server/routes/api/indices/register_indices_routes.ts b/x-pack/plugins/index_management/server/routes/api/indices/register_indices_routes.ts index d73c9d375aade..5e9a8fd56442e 100644 --- a/x-pack/plugins/index_management/server/routes/api/indices/register_indices_routes.ts +++ b/x-pack/plugins/index_management/server/routes/api/indices/register_indices_routes.ts @@ -18,6 +18,7 @@ import { registerReloadRoute } from './register_reload_route'; import { registerDeleteRoute } from './register_delete_route'; import { registerUnfreezeRoute } from './register_unfreeze_route'; import { registerGetRoute } from './register_get_route'; +import { registerCreateRoute } from './register_create_route'; export function registerIndicesRoutes(dependencies: RouteDependencies) { registerClearCacheRoute(dependencies); @@ -31,4 +32,5 @@ export function registerIndicesRoutes(dependencies: RouteDependencies) { registerDeleteRoute(dependencies); registerUnfreezeRoute(dependencies); registerGetRoute(dependencies); + registerCreateRoute(dependencies); } diff --git a/x-pack/plugins/infra/public/common/visualizations/lens/dashboards/asset_details/host/host_metric_charts.ts b/x-pack/plugins/infra/public/common/visualizations/lens/dashboards/asset_details/host/host_metric_charts.ts index 0c31e1f2e6643..5da21aba45c33 100644 --- a/x-pack/plugins/infra/public/common/visualizations/lens/dashboards/asset_details/host/host_metric_charts.ts +++ b/x-pack/plugins/infra/public/common/visualizations/lens/dashboards/asset_details/host/host_metric_charts.ts @@ -16,12 +16,13 @@ import { memoryUsage, memoryUsageBreakdown } from '../metric_charts/memory'; import { rxTx } from '../metric_charts/network'; import type { XYConfig } from '../metric_charts/types'; -export const hostMetricCharts: XYConfig[] = [ +export const hostMetricFlyoutCharts: XYConfig[] = [ cpuUsage, memoryUsage, normalizedLoad1m, logRate, diskSpaceUsageAvailable, + diskSpaceUsageByMountPoint, diskThroughputReadWrite, diskIOReadWrite, rxTx, diff --git a/x-pack/plugins/infra/public/common/visualizations/lens/dashboards/asset_details/index.ts b/x-pack/plugins/infra/public/common/visualizations/lens/dashboards/asset_details/index.ts index 119a55de96854..4f85d0a7bfbac 100644 --- a/x-pack/plugins/infra/public/common/visualizations/lens/dashboards/asset_details/index.ts +++ b/x-pack/plugins/infra/public/common/visualizations/lens/dashboards/asset_details/index.ts @@ -5,12 +5,12 @@ * 2.0. */ -import { hostMetricCharts, hostMetricChartsFullPage } from './host/host_metric_charts'; +import { hostMetricFlyoutCharts, hostMetricChartsFullPage } from './host/host_metric_charts'; import { hostKPICharts, KPIChartProps } from './host/host_kpi_charts'; import { nginxAccessCharts, nginxStubstatusCharts } from './host/nginx_charts'; export { type KPIChartProps }; export const assetDetailsDashboards = { - host: { hostMetricCharts, hostMetricChartsFullPage, hostKPICharts }, - nginxDashboard: { nginxStubstatusCharts, nginxAccessCharts }, + host: { hostMetricFlyoutCharts, hostMetricChartsFullPage, hostKPICharts }, + nginx: { nginxStubstatusCharts, nginxAccessCharts }, }; diff --git a/x-pack/plugins/infra/public/components/asset_details/hooks/use_date_range.ts b/x-pack/plugins/infra/public/components/asset_details/hooks/use_date_range.ts index 7e98c56834529..24f5e56ecedb6 100644 --- a/x-pack/plugins/infra/public/components/asset_details/hooks/use_date_range.ts +++ b/x-pack/plugins/infra/public/components/asset_details/hooks/use_date_range.ts @@ -7,45 +7,66 @@ import type { TimeRange } from '@kbn/es-query'; import createContainer from 'constate'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useState } from 'react'; +import useEffectOnce from 'react-use/lib/useEffectOnce'; import { parseDateRange } from '../../../utils/datemath'; - import { toTimestampRange } from '../utils'; import { useAssetDetailsUrlState } from './use_asset_details_url_state'; -const DEFAULT_DATE_RANGE: TimeRange = { - from: 'now-15m', - to: 'now', -}; - export interface UseDateRangeProviderProps { initialDateRange: TimeRange; } +const DEFAULT_FROM_IN_MILLISECONDS = 15 * 60000; +const getDefaultDateRange = () => { + const now = Date.now(); + + return { + from: new Date(now - DEFAULT_FROM_IN_MILLISECONDS).toISOString(), + to: new Date(now).toISOString(), + }; +}; + export function useDateRangeProvider({ initialDateRange }: UseDateRangeProviderProps) { const [urlState, setUrlState] = useAssetDetailsUrlState(); const dateRange: TimeRange = urlState?.dateRange ?? initialDateRange; + const [parsedDateRange, setParsedDateRange] = useState(parseDateRange(dateRange)); + const [refreshTs, setRefreshTs] = useState(Date.now()); + + useEffectOnce(() => { + const { from, to } = getParsedDateRange(); + + // forces the date picker to initiallize with absolute dates. + setUrlState({ dateRange: { from, to } }); + }); const setDateRange = useCallback( (newDateRange: TimeRange) => { setUrlState({ dateRange: newDateRange }); + setParsedDateRange(parseDateRange(newDateRange)); + setRefreshTs(Date.now()); }, [setUrlState] ); - const parsedDateRange = useMemo(() => { - const { from = DEFAULT_DATE_RANGE.from, to = DEFAULT_DATE_RANGE.to } = - parseDateRange(dateRange); + const getParsedDateRange = useCallback(() => { + const defaultDateRange = getDefaultDateRange(); + const { from = defaultDateRange.from, to = defaultDateRange.to } = parsedDateRange; return { from, to }; - }, [dateRange]); + }, [parsedDateRange]); - const getDateRangeInTimestamp = useCallback( - () => toTimestampRange(parsedDateRange), - [parsedDateRange] - ); + const getDateRangeInTimestamp = useCallback(() => { + return toTimestampRange(getParsedDateRange()); + }, [getParsedDateRange]); - return { dateRange, setDateRange, getDateRangeInTimestamp }; + return { + dateRange, + getDateRangeInTimestamp, + getParsedDateRange, + refreshTs, + setDateRange, + }; } export const [DateRangeProvider, useDateRangeProviderContext] = diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/kpis/kpi_grid.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/kpis/kpi_grid.tsx index 7ffa55ed82979..011cb94c35253 100644 --- a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/kpis/kpi_grid.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/kpis/kpi_grid.tsx @@ -16,6 +16,7 @@ import { KPI_CHART_HEIGHT, AVERAGE_SUBTITLE, } from '../../../../../common/visualizations'; +import { useDateRangeProviderContext } from '../../../hooks/use_date_range'; interface Props { dataView?: DataView; @@ -24,6 +25,7 @@ interface Props { } export const KPIGrid = React.memo(({ nodeName, dataView, timeRange }: Props) => { + const { refreshTs } = useDateRangeProviderContext(); const filters = useMemo(() => { return [ buildCombinedHostsFilter({ @@ -43,6 +45,7 @@ export const KPIGrid = React.memo(({ nodeName, dataView, timeRange }: Props) => dataView={dataView} dateRange={timeRange} layers={{ ...layers, options: { ...layers.options, subtitle: AVERAGE_SUBTITLE } }} + lastReloadRequestTime={refreshTs} height={KPI_CHART_HEIGHT} filters={filters} title={title} diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_grid.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_grid.tsx index e84a1e19d4e0a..0ec0f51dcd18f 100644 --- a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_grid.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_grid.tsx @@ -15,20 +15,22 @@ import { MetricsSectionTitle, NginxMetricsSectionTitle } from '../../../componen interface Props { assetName: string; - timeRange: TimeRange; + dateRange: TimeRange; metricsDataView?: DataView; logsDataView?: DataView; } +const { host, nginx } = assetDetailsDashboards; + export const MetricsGrid = React.memo( - ({ assetName, metricsDataView, logsDataView, timeRange }: Props) => { + ({ assetName, metricsDataView, logsDataView, dateRange: timeRange }: Props) => { return ( <>
({ - ...n, + ...nginx.nginxStubstatusCharts.map((chart) => ({ + ...chart, dependsOn: ['nginx.stubstatus'], })), - ...assetDetailsDashboards.nginxDashboard.nginxAccessCharts.map((n) => ({ - ...n, + ...nginx.nginxAccessCharts.map((chart) => ({ + ...chart, dependsOn: ['nginx.access'], })), ]} @@ -63,13 +65,13 @@ export const MetricsGridCompact = ({ assetName, metricsDataView, logsDataView, - timeRange, + dateRange: timeRange, }: Props) => (
{ - const { dateRange } = useDateRangeProviderContext(); + const { getParsedDateRange } = useDateRangeProviderContext(); const { asset, assetType, renderMode } = useAssetDetailsRenderPropsContext(); const { metadata, @@ -32,18 +32,19 @@ export const Overview = () => { } = useMetadataStateProviderContext(); const { logs, metrics } = useDataViewsProviderContext(); + const parsedDateRange = getParsedDateRange(); const isFullPageView = renderMode.mode !== 'flyout'; const metricsSection = isFullPageView ? ( ) : ( { return ( - + {fetchMetadataError ? ( @@ -88,12 +89,16 @@ export const Overview = () => { /> ) : ( - <>{metadataSummarySection} + metadataSummarySection )} - + {metricsSection} diff --git a/x-pack/plugins/infra/public/components/lens/index.tsx b/x-pack/plugins/infra/public/components/lens/index.tsx index ae05bd8e82fe4..5da90461d1faa 100644 --- a/x-pack/plugins/infra/public/components/lens/index.tsx +++ b/x-pack/plugins/infra/public/components/lens/index.tsx @@ -10,3 +10,5 @@ export { ChartPlaceholder } from './chart_placeholder'; export { TooltipContent } from './metric_explanation/tooltip_content'; export { HostMetricsDocsLink } from './metric_explanation/host_metrics_docs_link'; export { HostMetricsExplanationContent } from './metric_explanation/host_metrics_explanation_content'; + +export * from './types'; diff --git a/x-pack/plugins/infra/public/components/lens/lens_chart.tsx b/x-pack/plugins/infra/public/components/lens/lens_chart.tsx index abcbe555a5d6a..8ff3348c43195 100644 --- a/x-pack/plugins/infra/public/components/lens/lens_chart.tsx +++ b/x-pack/plugins/infra/public/components/lens/lens_chart.tsx @@ -56,7 +56,7 @@ export const LensChart = ({ const sytle: CSSProperties = useMemo(() => ({ height }), [height]); - const Lens = ( + const lens = ( ); - - const getContent = () => { - if (!toolTip) { - return Lens; - } - - return ( - - {/* EuiToolTip forwards some event handlers to the child component. + const content = !toolTip ? ( + lens + ) : ( + + {/* EuiToolTip forwards some event handlers to the child component. Wrapping Lens inside a div prevents that from causing unnecessary re-renders */} -
{Lens}
-
- ); - }; +
{lens}
+
+ ); return ( - {error ? : getContent()} + {error ? : content} ); }; diff --git a/x-pack/plugins/infra/public/components/lens/lens_wrapper.tsx b/x-pack/plugins/infra/public/components/lens/lens_wrapper.tsx index 8513881c22ada..d5c70be73033a 100644 --- a/x-pack/plugins/infra/public/components/lens/lens_wrapper.tsx +++ b/x-pack/plugins/infra/public/components/lens/lens_wrapper.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; -import type { Action } from '@kbn/ui-actions-plugin/public'; + import { ViewMode } from '@kbn/embeddable-plugin/public'; import type { TimeRange } from '@kbn/es-query'; import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; @@ -15,16 +15,7 @@ import { LensAttributes } from '@kbn/lens-embeddable-utils'; import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; import { ChartLoadingProgress, ChartPlaceholder } from './chart_placeholder'; import { parseDateRange } from '../../utils/datemath'; - -export type LensWrapperProps = Omit< - TypedLensByValueInput, - 'timeRange' | 'attributes' | 'viewMode' -> & { - attributes: LensAttributes | null; - dateRange: TimeRange; - extraActions: Action[]; - loading?: boolean; -}; +import type { LensWrapperProps } from './types'; export const LensWrapper = ({ attributes, @@ -32,6 +23,7 @@ export const LensWrapper = ({ filters, lastReloadRequestTime, loading = false, + onLoad, query, ...props }: LensWrapperProps) => { @@ -85,11 +77,17 @@ export const LensWrapper = ({ query, ]); - const onLoad = useCallback(() => { - if (!embeddableLoaded) { - setEmbeddableLoaded(true); - } - }, [embeddableLoaded]); + const handleOnLoad = useCallback( + (isLoading: boolean) => { + if (!embeddableLoaded) { + setEmbeddableLoaded(true); + } + if (onLoad) { + onLoad(isLoading); + } + }, + [embeddableLoaded, onLoad] + ); const parsedDateRange: TimeRange = useMemo(() => { const { from = state.dateRange.from, to = state.dateRange.to } = parseDateRange( @@ -104,7 +102,6 @@ export const LensWrapper = ({
& { + attributes: LensAttributes | null; + dateRange: TimeRange; + extraActions: Action[]; + loading?: boolean; +}; + +export type BrushEndArgs = Parameters>[0]; export type BaseChartProps = Pick< LensWrapperProps, diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx index 065b6739c41fc..1fab43b5cd144 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx @@ -6,13 +6,11 @@ */ import React from 'react'; - import { useSourceContext } from '../../../../../containers/metrics_source'; import { useUnifiedSearchContext } from '../../hooks/use_unified_search'; import type { HostNodeRow } from '../../hooks/use_hosts_table'; import { AssetDetails } from '../../../../../components/asset_details/asset_details'; import { orderedFlyoutTabs } from './tabs'; -import { useAssetDetailsUrlState } from '../../../../../components/asset_details/hooks/use_asset_details_url_state'; export interface Props { node: HostNodeRow; @@ -22,13 +20,12 @@ export interface Props { export const FlyoutWrapper = ({ node: { name }, closeFlyout }: Props) => { const { source } = useSourceContext(); const { parsedDateRange } = useUnifiedSearchContext(); - const [urlState] = useAssetDetailsUrlState(); return source ? ( { - const { searchCriteria } = useUnifiedSearchContext(); + const { searchCriteria, parsedDateRange } = useUnifiedSearchContext(); const { dataView } = useMetricsDataViewContext(); const { requestTs, hostNodes, loading: hostsLoading } = useHostsViewContext(); const { data: hostCountData, isRequestRunning: hostCountLoading } = useHostCountContext(); @@ -52,7 +52,7 @@ export const Kpi = ({ id, title, layers, toolTip, height }: KPIChartProps & { he // we want it to reload only once the table has finished loading const { afterLoadedState } = useAfterLoadedState(loading, { lastReloadRequestTime: requestTs, - dateRange: searchCriteria.dateRange, + dateRange: parsedDateRange, query: shouldUseSearchCriteria ? searchCriteria.query : undefined, filters, }); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/chart.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/chart.tsx index 7bbf361e6b26e..e8acd3a05fac3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/chart.tsx @@ -23,7 +23,7 @@ export interface ChartProps extends Pick { - const { searchCriteria } = useUnifiedSearchContext(); + const { parsedDateRange, searchCriteria } = useUnifiedSearchContext(); const { dataView } = useMetricsDataViewContext(); const { requestTs, loading } = useHostsViewContext(); const { currentPage } = useHostsTableContext(); @@ -46,7 +46,7 @@ export const Chart = ({ id, title, layers, visualOptions, overrides }: ChartProp // we want it to reload only once the table has finished loading const { afterLoadedState } = useAfterLoadedState(loading, { lastReloadRequestTime: requestTs, - dateRange: searchCriteria.dateRange, + dateRange: parsedDateRange, query: shouldUseSearchCriteria ? searchCriteria.query : undefined, }); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_count.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_count.ts index 06ecb1a87e393..5d1f4766a775a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_count.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_count.ts @@ -27,7 +27,6 @@ export const useHostCount = () => { const { search: fetchHostCount, requests$ } = useDataSearch({ getRequest: useCallback(() => { const query = buildQuery(); - const dateRange = parsedDateRange; const filters: QueryDslQueryContainer = { bool: { @@ -42,8 +41,8 @@ export const useHostCount = () => { { range: { [dataView?.timeFieldName ?? '@timestamp']: { - gte: dateRange.from, - lte: dateRange.to, + gte: parsedDateRange.from, + lte: parsedDateRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index 318bbde58688d..e2bebf2aa0aff 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -35,6 +35,7 @@ import { getFoundExceptionListSchemaMock } from '../../common/schemas/response/f // TODO: This really belongs as: kbn-securitysolution-list-api/src/api/index.test.ts as soon as we can. const abortCtrl = new AbortController(); +const apiVersion = '2023-10-31'; describe('Exceptions Lists API', () => { let httpMock: ReturnType['http']; @@ -61,6 +62,7 @@ describe('Exceptions Lists API', () => { body: JSON.stringify(payload), method: 'POST', signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -109,6 +111,7 @@ describe('Exceptions Lists API', () => { body: JSON.stringify(payload), method: 'POST', signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -157,6 +160,7 @@ describe('Exceptions Lists API', () => { body: JSON.stringify(payload), method: 'PUT', signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -205,6 +209,7 @@ describe('Exceptions Lists API', () => { body: JSON.stringify(payload), method: 'PUT', signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -262,6 +267,7 @@ describe('Exceptions Lists API', () => { sort_order: 'desc', }, signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -300,6 +306,7 @@ describe('Exceptions Lists API', () => { sort_order: 'desc', }, signal: abortCtrl.signal, + version: apiVersion, }); expect(exceptionResponse.data).toEqual([getExceptionListSchemaMock()]); }); @@ -344,6 +351,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single', }, signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -402,6 +410,7 @@ describe('Exceptions Lists API', () => { sort_order: 'desc', }, signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -430,6 +439,7 @@ describe('Exceptions Lists API', () => { sort_order: 'desc', }, signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -458,6 +468,7 @@ describe('Exceptions Lists API', () => { sort_order: 'desc', }, signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -519,6 +530,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single', }, signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -568,6 +580,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single', }, signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -617,6 +630,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single', }, signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -660,6 +674,7 @@ describe('Exceptions Lists API', () => { expect(httpMock.fetch).toHaveBeenCalledWith('/api/endpoint_list', { method: 'POST', signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -714,6 +729,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single', }, signal: abortCtrl.signal, + version: apiVersion, }); }); @@ -752,6 +768,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single', }, signal: abortCtrl.signal, + version: apiVersion, }); }); }); diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx index 314221ca38773..719c71a3b853f 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_context_menu.tsx @@ -5,15 +5,22 @@ * 2.0. */ -import React, { FC, useCallback, useMemo, useState } from 'react'; +import React, { FC, ReactEventHandler, useCallback, useMemo, useState } from 'react'; import { + EuiButton, EuiButtonIcon, - EuiContextMenuItem, - EuiContextMenuPanel, + EuiContextMenu, + EuiContextMenuPanelDescriptor, + EuiContextMenuPanelItemDescriptor, + EuiFieldNumber, EuiFlexItem, + EuiForm, + EuiFormRow, + EuiPanel, EuiPopover, - EuiPopoverTitle, + EuiSpacer, formatDate, + htmlIdGenerator, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -22,11 +29,24 @@ import type { Query, TimeRange } from '@kbn/es-query'; import { isDefined } from '@kbn/ml-is-defined'; import { useTimeRangeUpdates } from '@kbn/ml-date-picker'; import { SEARCH_QUERY_LANGUAGE } from '@kbn/ml-query-utils'; +import { EuiContextMenuProps } from '@elastic/eui/src/components/context_menu/context_menu'; +import { + LazySavedObjectSaveModalDashboard, + SaveModalDashboardProps, + withSuspense, +} from '@kbn/presentation-util-plugin/public'; +import { useTableSeverity } from '../components/controls/select_severity'; +import { JobId } from '../../../common/types/anomaly_detection_jobs'; +import { getDefaultExplorerChartsPanelTitle } from '../../embeddables/anomaly_charts/anomaly_charts_embeddable'; +import { MAX_ANOMALY_CHARTS_ALLOWED } from '../../embeddables/anomaly_charts/anomaly_charts_initializer'; import { useAnomalyExplorerContext } from './anomaly_explorer_context'; import { escapeKueryForFieldValuePair } from '../util/string_utils'; import { useCasesModal } from '../contexts/kibana/use_cases_modal'; import { DEFAULT_MAX_SERIES_TO_PLOT } from '../services/anomaly_explorer_charts_service'; -import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '../../embeddables'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + AnomalyChartsEmbeddableInput, +} from '../../embeddables'; import { useMlKibana } from '../contexts/kibana'; import { AppStateSelectedCells, @@ -35,7 +55,6 @@ import { getSelectionTimeRange, } from './explorer_utils'; import { TimeRangeBounds } from '../util/time_buckets'; -import { AddAnomalyChartsToDashboardControl } from './dashboard_controls/add_anomaly_charts_to_dashboard_controls'; interface AnomalyContextMenuProps { selectedJobs: ExplorerJob[]; @@ -44,6 +63,16 @@ interface AnomalyContextMenuProps { interval?: number; chartsCount: number; } + +const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); + +function getDefaultEmbeddablePanelConfig(jobIds: JobId[], queryString?: string) { + return { + id: htmlIdGenerator()(), + title: getDefaultExplorerChartsPanelTitle(jobIds).concat(queryString ? `- ${queryString}` : ''), + }; +} + export const AnomalyContextMenu: FC = ({ selectedJobs, selectedCells, @@ -55,11 +84,14 @@ export const AnomalyContextMenu: FC = ({ services: { application: { capabilities }, cases, + embeddable, }, } = useMlKibana(); const globalTimeRange = useTimeRangeUpdates(true); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false); + const [severity] = useTableSeverity(); + const [maxSeriesToPlot, setMaxSeriesToPlot] = useState(DEFAULT_MAX_SERIES_TO_PLOT); const closePopoverOnAction = useCallback( (actionCallback: Function) => { setIsMenuOpen(false); @@ -103,77 +135,191 @@ export const AnomalyContextMenu: FC = ({ return globalTimeRange; }, [chartsData.seriesToPlot, globalTimeRange, selectedCells, bounds, interval]); - const menuItems = useMemo(() => { - const items = []; + const isMaxSeriesToPlotValid = + maxSeriesToPlot >= 1 && maxSeriesToPlot <= MAX_ANOMALY_CHARTS_ALLOWED; + + const jobIds = selectedJobs.map(({ id }) => id); + + const getEmbeddableInput = useCallback( + (timeRange?: TimeRange) => { + // Respect the query and the influencers selected + // If no query or filter set, filter out to the lanes the selected cells + // And if no selected cells, show everything + + const selectionInfluencers = getSelectionInfluencers( + selectedCells, + selectedCells?.viewByFieldName! + ); + + const influencers = selectionInfluencers ?? []; + const config = getDefaultEmbeddablePanelConfig(jobIds, queryString); + const queryFromSelectedCells = influencers + .map((s) => escapeKueryForFieldValuePair(s.fieldName, s.fieldValue)) + .join(' or '); + + // When adding anomaly charts to Dashboard, we want to respect the Dashboard's time range + // so we are not passing the time range here + return { + ...config, + ...(timeRange ? { timeRange } : {}), + jobIds, + maxSeriesToPlot: maxSeriesToPlot ?? DEFAULT_MAX_SERIES_TO_PLOT, + severityThreshold: severity.val, + ...((isDefined(queryString) && queryString !== '') || + (queryFromSelectedCells !== undefined && queryFromSelectedCells !== '') + ? { + query: { + query: queryString === '' ? queryFromSelectedCells : queryString, + language: SEARCH_QUERY_LANGUAGE.KUERY, + } as Query, + } + : {}), + }; + }, + [jobIds, maxSeriesToPlot, severity, queryString, selectedCells] + ); + + const onSaveCallback: SaveModalDashboardProps['onSave'] = useCallback( + ({ dashboardId, newTitle, newDescription }) => { + const stateTransfer = embeddable!.getStateTransfer(); + + const embeddableInput: Partial = { + ...getEmbeddableInput(), + title: newTitle, + description: newDescription, + }; + + const state = { + input: embeddableInput, + type: ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + }; + + const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`; + + stateTransfer.navigateToWithEmbeddablePackage('dashboards', { + state, + path, + }); + }, + [embeddable, getEmbeddableInput] + ); + + const panels = useMemo>(() => { + const rootItems: EuiContextMenuPanelItemDescriptor[] = []; + const menuPanels: EuiContextMenuPanelDescriptor[] = [{ id: 'panelActions', items: rootItems }]; + + const getContent = (callback: ReactEventHandler) => ( + + + + + ) : undefined + } + label={ + + } + > + setMaxSeriesToPlot(parseInt(e.target.value, 10))} + min={1} + max={MAX_ANOMALY_CHARTS_ALLOWED} + /> + + + + + + + + ); + if (canEditDashboards) { - items.push( - + rootItems.push({ + name: ( - - ); + ), + panel: 'addToDashboardPanel', + 'data-test-subj': 'mlAnomalyAddChartsToDashboardButton', + }); + + menuPanels.push({ + id: 'addToDashboardPanel', + size: 's', + title: i18n.translate('xpack.ml.explorer.anomalies.addToDashboardLabel', { + defaultMessage: 'Add to dashboard', + }), + content: getContent( + closePopoverOnAction.bind(null, setIsAddDashboardActive.bind(null, true)) + ), + }); } if (!!casesPrivileges?.create || !!casesPrivileges?.update) { - const selectionInfluencers = getSelectionInfluencers( - selectedCells, - selectedCells?.viewByFieldName! - ); - - const queryFromSelectedCells = Array.isArray(selectionInfluencers) - ? selectionInfluencers - .map((s) => escapeKueryForFieldValuePair(s.fieldName, s.fieldValue)) - .join(' or ') - : ''; + rootItems.push({ + name: ( + + ), + panel: 'addToCasePanel', + 'data-test-subj': 'mlAnomalyAttachChartsToCasesButton', + }); - items.push( - v.id), - timeRange: timeRangeToPlot, - maxSeriesToPlot: DEFAULT_MAX_SERIES_TO_PLOT, - ...((isDefined(queryString) && queryString !== '') || queryFromSelectedCells !== '' - ? { - query: { - query: queryString === '' ? queryFromSelectedCells : queryString, - language: SEARCH_QUERY_LANGUAGE.KUERY, - } as Query, - } - : {}), - }) - )} - data-test-subj="mlAnomalyAttachChartsToCasesButton" - > - - - ); + openCasesModal.bind(null, getEmbeddableInput(timeRangeToPlot)) + ) + ), + }); } - return items; - // eslint-disable-next-line react-hooks/exhaustive-deps + + return menuPanels; }, [ + getEmbeddableInput, canEditDashboards, - globalTimeRange, + casesPrivileges, + maxSeriesToPlot, + isMaxSeriesToPlotValid, closePopoverOnAction, - selectedJobs, - selectedCells, - queryString, + openCasesModal, timeRangeToPlot, ]); - const jobIds = selectedJobs.map(({ id }) => id); - return ( <> - {menuItems.length > 0 && chartsCount > 0 ? ( + {!!panels[0]?.items?.length && chartsCount > 0 ? ( = ({ panelPaddingSize="none" anchorPosition="downLeft" > - - {i18n.translate('xpack.ml.explorer.anomalies.actionsPopoverLabel', { - defaultMessage: 'Anomaly charts', - })} - - + ) : null} {isAddDashboardsActive && selectedJobs ? ( - { - setIsAddDashboardActive(false); + id)), }} - jobIds={jobIds} + onClose={setIsAddDashboardActive.bind(null, false)} + onSave={onSaveCallback} /> ) : null} diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index 0ba024a9c5f4f..4e0416a63b34a 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -21,6 +21,7 @@ import { EuiSpacer, EuiText, EuiTitle, + htmlIdGenerator, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -31,15 +32,21 @@ import { formatHumanReadableDateTime } from '@kbn/ml-date-utils'; import { isDefined } from '@kbn/ml-is-defined'; import { useTimeRangeUpdates } from '@kbn/ml-date-picker'; import { SEARCH_QUERY_LANGUAGE } from '@kbn/ml-query-utils'; +import { + LazySavedObjectSaveModalDashboard, + SaveModalDashboardProps, + withSuspense, +} from '@kbn/presentation-util-plugin/public'; +import { JobId } from '../../../common/types/anomaly_detection_jobs'; +import { getDefaultSwimlanePanelTitle } from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { useCasesModal } from '../contexts/kibana/use_cases_modal'; -import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '../..'; +import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, AnomalySwimlaneEmbeddableInput } from '../..'; import { OVERALL_LABEL, SWIMLANE_TYPE, SwimlaneType, VIEW_BY_JOB_LABEL, } from './explorer_constants'; -import { AddSwimlaneToDashboardControl } from './dashboard_controls/add_swimlane_to_dashboard_controls'; import { useMlKibana } from '../contexts/kibana'; import { ExplorerState } from './reducers/explorer_reducer'; import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found'; @@ -66,6 +73,15 @@ interface AnomalyTimelineProps { explorerState: ExplorerState; } +const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); + +function getDefaultEmbeddablePanelConfig(jobIds: JobId[], queryString?: string) { + return { + title: getDefaultSwimlanePanelTitle(jobIds).concat(queryString ? `- ${queryString}` : ''), + id: htmlIdGenerator()(), + }; +} + export const AnomalyTimeline: FC = React.memo( ({ explorerState }) => { const { @@ -73,6 +89,7 @@ export const AnomalyTimeline: FC = React.memo( application: { capabilities }, charts: chartsService, cases, + embeddable, }, } = useMlKibana(); @@ -88,7 +105,6 @@ export const AnomalyTimeline: FC = React.memo( ); const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false); const canEditDashboards = capabilities.dashboard?.createNew ?? false; @@ -147,6 +163,8 @@ export const AnomalyTimeline: FC = React.memo( anomalyTimelineStateService.getSwimLaneSeverity() ); + const [selectedSwimlane, setSelectedSwimlane] = useState(); + const timeRange = getTimeBoundsFromSelection(selectedCells); const viewByLoadedForTimeFormatted = timeRange @@ -210,9 +228,39 @@ export const AnomalyTimeline: FC = React.memo( defaultMessage="Add to dashboard" /> ), - onClick: closePopoverOnAction(setIsAddDashboardActive.bind(null, true)), + panel: 'addToDashboardPanel', 'data-test-subj': 'mlAnomalyTimelinePanelAddToDashboardButton', }); + + panels.push({ + id: 'addToDashboardPanel', + size: 's', + title: i18n.translate('xpack.ml.explorer.addToDashboardLabel', { + defaultMessage: 'Add to dashboard', + }), + items: [ + { + name: ( + + ), + + onClick: closePopoverOnAction(setSelectedSwimlane.bind(null, SWIMLANE_TYPE.OVERALL)), + 'data-test-subj': 'mlAnomalyTimelinePanelAddOverallToDashboardButton', + }, + { + name: ( + + ), + + onClick: closePopoverOnAction(setSelectedSwimlane.bind(null, SWIMLANE_TYPE.VIEW_BY)), + 'data-test-subj': 'mlAnomalyTimelinePanelAddViewByToDashboardButton', + }, + ], + }); } const casesPrivileges = cases?.helpers.canUseCases(); @@ -246,7 +294,7 @@ export const AnomalyTimeline: FC = React.memo( defaultMessage="Overall" /> ), - onClick: closePopoverOnAction(openCasesModal.bind(null, 'overall')), + onClick: closePopoverOnAction(openCasesModal.bind(null, SWIMLANE_TYPE.OVERALL)), 'data-test-subj': 'mlAnomalyTimelinePanelAttachOverallButton', }, { @@ -257,7 +305,7 @@ export const AnomalyTimeline: FC = React.memo( values={{ viewByField: viewBySwimlaneFieldName }} /> ), - onClick: closePopoverOnAction(openCasesModal.bind(null, 'viewBy')), + onClick: closePopoverOnAction(openCasesModal.bind(null, SWIMLANE_TYPE.VIEW_BY)), 'data-test-subj': 'mlAnomalyTimelinePanelAttachViewByButton', }, ], @@ -298,6 +346,45 @@ export const AnomalyTimeline: FC = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const onSaveCallback: SaveModalDashboardProps['onSave'] = useCallback( + ({ dashboardId, newTitle, newDescription }) => { + if (!selectedJobs) return; + + const stateTransfer = embeddable!.getStateTransfer(); + + const jobIds = selectedJobs.map((j) => j.id); + + const config = getDefaultEmbeddablePanelConfig(jobIds, queryString); + + const embeddableInput: Partial = { + id: config.id, + title: newTitle, + description: newDescription, + jobIds, + swimlaneType: selectedSwimlane, + ...(selectedSwimlane === SWIMLANE_TYPE.VIEW_BY + ? { viewBy: viewBySwimlaneFieldName } + : {}), + ...(queryString !== undefined + ? { query: { query: queryString, language: SEARCH_QUERY_LANGUAGE.KUERY } as Query } + : {}), + }; + + const state = { + input: embeddableInput, + type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + }; + + const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`; + + stateTransfer.navigateToWithEmbeddablePackage('dashboards', { + state, + path, + }); + }, + [embeddable, queryString, selectedJobs, selectedSwimlane, viewBySwimlaneFieldName] + ); + return ( <> @@ -513,19 +600,21 @@ export const AnomalyTimeline: FC = React.memo( /> )} - {isAddDashboardsActive && selectedJobs && ( - { - setIsAddDashboardActive(false); - if (callback) { - await callback(); - } + {selectedSwimlane && selectedJobs ? ( + id)), + }} + onClose={() => { + setSelectedSwimlane(undefined); }} - jobIds={selectedJobs.map(({ id }) => id)} - viewBy={viewBySwimlaneFieldName!} - queryString={queryString} + onSave={onSaveCallback} /> - )} + ) : null} ); }, diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx deleted file mode 100644 index 9ca48863c1670..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_anomaly_charts_to_dashboard_controls.tsx +++ /dev/null @@ -1,152 +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, { FC, useCallback, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFieldNumber, EuiFormRow, htmlIdGenerator } from '@elastic/eui'; -import type { Query } from '@kbn/es-query'; -import useObservable from 'react-use/lib/useObservable'; -import { isDefined } from '@kbn/ml-is-defined'; -import { SEARCH_QUERY_LANGUAGE } from '@kbn/ml-query-utils'; -import { getSelectionInfluencers } from '../explorer_utils'; -import { useAnomalyExplorerContext } from '../anomaly_explorer_context'; -import { escapeKueryForFieldValuePair } from '../../util/string_utils'; -import { useDashboardTable } from './use_dashboards_table'; -import { AddToDashboardControl } from './add_to_dashboard_controls'; -import { useAddToDashboardActions } from './use_add_to_dashboard_actions'; -import { DEFAULT_MAX_SERIES_TO_PLOT } from '../../services/anomaly_explorer_charts_service'; -import { JobId } from '../../../../common/types/anomaly_detection_jobs'; -import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '../../../embeddables'; -import { getDefaultExplorerChartsPanelTitle } from '../../../embeddables/anomaly_charts/anomaly_charts_embeddable'; -import { useTableSeverity } from '../../components/controls/select_severity'; -import { MAX_ANOMALY_CHARTS_ALLOWED } from '../../../embeddables/anomaly_charts/anomaly_charts_initializer'; - -function getDefaultEmbeddablePanelConfig(jobIds: JobId[], queryString?: string) { - return { - id: htmlIdGenerator()(), - title: getDefaultExplorerChartsPanelTitle(jobIds).concat(queryString ? `- ${queryString}` : ''), - }; -} - -export interface AddToDashboardControlProps { - jobIds: string[]; - onClose: (callback?: () => Promise) => void; -} - -/** - * Component for attaching anomaly swim lane embeddable to dashboards. - */ -export const AddAnomalyChartsToDashboardControl: FC = ({ - onClose, - jobIds, -}) => { - const [severity] = useTableSeverity(); - const [maxSeriesToPlot, setMaxSeriesToPlot] = useState(DEFAULT_MAX_SERIES_TO_PLOT); - const { anomalyExplorerCommonStateService, anomalyTimelineStateService } = - useAnomalyExplorerContext(); - const { queryString } = useObservable( - anomalyExplorerCommonStateService.getFilterSettings$(), - anomalyExplorerCommonStateService.getFilterSettings() - ); - - const selectedCells = useObservable( - anomalyTimelineStateService.getSelectedCells$(), - anomalyTimelineStateService.getSelectedCells() - ); - - const getEmbeddableInput = useCallback(() => { - // Respect the query and the influencers selected - // If no query or filter set, filter out to the lanes the selected cells - // And if no selected cells, show everything - - const selectionInfluencers = getSelectionInfluencers( - selectedCells, - selectedCells?.viewByFieldName! - ); - - const influencers = selectionInfluencers ?? []; - const config = getDefaultEmbeddablePanelConfig(jobIds, queryString); - const queryFromSelectedCells = influencers - .map((s) => escapeKueryForFieldValuePair(s.fieldName, s.fieldValue)) - .join(' or '); - - // When adding anomaly charts to Dashboard, we want to respect the Dashboard's time range - // so we are not passing the time range here - return { - ...config, - jobIds, - maxSeriesToPlot: maxSeriesToPlot ?? DEFAULT_MAX_SERIES_TO_PLOT, - severityThreshold: severity.val, - ...((isDefined(queryString) && queryString !== '') || - (queryFromSelectedCells !== undefined && queryFromSelectedCells !== '') - ? { - query: { - query: queryString === '' ? queryFromSelectedCells : queryString, - language: SEARCH_QUERY_LANGUAGE.KUERY, - } as Query, - } - : {}), - }; - }, [jobIds, maxSeriesToPlot, severity, queryString, selectedCells]); - - const { dashboardItems, isLoading, search } = useDashboardTable(); - const { addToDashboardAndEditCallback } = useAddToDashboardActions( - ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, - getEmbeddableInput - ); - const title = ( - - ); - - const isMaxSeriesToPlotValid = - maxSeriesToPlot >= 1 && maxSeriesToPlot <= MAX_ANOMALY_CHARTS_ALLOWED; - const extraControls = ( - - ) : undefined - } - label={ - - } - > - setMaxSeriesToPlot(parseInt(e.target.value, 10))} - min={1} - max={MAX_ANOMALY_CHARTS_ALLOWED} - /> - - ); - - return ( - - {extraControls} - - ); -}; diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_swimlane_to_dashboard_controls.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_swimlane_to_dashboard_controls.tsx deleted file mode 100644 index 4dcfdbf826b1d..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_swimlane_to_dashboard_controls.tsx +++ /dev/null @@ -1,144 +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, { FC, useCallback, useState } from 'react'; -import { - EuiFormRow, - EuiInMemoryTableProps, - EuiSpacer, - EuiRadioGroup, - htmlIdGenerator, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; -import type { Query } from '@kbn/es-query'; -import { SEARCH_QUERY_LANGUAGE } from '@kbn/ml-query-utils'; -import { getDefaultSwimlanePanelTitle } from '../../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; -import { SWIMLANE_TYPE, SwimlaneType } from '../explorer_constants'; -import { JobId } from '../../../../common/types/anomaly_detection_jobs'; -import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '../../../embeddables'; -import { useDashboardTable } from './use_dashboards_table'; -import { AddToDashboardControl } from './add_to_dashboard_controls'; -import { useAddToDashboardActions } from './use_add_to_dashboard_actions'; - -export interface DashboardItem { - id: string; - title: string; - description: string | undefined; - attributes: DashboardAttributes; -} - -export type EuiTableProps = EuiInMemoryTableProps; - -function getDefaultEmbeddablePanelConfig(jobIds: JobId[], queryString?: string) { - return { - title: getDefaultSwimlanePanelTitle(jobIds).concat(queryString ? `- ${queryString}` : ''), - id: htmlIdGenerator()(), - }; -} - -interface AddToDashboardControlProps { - jobIds: JobId[]; - viewBy: string; - onClose: (callback?: () => Promise) => void; - queryString?: string; -} - -/** - * Component for attaching anomaly swim lane embeddable to dashboards. - */ -export const AddSwimlaneToDashboardControl: FC = ({ - onClose, - jobIds, - viewBy, - queryString, -}) => { - const { dashboardItems, isLoading, search } = useDashboardTable(); - - const [selectedSwimlane, setSelectedSwimlane] = useState(SWIMLANE_TYPE.OVERALL); - - const getEmbeddableInput = useCallback(() => { - const config = getDefaultEmbeddablePanelConfig(jobIds, queryString); - - return { - ...config, - jobIds, - swimlaneType: selectedSwimlane, - ...(selectedSwimlane === SWIMLANE_TYPE.VIEW_BY ? { viewBy } : {}), - ...(queryString !== undefined - ? { query: { query: queryString, language: SEARCH_QUERY_LANGUAGE.KUERY } as Query } - : {}), - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedSwimlane]); - - const { addToDashboardAndEditCallback } = useAddToDashboardActions( - ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, - getEmbeddableInput - ); - - const swimlaneTypeOptions = [ - { - id: SWIMLANE_TYPE.OVERALL, - label: i18n.translate('xpack.ml.explorer.overallLabel', { - defaultMessage: 'Overall', - }), - }, - { - id: SWIMLANE_TYPE.VIEW_BY, - label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', { - defaultMessage: 'View by {viewByField}', - values: { viewByField: viewBy }, - }), - }, - ]; - - const extraControls = ( - <> - - } - > - { - setSelectedSwimlane(optionId as SwimlaneType); - }} - data-test-subj="mlAddToDashboardSwimlaneTypeSelector" - /> - - - - ); - - const title = ( - - ); - - return ( - - {extraControls} - - ); -}; diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_to_dashboard_controls.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_to_dashboard_controls.tsx deleted file mode 100644 index d030cfc9b92b6..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/add_to_dashboard_controls.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React, { FC } from 'react'; -import { - EuiButtonEmpty, - EuiFormRow, - EuiInMemoryTable, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { DashboardItem, EuiTableProps, useDashboardTable } from './use_dashboards_table'; - -interface AddToDashboardControlProps extends ReturnType { - onClose: (callback?: () => Promise) => void; - addToDashboardAndEditCallback: (dashboardItem: DashboardItem) => Promise; - title: React.ReactNode; - disabled: boolean; - children?: React.ReactElement; -} -export const AddToDashboardControl: FC = ({ - onClose, - dashboardItems, - isLoading, - search, - addToDashboardAndEditCallback, - title, - disabled, - children, -}) => { - const columns: EuiTableProps['columns'] = [ - { - field: 'title', - name: i18n.translate('xpack.ml.explorer.dashboardsTable.titleColumnHeader', { - defaultMessage: 'Title', - }), - sortable: true, - truncateText: true, - }, - { - field: 'description', - name: i18n.translate('xpack.ml.explorer.dashboardsTable.descriptionColumnHeader', { - defaultMessage: 'Description', - }), - truncateText: true, - }, - { - field: 'description', - name: i18n.translate('xpack.ml.explorer.dashboardsTable.actionsHeader', { - defaultMessage: 'Actions', - }), - width: '80px', - actions: [ - { - name: i18n.translate('xpack.ml.explorer.dashboardsTable.editActionName', { - defaultMessage: 'Add to dashboard', - }), - description: i18n.translate('xpack.ml.explorer.dashboardsTable.editActionName', { - defaultMessage: 'Add to dashboard', - }), - icon: 'documentEdit', - type: 'icon', - enabled: () => !disabled, - onClick: async (item) => { - await addToDashboardAndEditCallback(item); - }, - 'data-test-subj': 'mlEmbeddableAddAndEditDashboard', - }, - ], - }, - ]; - - return ( - - - {title} - - - {children} - - } - data-test-subj="mlDashboardSelectionContainer" - > - ({ - 'data-test-subj': `mlDashboardSelectionTableRow row-${item.id}`, - })} - /> - - - - - - - - - ); -}; diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx deleted file mode 100644 index b47dea3a5e959..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx +++ /dev/null @@ -1,55 +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 { useCallback } from 'react'; -import { DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; -import { DashboardItem } from './use_dashboards_table'; -import { useMlKibana } from '../../contexts/kibana'; -import { useDashboardService } from '../../services/dashboard_service'; -import { - ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, - ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, - AnomalyChartsEmbeddableInput, - AnomalySwimlaneEmbeddableInput, -} from '../../../embeddables'; - -export function useAddToDashboardActions< - T extends typeof ANOMALY_SWIMLANE_EMBEDDABLE_TYPE | typeof ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE ->( - type: T, - getEmbeddableInput: () => Partial< - T extends typeof ANOMALY_SWIMLANE_EMBEDDABLE_TYPE - ? AnomalySwimlaneEmbeddableInput - : AnomalyChartsEmbeddableInput - > -) { - const { - services: { embeddable }, - } = useMlKibana(); - const dashboardService = useDashboardService(); - - const addToDashboardAndEditCallback = useCallback( - async (selectedDashboard: DashboardItem) => { - const stateTransfer = embeddable.getStateTransfer(); - const selectedDashboardId = selectedDashboard.id; - - const dashboardPath = await dashboardService.getDashboardEditUrl(selectedDashboardId); - - await stateTransfer.navigateToWithEmbeddablePackage(DASHBOARD_APP_ID, { - path: dashboardPath, - state: { - type, - input: getEmbeddableInput(), - }, - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [getEmbeddableInput] - ); - - return { addToDashboardAndEditCallback }; -} diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_dashboards_table.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_dashboards_table.tsx deleted file mode 100644 index a561164789a4b..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_dashboards_table.tsx +++ /dev/null @@ -1,82 +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 type { EuiInMemoryTableProps } from '@elastic/eui'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { debounce } from 'lodash'; -import type { DashboardAttributes } from '@kbn/dashboard-plugin/common'; -import { useDashboardService } from '../../services/dashboard_service'; -import { useMlKibana } from '../../contexts/kibana'; - -export interface DashboardItem { - id: string; - title: string; - description: string | undefined; - attributes: DashboardAttributes; -} - -export type EuiTableProps = EuiInMemoryTableProps; - -export const useDashboardTable = () => { - const { - notifications: { toasts }, - } = useMlKibana(); - - const dashboardService = useDashboardService(); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - fetchDashboards(); - - return () => { - fetchDashboards.cancel(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const search: EuiTableProps['search'] = useMemo(() => { - return { - onChange: ({ queryText }) => { - setIsLoading(true); - fetchDashboards(queryText); - }, - box: { - incremental: true, - 'data-test-subj': 'mlDashboardsSearchBox', - }, - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [dashboardItems, setDashboardItems] = useState([]); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const fetchDashboards = useCallback( - debounce(async (query?: string) => { - try { - const response = await dashboardService.fetchDashboards(query); - const items: DashboardItem[] = response.map((savedObject) => { - return { - id: savedObject.id, - title: savedObject.attributes.title, - description: savedObject.attributes.description, - attributes: savedObject.attributes, - }; - }); - setDashboardItems(items); - } catch (e) { - toasts.danger({ - body: e, - }); - } - setIsLoading(false); - }, 500), - [] - ); - - return { dashboardItems, search, isLoading }; -}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 0ceb960310346..5a3e129bbfaaa 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -103,7 +103,7 @@ interface ExplorerUrlStateManagerProps { const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => { const { - services: { cases }, + services: { cases, presentationUtil }, } = useMlKibana(); const [globalState] = useUrlState('_g'); @@ -253,6 +253,7 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } const CasesContext = cases?.ui.getCasesContext() ?? React.Fragment; + const PresentationContextProvider = presentationUtil?.ContextProvider ?? React.Fragment; const casesPermissions = cases?.helpers.canUseCases(); @@ -277,25 +278,27 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim - {jobsWithTimeRange.length === 0 ? ( - - ) : ( - - )} + + {jobsWithTimeRange.length === 0 ? ( + + ) : ( + + )} +
diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts b/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts index 4e355e01cfc32..2f0a505ba1f55 100644 --- a/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts @@ -5,18 +5,35 @@ * 2.0. */ +import { loggerMock } from '@kbn/logging-mocks'; import { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured_actions_client/unsecured_actions_client.mock'; import { ConnectorsEmailService } from './connectors_email_service'; import type { PlainTextEmail, HTMLEmail } from './types'; +import { ExecutionResponseType } from '@kbn/actions-plugin/server/create_execute_function'; const REQUESTER_ID = 'requesterId'; const CONNECTOR_ID = 'connectorId'; describe('sendPlainTextEmail()', () => { + const logger = loggerMock.create(); + beforeEach(() => { + loggerMock.clear(logger); + }); + describe('calls the provided ActionsClient#bulkEnqueueExecution() with the appropriate params', () => { it(`omits the 'relatedSavedObjects' field if no context is provided`, () => { const actionsClient = unsecuredActionsClientMock.create(); - const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.SUCCESS, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); const payload: PlainTextEmail = { to: ['user1@email.com'], subject: 'This is a notification email', @@ -40,7 +57,17 @@ describe('sendPlainTextEmail()', () => { it(`populates the 'relatedSavedObjects' field if context is provided`, () => { const actionsClient = unsecuredActionsClientMock.create(); - const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.SUCCESS, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); const payload: PlainTextEmail = { to: ['user1@email.com', 'user2@email.com', 'user3@email.com'], subject: 'This is a notification email', @@ -107,14 +134,53 @@ describe('sendPlainTextEmail()', () => { }, ]); }); + + it(`logs an error when the maximum number of queued actions has been reached`, async () => { + const actionsClient = unsecuredActionsClientMock.create(); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: true, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); + const payload: PlainTextEmail = { + to: ['user1@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + }; + + await email.sendPlainTextEmail(payload); + + expect(logger.warn).toHaveBeenCalled(); + }); }); }); describe('sendHTMLEmail()', () => { + const logger = loggerMock.create(); + beforeEach(() => { + loggerMock.clear(logger); + }); + describe('calls the provided ActionsClient#bulkEnqueueExecution() with the appropriate params', () => { it(`omits the 'relatedSavedObjects' field if no context is provided`, () => { const actionsClient = unsecuredActionsClientMock.create(); - const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.SUCCESS, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); const payload: HTMLEmail = { to: ['user1@email.com'], subject: 'This is a notification email', @@ -140,7 +206,17 @@ describe('sendHTMLEmail()', () => { it(`populates the 'relatedSavedObjects' field if context is provided`, () => { const actionsClient = unsecuredActionsClientMock.create(); - const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.SUCCESS, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); const payload: HTMLEmail = { to: ['user1@email.com', 'user2@email.com', 'user3@email.com'], subject: 'This is a notification email', @@ -211,5 +287,29 @@ describe('sendHTMLEmail()', () => { }, ]); }); + it(`logs an error when the maximum number of queued actions has been reached`, async () => { + const actionsClient = unsecuredActionsClientMock.create(); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: true, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); + const payload: HTMLEmail = { + to: ['user1@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + messageHTML: 'With some contents inside.', + }; + + await email.sendHTMLEmail(payload); + + expect(logger.warn).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service.ts b/x-pack/plugins/notifications/server/services/connectors_email_service.ts index 91958ce12d133..94acd532cdac5 100755 --- a/x-pack/plugins/notifications/server/services/connectors_email_service.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service.ts @@ -6,13 +6,19 @@ */ import type { IUnsecuredActionsClient } from '@kbn/actions-plugin/server'; +import { + ExecutionResponseItem, + ExecutionResponseType, +} from '@kbn/actions-plugin/server/create_execute_function'; +import type { Logger } from '@kbn/core/server'; import type { EmailService, PlainTextEmail, HTMLEmail } from './types'; export class ConnectorsEmailService implements EmailService { constructor( private requesterId: string, private connectorId: string, - private actionsClient: IUnsecuredActionsClient + private actionsClient: IUnsecuredActionsClient, + private logger: Logger ) {} async sendPlainTextEmail(params: PlainTextEmail): Promise { @@ -25,7 +31,11 @@ export class ConnectorsEmailService implements EmailService { }, relatedSavedObjects: params.context?.relatedObjects, })); - return await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions); + + const response = await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions); + if (response.errors) { + this.logEnqueueExecutionResponse(response.items); + } } async sendHTMLEmail(params: HTMLEmail): Promise { @@ -40,6 +50,19 @@ export class ConnectorsEmailService implements EmailService { relatedSavedObjects: params.context?.relatedObjects, })); - return await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions); + const response = await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions); + if (response.errors) { + this.logEnqueueExecutionResponse(response.items); + } + } + + private logEnqueueExecutionResponse(items: ExecutionResponseItem[]) { + for (const r of items) { + if (r.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR) { + this.logger.warn( + `Skipped scheduling action "${r.id}" because the maximum number of queued actions has been reached.` + ); + } + } } } diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts index 7db7502640054..7c7cfacef5c35 100644 --- a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts @@ -235,7 +235,8 @@ describe('ConnectorsEmailServiceProvider', () => { expect(connectorsEmailServiceMock).toHaveBeenCalledWith( PLUGIN_ID, validConnectorConfig.connectors.default.email, - actionsStart.getUnsecuredActionsClient() + actionsStart.getUnsecuredActionsClient(), + logger ); }); }); diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts index b3364f31d3689..5c631005c969e 100755 --- a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts @@ -71,7 +71,12 @@ export class EmailServiceProvider try { const unsecuredActionsClient = actions.getUnsecuredActionsClient(); email = new LicensedEmailService( - new ConnectorsEmailService(PLUGIN_ID, emailConnector, unsecuredActionsClient), + new ConnectorsEmailService( + PLUGIN_ID, + emailConnector, + unsecuredActionsClient, + this.logger + ), licensing.license$, MINIMUM_LICENSE, this.logger diff --git a/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx index cd5915eb3ef03..7498e7c0e8d6e 100644 --- a/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { DataViewBase } from '@kbn/es-query'; +import moment from 'moment'; +import React, { useEffect, useMemo } from 'react'; +import { DataViewBase, Query } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useEffect, useMemo } from 'react'; -import moment from 'moment'; import { EuiFlexGroup, EuiFlexItem, @@ -39,12 +39,7 @@ import { MetricsExplorerChartType } from '../hooks/use_metrics_explorer_options' import { MetricThresholdRuleTypeParams } from '../types'; // TODO Use a generic props for app sections https://github.com/elastic/kibana/issues/152690 -export type MetricThresholdRule = Rule< - MetricThresholdRuleTypeParams & { - filterQuery?: string; - groupBy?: string | string[]; - } ->; +export type MetricThresholdRule = Rule; export type MetricThresholdAlert = TopAlert; const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm'; @@ -163,7 +158,7 @@ export default function AlertDetailsAppSection({ chartType={MetricsExplorerChartType.line} derivedIndexPattern={derivedIndexPattern} expression={criterion} - filterQuery={rule.params.filterQuery} + filterQuery={(rule.params.searchConfiguration?.query as Query)?.query as string} groupBy={rule.params.groupBy} hideTitle timeRange={timeRange} diff --git a/x-pack/plugins/observability/public/components/threshold/components/validation.tsx b/x-pack/plugins/observability/public/components/threshold/components/validation.tsx index 8917e8dac2f35..4f5c818d7943d 100644 --- a/x-pack/plugins/observability/public/components/threshold/components/validation.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/validation.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { Query, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { buildEsQuery, fromKueryExpression } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public'; @@ -28,11 +28,9 @@ const isCustomMetricExpressionParams = ( export function validateMetricThreshold({ criteria, searchConfiguration, - filterQuery, }: { criteria: MetricExpressionParams[]; searchConfiguration: SerializedSearchSourceFields; - filterQuery?: string; }): ValidationResult { const validationResult = { errors: {} }; const errors: { @@ -67,9 +65,13 @@ export function validateMetricThreshold({ ]; } - if (filterQuery) { + if (searchConfiguration && searchConfiguration.query) { try { - buildEsQuery(undefined, [{ query: filterQuery, language: 'kuery' }], []); + buildEsQuery( + undefined, + [{ query: (searchConfiguration.query as Query).query, language: 'kuery' }], + [] + ); } catch (e) { errors.filterQuery = [ i18n.translate('xpack.observability.threshold.rule.alertFlyout.error.invalidFilterQuery', { diff --git a/x-pack/plugins/observability/public/components/threshold/mocks/metric_threshold_rule.ts b/x-pack/plugins/observability/public/components/threshold/mocks/metric_threshold_rule.ts index 98f20b455267e..478d27484df0d 100644 --- a/x-pack/plugins/observability/public/components/threshold/mocks/metric_threshold_rule.ts +++ b/x-pack/plugins/observability/public/components/threshold/mocks/metric_threshold_rule.ts @@ -84,7 +84,13 @@ export const buildMetricThresholdRule = ( metric: 'system.memory.used.pct', }, ], - filterQuery: 'host.hostname: Users-System.local and service.type: system', + searchConfiguration: { + query: { + query: 'host.hostname: Users-System.local and service.type: system', + language: 'kuery', + }, + index: 'mockedIndex', + }, groupBy: ['host.hostname'], }, monitoring: { diff --git a/x-pack/plugins/observability/public/components/threshold/threshold_rule_expression.test.tsx b/x-pack/plugins/observability/public/components/threshold/threshold_rule_expression.test.tsx index 9a4091ee1ca29..e81e695f8cef9 100644 --- a/x-pack/plugins/observability/public/components/threshold/threshold_rule_expression.test.tsx +++ b/x-pack/plugins/observability/public/components/threshold/threshold_rule_expression.test.tsx @@ -44,9 +44,14 @@ describe('Expression', () => { const ruleParams = { criteria: [], groupBy: undefined, - filterQuery: '', sourceId: 'default', - searchConfiguration: {}, + searchConfiguration: { + index: 'mockedIndex', + query: { + query: '', + language: 'kuery', + }, + }, }; const wrapper = mountWithIntl( @@ -90,7 +95,7 @@ describe('Expression', () => { }; const { ruleParams } = await setup(currentOptions); expect(ruleParams.groupBy).toBe('host.hostname'); - expect(ruleParams.filterQuery).toBe('foo'); + expect(ruleParams.searchConfiguration.query.query).toBe('foo'); expect(ruleParams.criteria).toEqual([ { metric: 'system.load.1', @@ -114,7 +119,6 @@ describe('Expression', () => { it('should show an error message when searchSource throws an error', async () => { const currentOptions = { groupBy: 'host.hostname', - filterQuery: 'foo', metrics: [ { aggregation: 'avg', field: 'system.load.1' }, { aggregation: 'cardinality', field: 'system.cpu.user.pct' }, @@ -154,7 +158,6 @@ describe('Expression', () => { it('should show no timestamp error when selected data view does not have a timeField', async () => { const currentOptions = { groupBy: 'host.hostname', - filterQuery: 'foo', metrics: [ { aggregation: 'avg', field: 'system.load.1' }, { aggregation: 'cardinality', field: 'system.cpu.user.pct' }, diff --git a/x-pack/plugins/observability/public/components/threshold/threshold_rule_expression.tsx b/x-pack/plugins/observability/public/components/threshold/threshold_rule_expression.tsx index a34f526860772..1e25865147290 100644 --- a/x-pack/plugins/observability/public/components/threshold/threshold_rule_expression.tsx +++ b/x-pack/plugins/observability/public/components/threshold/threshold_rule_expression.tsx @@ -22,7 +22,7 @@ import { EuiTitle, EuiToolTip, } from '@elastic/eui'; -import { ISearchSource } from '@kbn/data-plugin/common'; +import { ISearchSource, Query } from '@kbn/data-plugin/common'; import { DataView } from '@kbn/data-views-plugin/common'; import { DataViewBase } from '@kbn/es-query'; import { DataViewSelectPopover } from '@kbn/stack-alerts-plugin/public'; @@ -41,7 +41,6 @@ import { TimeUnitChar } from '../../../common/utils/formatters/duration'; import { AlertContextMeta, AlertParams, MetricExpression } from './types'; import { ExpressionChart } from './components/expression_chart'; import { ExpressionRow } from './components/expression_row'; -import { RuleFlyoutKueryBar } from '../rule_kql_filter/kuery_bar'; import { MetricsExplorerGroupBy } from './components/group_by'; import { MetricsExplorerOptions } from './hooks/use_metrics_explorer_options'; @@ -63,7 +62,15 @@ export const defaultExpression = { // eslint-disable-next-line import/no-default-export export default function Expressions(props: Props) { const { setRuleParams, ruleParams, errors, metadata, onChangeMetaData } = props; - const { data, dataViews, dataViewEditor, docLinks } = useKibana().services; + const { + data, + dataViews, + dataViewEditor, + docLinks, + unifiedSearch: { + ui: { SearchBar }, + }, + } = useKibana().services; const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); @@ -83,7 +90,7 @@ export default function Expressions(props: Props) { const initSearchSource = async () => { let initialSearchConfiguration = ruleParams.searchConfiguration; - if (!ruleParams.searchConfiguration) { + if (!ruleParams.searchConfiguration || !ruleParams.searchConfiguration.index) { const newSearchSource = data.search.searchSource.createEmpty(); newSearchSource.setField('query', data.query.queryString.getDefaultQuery()); const defaultDataView = await data.dataViews.getDefaultDataView(); @@ -98,7 +105,12 @@ export default function Expressions(props: Props) { const createdSearchSource = await data.search.searchSource.create( initialSearchConfiguration ); - setRuleParams('searchConfiguration', initialSearchConfiguration); + setRuleParams('searchConfiguration', { + ...initialSearchConfiguration, + ...(ruleParams.searchConfiguration?.query && { + query: ruleParams.searchConfiguration.query, + }), + }); setSearchSource(createdSearchSource); setDataView(createdSearchSource.getField('index')); @@ -129,6 +141,30 @@ export default function Expressions(props: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [data.search.searchSource, data.dataViews, dataView]); + useEffect(() => { + if (ruleParams.criteria && ruleParams.criteria.length) { + setTimeSize(ruleParams.criteria[0].timeSize); + setTimeUnit(ruleParams.criteria[0].timeUnit); + } else { + preFillAlertCriteria(); + } + + if (!ruleParams.filterQuery) { + preFillAlertFilter(); + } + + if (!ruleParams.groupBy) { + preFillAlertGroupBy(); + } + + if (typeof ruleParams.alertOnNoData === 'undefined') { + setRuleParams('alertOnNoData', true); + } + if (typeof ruleParams.alertOnGroupDisappear === 'undefined') { + setRuleParams('alertOnGroupDisappear', true); + } + }, [metadata]); // eslint-disable-line react-hooks/exhaustive-deps + const options = useMemo(() => { if (metadata?.currentOptions?.metrics) { return metadata.currentOptions as MetricsExplorerOptions; @@ -189,10 +225,10 @@ export default function Expressions(props: Props) { ); const onFilterChange = useCallback( - (filter: any) => { - setRuleParams('filterQuery', filter); + ({ query }: { query?: Query }) => { + setRuleParams('searchConfiguration', { ...ruleParams.searchConfiguration, query }); }, - [setRuleParams] + [setRuleParams, ruleParams.searchConfiguration] ); /* eslint-disable-next-line react-hooks/exhaustive-deps */ @@ -262,15 +298,27 @@ export default function Expressions(props: Props) { const preFillAlertFilter = useCallback(() => { const md = metadata; if (md && md.currentOptions?.filterQuery) { - setRuleParams('filterQuery', md.currentOptions.filterQuery); + setRuleParams('searchConfiguration', { + ...ruleParams.searchConfiguration, + query: { + query: md.currentOptions.filterQuery, + language: 'kuery', + }, + }); } else if (md && md.currentOptions?.groupBy && md.series) { const { groupBy } = md.currentOptions; - const filter = Array.isArray(groupBy) + const query = Array.isArray(groupBy) ? groupBy.map((field, index) => `${field}: "${md.series?.keys?.[index]}"`).join(' and ') : `${groupBy}: "${md.series.id}"`; - setRuleParams('filterQuery', filter); + setRuleParams('searchConfiguration', { + ...ruleParams.searchConfiguration, + query: { + query, + language: 'kuery', + }, + }); } - }, [metadata, setRuleParams]); + }, [metadata, setRuleParams, ruleParams.searchConfiguration]); const preFillAlertGroupBy = useCallback(() => { const md = metadata; @@ -279,30 +327,6 @@ export default function Expressions(props: Props) { } }, [metadata, setRuleParams]); - useEffect(() => { - if (ruleParams.criteria && ruleParams.criteria.length) { - setTimeSize(ruleParams.criteria[0].timeSize); - setTimeUnit(ruleParams.criteria[0].timeUnit); - } else { - preFillAlertCriteria(); - } - - if (!ruleParams.filterQuery) { - preFillAlertFilter(); - } - - if (!ruleParams.groupBy) { - preFillAlertGroupBy(); - } - - if (typeof ruleParams.alertOnNoData === 'undefined') { - setRuleParams('alertOnNoData', true); - } - if (typeof ruleParams.alertOnGroupDisappear === 'undefined') { - setRuleParams('alertOnGroupDisappear', true); - } - }, [metadata]); // eslint-disable-line react-hooks/exhaustive-deps - const hasGroupBy = useMemo( () => ruleParams.groupBy && ruleParams.groupBy.length > 0, [ruleParams.groupBy] @@ -358,9 +382,9 @@ export default function Expressions(props: Props) { } const placeHolder = i18n.translate( - 'xpack.observability.threshold.rule.homePage.toolbar.kqlSearchFieldPlaceholder', + 'xpack.observability.threshold.rule.alertFlyout.searchBar.placeholder', { - defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)', + defaultMessage: 'Search for observability data… (e.g. host.name:host-1)', } ); @@ -399,13 +423,27 @@ export default function Expressions(props: Props) { - + {errors.filterQuery && ( + + {errors.filterQuery} + + )}
@@ -447,7 +485,7 @@ export default function Expressions(props: Props) { diff --git a/x-pack/plugins/observability/public/components/threshold/types.ts b/x-pack/plugins/observability/public/components/threshold/types.ts index 92d64ab2378bc..6336c3f292677 100644 --- a/x-pack/plugins/observability/public/components/threshold/types.ts +++ b/x-pack/plugins/observability/public/components/threshold/types.ts @@ -175,4 +175,6 @@ export interface InventoryMetricConditions { export interface MetricThresholdRuleTypeParams extends RuleTypeParams { criteria: MetricExpressionParams[]; + searchConfiguration: SerializedSearchSourceFields; + groupBy?: string | string[]; } diff --git a/x-pack/plugins/observability/public/plugin.mock.tsx b/x-pack/plugins/observability/public/plugin.mock.tsx index e30d5254e7a07..be663d15e444d 100644 --- a/x-pack/plugins/observability/public/plugin.mock.tsx +++ b/x-pack/plugins/observability/public/plugin.mock.tsx @@ -9,6 +9,7 @@ import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; +import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; const triggersActionsUiStartMock = { createStart() { @@ -102,6 +103,7 @@ export const observabilityPublicPluginsStartMock = { dataViewEditor: dataViewEditor.createStart(), lens: null, discover: null, + unifiedSearch: unifiedSearchPluginMock.createStartContract(), }; }, }; diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/lib/evaluate_rule.ts b/x-pack/plugins/observability/server/lib/rules/threshold/lib/evaluate_rule.ts index 6300515059614..31b86eb73beea 100644 --- a/x-pack/plugins/observability/server/lib/rules/threshold/lib/evaluate_rule.ts +++ b/x-pack/plugins/observability/server/lib/rules/threshold/lib/evaluate_rule.ts @@ -5,22 +5,22 @@ * 2.0. */ -import { ElasticsearchClient } from '@kbn/core/server'; import moment from 'moment'; +import { ElasticsearchClient } from '@kbn/core/server'; import type { Logger } from '@kbn/logging'; import { MetricExpressionParams } from '../../../../../common/threshold_rule/types'; import { isCustom } from './metric_expression_params'; -import { getIntervalInSeconds } from '../utils'; +import { AdditionalContext, getIntervalInSeconds } from '../utils'; +import { SearchConfigurationType } from '../threshold_executor'; import { CUSTOM_EQUATION_I18N, DOCUMENT_COUNT_I18N } from '../messages'; import { createTimerange } from './create_timerange'; import { getData } from './get_data'; import { checkMissingGroups, MissingGroupsRecord } from './check_missing_group'; -import { AdditionalContext } from '../utils'; export interface EvaluatedRuleParams { criteria: MetricExpressionParams[]; groupBy: string | undefined | string[]; - filterQuery?: string; + searchConfiguration: SearchConfigurationType; } export type Evaluation = Omit & { @@ -46,7 +46,7 @@ export const evaluateRule = async >> => { - const { criteria, groupBy, filterQuery } = params; + const { criteria, groupBy, searchConfiguration } = params; return Promise.all( criteria.map(async (criterion) => { @@ -66,7 +66,7 @@ export const evaluateRule = async ; export type MetricThresholdRuleParams = Record; export type MetricThresholdRuleTypeState = RuleTypeState & { lastRunTimestamp?: number; missingGroups?: Array; groupBy?: string | string[]; + searchConfiguration?: SearchConfigurationType; }; export type MetricThresholdAlertState = AlertState; // no specific instance state used @@ -156,10 +159,13 @@ export const createMetricThresholdExecutor = ({ // For backwards-compatibility, interpret undefined alertOnGroupDisappear as true const alertOnGroupDisappear = _alertOnGroupDisappear !== false; const compositeSize = config.thresholdRule.groupByPageSize; - const filterQueryIsSame = isEqual(state.filterQuery, params.filterQuery); + const queryIsSame = isEqual( + state.searchConfiguration?.query.query, + params.searchConfiguration.query.query + ); const groupByIsSame = isEqual(state.groupBy, params.groupBy); const previousMissingGroups = - alertOnGroupDisappear && filterQueryIsSame && groupByIsSame && state.missingGroups + alertOnGroupDisappear && queryIsSame && groupByIsSame && state.missingGroups ? state.missingGroups : []; @@ -356,7 +362,7 @@ export const createMetricThresholdExecutor = ({ lastRunTimestamp: startedAt.valueOf(), missingGroups: [...nextMissingGroups], groupBy: params.groupBy, - filterQuery: params.filterQuery, + searchConfiguration: params.searchConfiguration, }, }; }; diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/types.ts b/x-pack/plugins/observability/server/lib/rules/threshold/types.ts index d98a59bd50b9b..40115fdeb9c80 100644 --- a/x-pack/plugins/observability/server/lib/rules/threshold/types.ts +++ b/x-pack/plugins/observability/server/lib/rules/threshold/types.ts @@ -4,8 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { schema } from '@kbn/config-schema'; import * as rt from 'io-ts'; import { ML_ANOMALY_THRESHOLD } from '@kbn/ml-anomaly-utils/anomaly_threshold'; +import { validateKQLStringFilter } from './utils'; import { Aggregators, Comparator } from '../../../../common/threshold_rule/types'; import { TimeUnitChar } from '../../../../common'; @@ -71,3 +73,13 @@ export interface AlertExecutionDetails { alertId: string; executionId: string; } + +export const searchConfigurationSchema = schema.object({ + index: schema.string(), + query: schema.object({ + language: schema.string({ + validate: validateKQLStringFilter, + }), + query: schema.string(), + }), +}); diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_prompt_editor.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_prompt_editor.tsx index 7033e1b48c47c..124927564eec1 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_prompt_editor.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/chat_prompt_editor.tsx @@ -60,13 +60,6 @@ export function ChatPromptEditor({ const textAreaRef = useRef(null); - const recalculateFunctionEditorLineCount = useCallback(() => { - const newLineCount = model?.getLineCount() || 0; - if (newLineCount !== functionEditorLineCount) { - setFunctionEditorLineCount(newLineCount); - } - }, [functionEditorLineCount, model]); - const handleChange = (event: React.ChangeEvent) => { setPrompt(event.currentTarget.value); }; @@ -90,11 +83,24 @@ export function ChatPromptEditor({ const handleResizeTextArea = () => { if (textAreaRef.current) { - textAreaRef.current.style.height = 'auto'; - textAreaRef.current.style.height = textAreaRef.current?.scrollHeight + 'px'; + textAreaRef.current.style.minHeight = 'auto'; + textAreaRef.current.style.minHeight = textAreaRef.current?.scrollHeight + 'px'; + } + }; + + const handleResetTextArea = () => { + if (textAreaRef.current) { + textAreaRef.current.style.minHeight = 'auto'; } }; + const recalculateFunctionEditorLineCount = useCallback(() => { + const newLineCount = model?.getLineCount() || 0; + if (newLineCount !== functionEditorLineCount) { + setFunctionEditorLineCount(newLineCount); + } + }, [functionEditorLineCount, model]); + const handleSubmit = useCallback(async () => { if (loading || !prompt?.trim()) { return; @@ -104,7 +110,7 @@ export function ChatPromptEditor({ setPrompt(''); setFunctionPayload(undefined); - handleResizeTextArea(); + handleResetTextArea(); try { if (selectedFunctionName) { @@ -170,6 +176,10 @@ export function ChatPromptEditor({ }; }); + useEffect(() => { + handleResizeTextArea(); + }, []); + return ( diff --git a/x-pack/plugins/observability_ai_assistant/public/components/chat/experimental_feature_banner.tsx b/x-pack/plugins/observability_ai_assistant/public/components/chat/experimental_feature_banner.tsx index 8ec5346283365..f3e656da5438e 100644 --- a/x-pack/plugins/observability_ai_assistant/public/components/chat/experimental_feature_banner.tsx +++ b/x-pack/plugins/observability_ai_assistant/public/components/chat/experimental_feature_banner.tsx @@ -31,7 +31,7 @@ export function ExperimentalFeatureBanner() { Tech Preview }} + values={{ techPreview: Technical preview }} /> diff --git a/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/configure.cy.ts b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/configure.cy.ts new file mode 100644 index 0000000000000..0178aca04c344 --- /dev/null +++ b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/configure.cy.ts @@ -0,0 +1,358 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +describe('[Logs onboarding] Custom logs - configure step', () => { + describe('logFilePaths', () => { + beforeEach(() => { + cy.loginAsViewerUser(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + }); + + it('Users shouldnt be able to continue if logFilePaths is empty', () => { + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .should('not.have.text'); + cy.getByTestSubj('obltOnboardingCustomLogsContinue').should( + 'be.disabled' + ); + }); + + it('Users should be able to continue if logFilePaths is not empty', () => { + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type('myLogs.log'); + cy.getByTestSubj('obltOnboardingCustomLogsContinue').should( + 'not.be.disabled' + ); + }); + + it('Users can add multiple logFilePaths', () => { + cy.getByTestSubj('obltOnboardingCustomLogsAddFilePath').first().click(); + cy.getByTestSubj('obltOnboardingLogFilePath-0').should('exist'); + cy.getByTestSubj('obltOnboardingLogFilePath-1').should('exist'); + }); + + it('Users can delete logFilePaths', () => { + cy.getByTestSubj('obltOnboardingCustomLogsAddFilePath').first().click(); + cy.get('*[data-test-subj^="obltOnboardingLogFilePath-"]').should( + 'have.length', + 2 + ); + + cy.getByTestSubj('obltOnboardingLogFilePathDelete-1').click(); + cy.get('*[data-test-subj^="obltOnboardingLogFilePath-"]').should( + 'have.length', + 1 + ); + }); + + describe('when users fill logFilePaths', () => { + it('datasetname and integration name are auto generated if it is the first path', () => { + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type('myLogs.log'); + cy.getByTestSubj('obltOnboardingCustomLogsIntegrationsName').should( + 'have.value', + 'myLogs' + ); + cy.getByTestSubj('obltOnboardingCustomLogsDatasetName').should( + 'have.value', + 'myLogs' + ); + }); + + it('datasetname and integration name are not generated if it is not the first path', () => { + cy.getByTestSubj('obltOnboardingCustomLogsAddFilePath').first().click(); + cy.getByTestSubj('obltOnboardingLogFilePath-1') + .find('input') + .first() + .type('myLogs.log'); + cy.getByTestSubj('obltOnboardingCustomLogsIntegrationsName').should( + 'be.empty' + ); + cy.getByTestSubj('obltOnboardingCustomLogsDatasetName').should( + 'be.empty' + ); + }); + }); + }); + + describe('serviceName', () => { + beforeEach(() => { + cy.loginAsViewerUser(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type('myLogs.log'); + }); + + it('should be optional allowing user to continue if it is empty', () => { + cy.getByTestSubj('obltOnboardingCustomLogsServiceName').should( + 'not.have.text' + ); + cy.getByTestSubj('obltOnboardingCustomLogsContinue').should('be.enabled'); + }); + }); + + describe('advancedSettings', () => { + beforeEach(() => { + cy.loginAsViewerUser(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type('myLogs.log'); + }); + + it('Users should expand the content when clicking it', () => { + cy.getByTestSubj('obltOnboardingCustomLogsAdvancedSettings').click(); + + cy.getByTestSubj('obltOnboardingCustomLogsNamespace').should( + 'be.visible' + ); + cy.getByTestSubj('obltOnboardingCustomLogsCustomConfig').should( + 'be.visible' + ); + }); + + it('Users should hide the content when clicking it', () => { + cy.getByTestSubj('obltOnboardingCustomLogsAdvancedSettings').click(); + + cy.getByTestSubj('obltOnboardingCustomLogsNamespace').should( + 'not.be.visible' + ); + cy.getByTestSubj('obltOnboardingCustomLogsCustomConfig').should( + 'not.be.visible' + ); + }); + + describe('Namespace', () => { + beforeEach(() => { + cy.getByTestSubj('obltOnboardingCustomLogsAdvancedSettings').click(); + }); + + afterEach(() => { + cy.getByTestSubj('obltOnboardingCustomLogsAdvancedSettings').click(); + }); + + it('Users should see a default namespace', () => { + cy.getByTestSubj('obltOnboardingCustomLogsNamespace').should( + 'have.value', + 'default' + ); + }); + + it('Users should not be able to continue if they do not specify a namespace', () => { + cy.getByTestSubj('obltOnboardingCustomLogsNamespace').clear(); + + cy.getByTestSubj('obltOnboardingCustomLogsContinue').should( + 'be.disabled' + ); + }); + }); + + describe('customConfig', () => { + beforeEach(() => { + cy.getByTestSubj('obltOnboardingCustomLogsAdvancedSettings').click(); + }); + + afterEach(() => { + cy.getByTestSubj('obltOnboardingCustomLogsAdvancedSettings').click(); + }); + + it('should be optional allowing user to continue if it is empty', () => { + cy.getByTestSubj('obltOnboardingCustomLogsCustomConfig').should( + 'not.have.text' + ); + cy.getByTestSubj('obltOnboardingCustomLogsContinue').should( + 'be.enabled' + ); + }); + }); + }); + + describe('integrationName', () => { + beforeEach(() => { + cy.loginAsViewerUser(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type('myLogs.log'); + }); + + it('Users should not be able to continue if they do not specify an integrationName', () => { + cy.getByTestSubj('obltOnboardingCustomLogsIntegrationsName').clear(); + + // https://github.com/elastic/kibana/issues/165778 + // cy.getByTestSubj('obltOnboardingCustomLogsContinue').should( + // 'be.disabled' + // ); + }); + + it('value will contain _ instead of special chars', () => { + cy.getByTestSubj('obltOnboardingCustomLogsIntegrationsName') + .clear() + .type('hello$world'); + + cy.getByTestSubj('obltOnboardingCustomLogsIntegrationsName').should( + 'have.value', + 'hello_world' + ); + }); + + it('value will be invalid if it is not lowercase', () => { + cy.getByTestSubj('obltOnboardingCustomLogsIntegrationsName') + .clear() + .type('H3llowOrld'); + + cy.contains('An integration name should be lowercase.'); + }); + }); + + describe('datasetName', () => { + beforeEach(() => { + cy.loginAsViewerUser(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type('myLogs.log'); + }); + + it('Users should not be able to continue if they do not specify a datasetName', () => { + cy.getByTestSubj('obltOnboardingCustomLogsDatasetName').clear(); + + cy.getByTestSubj('obltOnboardingCustomLogsContinue').should( + 'be.disabled' + ); + }); + + it('value will contain _ instead of special chars', () => { + cy.getByTestSubj('obltOnboardingCustomLogsDatasetName') + .clear() + .type('hello$world'); + + cy.getByTestSubj('obltOnboardingCustomLogsDatasetName').should( + 'have.value', + 'hello_world' + ); + }); + + it('value will be invalid if it is not lowercase', () => { + cy.getByTestSubj('obltOnboardingCustomLogsDatasetName') + .clear() + .type('H3llowOrld'); + + cy.contains('A dataset name should be lowercase.'); + }); + }); + + describe('custom integration', () => { + const CUSTOM_INTEGRATION_NAME = 'mylogs'; + + beforeEach(() => { + cy.deleteIntegration(CUSTOM_INTEGRATION_NAME); + }); + + describe('when user is missing privileges', () => { + beforeEach(() => { + cy.loginAsViewerUser(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type(`${CUSTOM_INTEGRATION_NAME}.log`); + + cy.getByTestSubj('obltOnboardingCustomLogsContinue').click(); + }); + + it('installation fails', () => { + cy.getByTestSubj('obltOnboardingCustomIntegrationUnauthorized').should( + 'exist' + ); + }); + }); + + describe('when user has proper privileges', () => { + beforeEach(() => { + cy.loginAsEditorUser(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type(`${CUSTOM_INTEGRATION_NAME}.log`); + + cy.getByTestSubj('obltOnboardingCustomLogsContinue').click(); + }); + + afterEach(() => { + cy.deleteIntegration(CUSTOM_INTEGRATION_NAME); + }); + + it('installation succeed and user is redirected install elastic agent step', () => { + cy.getByTestSubj('obltOnboardingCustomLogsContinue').click(); + + cy.url().should( + 'include', + '/app/observabilityOnboarding/customLogs/installElasticAgent' + ); + }); + }); + + it('installation fails if integration already exists', () => { + cy.loginAsEditorUser(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + + cy.installCustomIntegration(CUSTOM_INTEGRATION_NAME); + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type(`${CUSTOM_INTEGRATION_NAME}.log`); + cy.getByTestSubj('obltOnboardingCustomLogsContinue').click(); + + cy.contains( + 'Failed to create the integration as an installation with the name mylogs already exists.' + ); + }); + + describe('when an error occurred on creation', () => { + before(() => { + cy.intercept('/api/fleet/epm/custom_integrations', { + statusCode: 500, + body: { + message: 'Internal error', + }, + }); + + cy.loginAsEditorUser(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type(`${CUSTOM_INTEGRATION_NAME}.log`); + cy.getByTestSubj('obltOnboardingCustomLogsContinue').click(); + }); + + it('user should see the error displayed', () => { + cy.getByTestSubj('obltOnboardingCustomIntegrationUnknownError').should( + 'exist' + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts new file mode 100644 index 0000000000000..c0175455efa8f --- /dev/null +++ b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts @@ -0,0 +1,634 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +describe('[Logs onboarding] Custom logs - install elastic agent', () => { + const CUSTOM_INTEGRATION_NAME = 'mylogs'; + + const configureCustomLogs = ( + loginFn = () => cy.loginAsLogMonitoringUser() + ) => { + loginFn(); + cy.visitKibana('/app/observabilityOnboarding/customLogs'); + + cy.deleteIntegration(CUSTOM_INTEGRATION_NAME); + + cy.getByTestSubj('obltOnboardingLogFilePath-0') + .find('input') + .first() + .type('mylogs.log'); + + cy.getByTestSubj('obltOnboardingCustomLogsContinue').click(); + }; + + describe('custom integration', () => { + beforeEach(() => { + configureCustomLogs(() => cy.loginAsEditorUser()); + }); + + it('Users should be able to see the custom integration success callout', () => { + cy.getByTestSubj('obltOnboardingCustomIntegrationInstalled').should( + 'be.visible' + ); + }); + }); + + describe('ApiKey generation', () => { + describe('when user is missing privileges', () => { + beforeEach(() => { + configureCustomLogs(() => cy.loginAsEditorUser()); + }); + + it('apiKey is not generated', () => { + cy.getByTestSubj('obltOnboardingLogsApiKeyCreationNoPrivileges').should( + 'exist' + ); + }); + }); + + describe('when user has proper privileges', () => { + beforeEach(() => { + configureCustomLogs(); + }); + + it('apiKey is generated', () => { + cy.getByTestSubj('obltOnboardingLogsApiKeyCreated').should('exist'); + }); + }); + + describe('when an error occurred on creation', () => { + before(() => { + cy.intercept('/internal/observability_onboarding/logs/flow', { + statusCode: 500, + body: { + message: 'Internal error', + }, + }); + + configureCustomLogs(); + }); + + it('apiKey is not generated', () => { + cy.getByTestSubj('obltOnboardingLogsApiKeyCreationFailed').should( + 'exist' + ); + }); + }); + }); + + describe('Install the Elastic Agent step', () => { + beforeEach(() => { + cy.intercept('POST', '/internal/observability_onboarding/logs/flow').as( + 'createOnboardingFlow' + ); + configureCustomLogs(); + }); + + describe('When user select Linux OS', () => { + it('Auto download config to host is disabled by default', () => { + cy.get('.euiButtonGroup').contains('Linux').click(); + cy.getByTestSubj('obltOnboardingInstallElasticAgentAutoDownloadConfig') + .should('be.enabled') + .should('not.be.checked'); + }); + + it('Installation script is shown', () => { + cy.getByTestSubj('obltOnboardingInstallElasticAgentStep') + .get('.euiCodeBlock') + .should('exist'); + }); + }); + + describe('When user select Mac OS', () => { + beforeEach(() => { + cy.get('.euiButtonGroup').contains('MacOS').click(); + }); + + it('Auto download config to host is disabled by default', () => { + cy.getByTestSubj('obltOnboardingInstallElasticAgentAutoDownloadConfig') + .should('be.enabled') + .should('not.be.checked'); + }); + + it('Installation script is shown', () => { + cy.getByTestSubj('obltOnboardingInstallElasticAgentStep') + .get('.euiCodeBlock') + .should('exist'); + }); + }); + + describe('When user select Windows OS', () => { + beforeEach(() => { + cy.get('.euiButtonGroup').contains('Windows').click(); + }); + + it('Auto download config to host is disabled by default', () => { + cy.getByTestSubj('obltOnboardingInstallElasticAgentAutoDownloadConfig') + .should('be.disabled') + .should('not.be.checked'); + }); + + it('A link to the documentation is shown instead of installation script', () => { + cy.getByTestSubj( + 'obltOnboardingInstallElasticAgentWindowsDocsLink' + ).should('exist'); + + cy.getByTestSubj('obltOnboardingInstallElasticAgentStep') + .get('.euiCodeBlock') + .should('not.exist'); + }); + }); + + describe('When Auto download config', () => { + describe('is selected', () => { + it('autoDownloadConfig flag is added to installation script', () => { + cy.getByTestSubj( + 'obltOnboardingInstallElasticAgentAutoDownloadConfig' + ) + .first() + .click(); + cy.getByTestSubj( + 'obltOnboardingInstallElasticAgentAutoDownloadConfigCallout' + ).should('exist'); + cy.getByTestSubj('obltOnboardingInstallElasticAgentStep') + .get('.euiCodeBlock') + .should('contain', 'autoDownloadConfig=1'); + }); + + it('Download config button is disabled', () => { + cy.getByTestSubj( + 'obltOnboardingInstallElasticAgentAutoDownloadConfig' + ) + .first() + .click(); + cy.getByTestSubj( + 'obltOnboardingConfigureElasticAgentStepDownloadConfig' + ).should('be.disabled'); + }); + }); + + it('is not selected autoDownloadConfig flag is not added to installation script', () => { + cy.getByTestSubj('obltOnboardingInstallElasticAgentStep') + .get('.euiCodeBlock') + .should('not.contain', 'autoDownloadConfig=1'); + }); + }); + + describe('When user executes the installation script in the host', () => { + let onboardingId: string; + + describe('updates on steps are shown in the flow', () => { + beforeEach(() => { + cy.wait('@createOnboardingFlow') + .its('response.body') + .then((body) => { + onboardingId = body.onboardingId; + }); + }); + + describe('Download elastic Agent step', () => { + it('shows a loading callout when elastic agent is downloading', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'loading' + ); + cy.getByTestSubj('obltOnboardingStepStatus-loading') + .contains('Downloading Elastic Agent') + .should('exist'); + }); + + it('shows a success callout when elastic agent is downloaded', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'complete' + ); + cy.getByTestSubj('obltOnboardingStepStatus-complete') + .contains('Elastic Agent downloaded') + .should('exist'); + }); + + it('shows a danger callout when elastic agent was not downloaded', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'danger' + ); + cy.getByTestSubj('obltOnboardingStepStatus-danger') + .contains('Download Elastic Agent') + .should('exist'); + }); + }); + + describe('Extract elastic Agent step', () => { + beforeEach(() => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'complete' + ); + }); + + it('shows a loading callout when elastic agent is extracting', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-extract', + 'loading' + ); + cy.getByTestSubj('obltOnboardingStepStatus-loading') + .contains('Extracting Elastic Agent') + .should('exist'); + }); + + it('shows a success callout when elastic agent is extracted', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-extract', + 'complete' + ); + cy.getByTestSubj('obltOnboardingStepStatus-complete') + .contains('Elastic Agent extracted') + .should('exist'); + }); + + it('shows a danger callout when elastic agent was not extracted', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-extract', + 'danger' + ); + cy.getByTestSubj('obltOnboardingStepStatus-danger') + .contains('Extract Elastic Agent') + .should('exist'); + }); + }); + + describe('Install elastic Agent step', () => { + beforeEach(() => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'complete' + ); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-extract', + 'complete' + ); + }); + + it('shows a loading callout when elastic agent is installing', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-install', + 'loading' + ); + cy.getByTestSubj('obltOnboardingStepStatus-loading') + .contains('Installing Elastic Agent') + .should('exist'); + }); + + it('shows a success callout when elastic agent is installed', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-install', + 'complete' + ); + cy.getByTestSubj('obltOnboardingStepStatus-complete') + .contains('Elastic Agent installed') + .should('exist'); + }); + + it('shows a danger callout when elastic agent was not installed', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-install', + 'danger' + ); + cy.getByTestSubj('obltOnboardingStepStatus-danger') + .contains('Install Elastic Agent') + .should('exist'); + }); + }); + + describe('Check elastic Agent status step', () => { + beforeEach(() => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'complete' + ); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-extract', + 'complete' + ); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-install', + 'complete' + ); + }); + + it('shows a loading callout when getting elastic agent status', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-status', + 'loading' + ); + cy.getByTestSubj('obltOnboardingStepStatus-loading') + .contains('Connecting to the Elastic Agent') + .should('exist'); + }); + + it('shows a success callout when elastic agent status is healthy', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-status', + 'complete' + ); + cy.getByTestSubj('obltOnboardingStepStatus-complete') + .contains('Connected to the Elastic Agent') + .should('exist'); + }); + + it('shows a warning callout when elastic agent status is not healthy', () => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-status', + 'warning' + ); + cy.getByTestSubj('obltOnboardingStepStatus-warning') + .contains('Connect to the Elastic Agent') + .should('exist'); + }); + }); + }); + }); + }); + + describe('Configure Elastic Agent step', () => { + let onboardingId: string; + + beforeEach(() => { + cy.intercept('POST', '/internal/observability_onboarding/logs/flow').as( + 'createOnboardingFlow' + ); + configureCustomLogs(); + cy.wait('@createOnboardingFlow') + .its('response.body') + .then((body) => { + onboardingId = body.onboardingId; + }); + }); + + describe('When user select Linux OS', () => { + beforeEach(() => { + cy.getByTestSubj( + 'obltOnboardingInstallElasticAgentAutoDownloadConfig' + ).click(); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'complete' + ); + cy.updateInstallationStepStatus(onboardingId, 'ea-extract', 'complete'); + cy.updateInstallationStepStatus(onboardingId, 'ea-install', 'complete'); + cy.updateInstallationStepStatus(onboardingId, 'ea-status', 'complete'); + }); + + it('shows loading callout when config is being downloaded to the host', () => { + cy.updateInstallationStepStatus(onboardingId, 'ea-config', 'loading'); + cy.get( + '[data-test-subj="obltOnboardingConfigureElasticAgentStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-loading"]' + ).should('exist'); + cy.getByTestSubj('obltOnboardingStepStatus-loading') + .contains('Downloading Elastic Agent config') + .should('exist'); + }); + + it('shows success callout when the configuration has been written to the host', () => { + cy.updateInstallationStepStatus(onboardingId, 'ea-config', 'complete'); + cy.get( + '[data-test-subj="obltOnboardingConfigureElasticAgentStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-complete"]' + ).should('exist'); + cy.getByTestSubj('obltOnboardingStepStatus-complete') + .contains( + 'Elastic Agent config written to /opt/Elastic/Agent/elastic-agent.yml' + ) + .should('exist'); + }); + + it('shows warning callout when the configuration was not written in the host', () => { + cy.updateInstallationStepStatus(onboardingId, 'ea-config', 'warning'); + cy.get( + '[data-test-subj="obltOnboardingConfigureElasticAgentStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-warning"]' + ).should('exist'); + cy.getByTestSubj('obltOnboardingStepStatus-warning') + .contains('Configure the agent') + .should('exist'); + }); + }); + + describe('When user select Mac OS', () => { + beforeEach(() => { + cy.get('.euiButtonGroup').contains('MacOS').click(); + cy.getByTestSubj( + 'obltOnboardingInstallElasticAgentAutoDownloadConfig' + ).click(); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'complete' + ); + cy.updateInstallationStepStatus(onboardingId, 'ea-extract', 'complete'); + cy.updateInstallationStepStatus(onboardingId, 'ea-install', 'complete'); + cy.updateInstallationStepStatus(onboardingId, 'ea-status', 'complete'); + }); + + it('shows loading callout when config is being downloaded to the host', () => { + cy.updateInstallationStepStatus(onboardingId, 'ea-config', 'loading'); + cy.get( + '[data-test-subj="obltOnboardingConfigureElasticAgentStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-loading"]' + ).should('exist'); + cy.getByTestSubj('obltOnboardingStepStatus-loading') + .contains('Downloading Elastic Agent config') + .should('exist'); + }); + + it('shows success callout when the configuration has been written to the host', () => { + cy.updateInstallationStepStatus(onboardingId, 'ea-config', 'complete'); + cy.get( + '[data-test-subj="obltOnboardingConfigureElasticAgentStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-complete"]' + ).should('exist'); + cy.getByTestSubj('obltOnboardingStepStatus-complete') + .contains( + 'Elastic Agent config written to /Library/Elastic/Agent/elastic-agent.yml' + ) + .should('exist'); + }); + + it('shows warning callout when the configuration was not written in the host', () => { + cy.updateInstallationStepStatus(onboardingId, 'ea-config', 'warning'); + cy.get( + '[data-test-subj="obltOnboardingConfigureElasticAgentStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-warning"]' + ).should('exist'); + cy.getByTestSubj('obltOnboardingStepStatus-warning') + .contains('Configure the agent') + .should('exist'); + }); + }); + + describe('When user select Windows', () => { + beforeEach(() => { + cy.get('.euiButtonGroup').contains('Windows').click(); + }); + + it('step is disabled', () => { + cy.get( + '[data-test-subj="obltOnboardingConfigureElasticAgentStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-disabled"]' + ).should('exist'); + }); + }); + }); + + describe('Check logs step', () => { + let onboardingId: string; + + beforeEach(() => { + cy.intercept('POST', '/internal/observability_onboarding/logs/flow').as( + 'createOnboardingFlow' + ); + configureCustomLogs(); + cy.wait('@createOnboardingFlow') + .its('response.body') + .then((body) => { + onboardingId = body.onboardingId; + }); + }); + + describe('When user select Linux OS or MacOS', () => { + describe('When configure Elastic Agent step is not finished', () => { + beforeEach(() => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'complete' + ); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-extract', + 'complete' + ); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-install', + 'complete' + ); + cy.updateInstallationStepStatus(onboardingId, 'ea-status', 'loading'); + }); + + it('check logs is not triggered', () => { + cy.get( + '[data-test-subj="obltOnboardingCheckLogsStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-incomplete"]' + ).should('exist'); + cy.get('.euiStep__title') + .contains('Ship logs to Elastic Observability') + .should('exist'); + }); + }); + + describe('When configure Elastic Agent step has finished', () => { + beforeEach(() => { + cy.updateInstallationStepStatus( + onboardingId, + 'ea-download', + 'complete' + ); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-extract', + 'complete' + ); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-install', + 'complete' + ); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-status', + 'complete' + ); + cy.updateInstallationStepStatus( + onboardingId, + 'ea-config', + 'complete' + ); + }); + + it('shows loading callout when logs are being checked', () => { + cy.get( + '[data-test-subj="obltOnboardingCheckLogsStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-loading"]' + ).should('exist'); + cy.get('.euiStep__title') + .contains('Waiting for logs to be shipped...') + .should('exist'); + }); + }); + }); + + describe('When user select Windows', () => { + beforeEach(() => { + cy.get('.euiButtonGroup').contains('Windows').click(); + }); + + it('step is disabled', () => { + cy.get( + '[data-test-subj="obltOnboardingCheckLogsStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-disabled"]' + ).should('exist'); + }); + }); + }); + + describe('When logs are being shipped', () => { + beforeEach(() => { + cy.intercept('GET', '**/progress', { + status: 200, + body: { + progress: { + 'ea-download': { status: 'complete' }, + 'ea-extract': { status: 'complete' }, + 'ea-install': { status: 'complete' }, + 'ea-status': { status: 'complete' }, + 'ea-config': { status: 'complete' }, + 'logs-ingest': { status: 'complete' }, + }, + }, + }).as('checkOnboardingProgress'); + configureCustomLogs(); + }); + + it('shows success callout when logs has arrived to elastic', () => { + cy.wait('@checkOnboardingProgress'); + cy.get( + '[data-test-subj="obltOnboardingCheckLogsStep"] .euiStep__titleWrapper [class$="euiStepNumber-s-complete"]' + ).should('exist'); + cy.get('.euiStep__title') + .contains('Logs are being shipped!') + .should('exist'); + }); + + it('when user clicks on Explore Logs it navigates to discover', () => { + cy.wait('@checkOnboardingProgress'); + cy.getByTestSubj('obltOnboardingExploreLogs').should('exist').click(); + cy.url().should('include', '/app/discover'); + + cy.get('button[title="logs-*"]').should('exist'); + }); + }); +}); diff --git a/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts b/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts index ddceaf3325bfa..f4a30e896c8db 100644 --- a/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts +++ b/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts @@ -96,6 +96,30 @@ Cypress.Commands.add( } ); +Cypress.Commands.add('installCustomIntegration', (integrationName: string) => { + const kibanaUrl = Cypress.env('KIBANA_URL'); + + cy.request({ + log: false, + method: 'POST', + url: `${kibanaUrl}/api/fleet/epm/custom_integrations`, + body: { + force: true, + integrationName, + datasets: [ + { name: 'access', type: 'logs' }, + { name: 'error', type: 'metrics' }, + { name: 'warning', type: 'logs' }, + ], + }, + headers: { + 'kbn-xsrf': 'e2e_test', + 'Elastic-Api-Version': '2023-10-31', + }, + auth: { user: 'editor', pass: 'changeme' }, + }); +}); + Cypress.Commands.add('deleteIntegration', (integrationName: string) => { const kibanaUrl = Cypress.env('KIBANA_URL'); @@ -107,8 +131,9 @@ Cypress.Commands.add('deleteIntegration', (integrationName: string) => { 'kbn-xsrf': 'e2e_test', }, auth: { user: 'editor', pass: 'changeme' }, + failOnStatusCode: false, }).then((response) => { - const status = response.body.item.status; + const status = response.body.item?.status; if (status === 'installed') { cy.request({ log: false, diff --git a/x-pack/plugins/observability_onboarding/e2e/cypress/support/types.d.ts b/x-pack/plugins/observability_onboarding/e2e/cypress/support/types.d.ts index 6b5695d159d76..dbc28bb442bb9 100644 --- a/x-pack/plugins/observability_onboarding/e2e/cypress/support/types.d.ts +++ b/x-pack/plugins/observability_onboarding/e2e/cypress/support/types.d.ts @@ -17,6 +17,7 @@ declare namespace Cypress { loginAsElastic(): Cypress.Chainable>; getByTestSubj(selector: string): Chainable>; visitKibana(url: string, rangeFrom?: string, rangeTo?: string): void; + installCustomIntegration(integrationName: string): void; deleteIntegration(integrationName: string): void; updateInstallationStepStatus( onboardingId: string, diff --git a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx index cb17a5fa68a4d..798614365d6b0 100644 --- a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx @@ -174,7 +174,6 @@ export function ConfigureLogs() { items={[ , {isCreatingIntegration ? i18n.translate( @@ -230,7 +230,10 @@ export function ConfigureLogs() { > <> {logFilePaths.map((filepath, index) => ( -
+
{index > 0 && } @@ -249,10 +252,10 @@ export function ConfigureLogs() { {index > 0 && ( removeLogFilePath(index)} + data-test-subj={`obltOnboardingLogFilePathDelete-${index}`} /> )} @@ -269,9 +272,9 @@ export function ConfigureLogs() { > {i18n.translate( 'xpack.observability_onboarding.configureLogs.logFile.addRow', @@ -320,7 +323,6 @@ export function ConfigureLogs() { } > setServiceName(event.target.value)} + data-test-subj="obltOnboardingCustomLogsServiceName" /> @@ -357,6 +360,7 @@ export function ConfigureLogs() { defaultMessage: 'Advanced settings', } )} + data-test-subj="obltOnboardingCustomLogsAdvancedSettings" > setNamespace(event.target.value)} + data-test-subj="obltOnboardingCustomLogsNamespace" /> @@ -461,11 +465,11 @@ export function ConfigureLogs() { } > setCustomConfigurations(event.target.value) } + data-test-subj="obltOnboardingCustomLogsCustomConfig" /> @@ -526,7 +530,6 @@ export function ConfigureLogs() { error={integrationNameError} > setIntegrationNameTouched(true)} + data-test-subj="obltOnboardingCustomLogsIntegrationsName" /> setDatasetNameTouched(true)} + data-test-subj="obltOnboardingCustomLogsDatasetName" /> @@ -624,13 +628,23 @@ const getIntegrationErrorCallout = (integrationError: IntegrationError) => { } ); return ( - +

{authorizationDescription}

); case 'UnknownError': return ( - +

{integrationError.message}

); diff --git a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/install_elastic_agent.tsx b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/install_elastic_agent.tsx index 041ec786697c0..2457d75d4f543 100644 --- a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/install_elastic_agent.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/install_elastic_agent.tsx @@ -211,7 +211,11 @@ export function InstallElasticAgent() { : stepStatus === 'complete' ? CHECK_LOGS_LABELS.completed : CHECK_LOGS_LABELS.incomplete; - return { title, status: stepStatus }; + return { + title, + status: stepStatus, + 'data-test-subj': 'obltOnboardingCheckLogsStep', + }; } return { title: CHECK_LOGS_LABELS.incomplete, @@ -245,7 +249,7 @@ export function InstallElasticAgent() {
@@ -395,7 +400,7 @@ const CHECK_LOGS_LABELS = { ), loading: i18n.translate( 'xpack.observability_onboarding.installElasticAgent.progress.logsIngest.loadingTitle', - { defaultMessage: 'Waiting for Logs to be shipped...' } + { defaultMessage: 'Waiting for logs to be shipped...' } ), completed: i18n.translate( 'xpack.observability_onboarding.installElasticAgent.progress.logsIngest.completedTitle', diff --git a/x-pack/plugins/osquery/common/api/packs/packs.schema.yaml b/x-pack/plugins/osquery/common/api/packs/packs.schema.yaml index 64e0ed0e4ffe3..006e0ebd75286 100644 --- a/x-pack/plugins/osquery/common/api/packs/packs.schema.yaml +++ b/x-pack/plugins/osquery/common/api/packs/packs.schema.yaml @@ -43,25 +43,30 @@ paths: schema: $ref: './read_packs.schema.yaml#/components/schemas/SuccessResponse' delete: - summary: Delete packs - parameters: - - $ref: './delete_packs.schema.yaml#/components/parameters/DeletePacksRequestQueryParameter' - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: './find_packs.schema.yaml#/components/schemas/SuccessResponse' + summary: Delete packs + parameters: + - $ref: './delete_packs.schema.yaml#/components/parameters/DeletePacksRequestQueryParameter' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: './find_packs.schema.yaml#/components/schemas/SuccessResponse' put: - summary: Update packs - parameters: - - $ref: './update_packs.schema.yaml#/components/parameters/UpdatePacksRequestQueryBody' - - $ref: './update_packs.schema.yaml#/components/parameters/UpdatePacksRequestQueryParameter' - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: './update_packs.schema.yaml#/components/schemas/SuccessResponse' + summary: Update packs + requestBody: + required: true + content: + application/json: + schema: + $ref: './update_packs.schema.yaml#/components/schemas/UpdatePacksRequestBody' + parameters: + - $ref: './update_packs.schema.yaml#/components/parameters/UpdatePacksRequestQueryParameter' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: './update_packs.schema.yaml#/components/schemas/SuccessResponse' diff --git a/x-pack/plugins/osquery/common/api/packs/update_packs.schema.yaml b/x-pack/plugins/osquery/common/api/packs/update_packs.schema.yaml index 344cb88fad7d4..0b0510b4773ab 100644 --- a/x-pack/plugins/osquery/common/api/packs/update_packs.schema.yaml +++ b/x-pack/plugins/osquery/common/api/packs/update_packs.schema.yaml @@ -11,12 +11,6 @@ components: required: true schema: $ref: '#/components/schemas/UpdatePacksRequestParams' - UpdatePacksRequestQueryBody: - name: query - in: path - required: true - schema: - $ref: '#/components/schemas/UpdatePacksRequestBody' schemas: UpdatePacksRequestParams: type: object diff --git a/x-pack/plugins/osquery/common/api/saved_query/saved_query.schema.yaml b/x-pack/plugins/osquery/common/api/saved_query/saved_query.schema.yaml index 1cd832370c0cd..d8cef82ac7103 100644 --- a/x-pack/plugins/osquery/common/api/saved_query/saved_query.schema.yaml +++ b/x-pack/plugins/osquery/common/api/saved_query/saved_query.schema.yaml @@ -55,8 +55,13 @@ paths: $ref: './find_saved_query.schema.yaml#/components/schemas/SuccessResponse' put: summary: Update saved query + requestBody: + required: true + content: + application/json: + schema: + $ref: './update_saved_query.schema.yaml#/components/schemas/UpdateSavedQueryRequestBody' parameters: - - $ref: './update_saved_query.schema.yaml#/components/parameters/UpdateSavedQueryRequestQueryBody' - $ref: './update_saved_query.schema.yaml#/components/parameters/UpdateSavedQueryRequestQueryParameter' responses: '200': diff --git a/x-pack/plugins/osquery/common/api/saved_query/update_saved_query.schema.yaml b/x-pack/plugins/osquery/common/api/saved_query/update_saved_query.schema.yaml index c7004a72adee2..b91359b5bbeef 100644 --- a/x-pack/plugins/osquery/common/api/saved_query/update_saved_query.schema.yaml +++ b/x-pack/plugins/osquery/common/api/saved_query/update_saved_query.schema.yaml @@ -11,12 +11,6 @@ components: required: true schema: $ref: '#/components/schemas/UpdateSavedQueryRequestParams' - UpdateSavedQueryRequestQueryBody: - name: query - in: path - required: true - schema: - $ref: '#/components/schemas/UpdateSavedQueryRequestBody' schemas: UpdateSavedQueryRequestParams: type: object diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/actions.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/actions.schema.yaml new file mode 100644 index 0000000000000..206a293715355 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/actions.schema.yaml @@ -0,0 +1,113 @@ +openapi: 3.0.0 +info: + title: Endpoint Actions Schema + version: '2023-10-31' +paths: + /api/endpoint/action/state: + get: + summary: Get Action State schema + operationId: EndpointGetActionsState + x-codegen-enabled: false + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + + /api/endpoint/action/running_procs: + post: + summary: Get Running Processes Action + operationId: EndpointGetRunningProcessesAction + x-codegen-enabled: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/BaseActionSchema' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + + /api/endpoint/action/isolate: + post: + summary: Isolate host Action + operationId: EndpointIsolateHostAction + x-codegen-enabled: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/BaseActionSchema' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + + /api/endpoint/action/unisolate: + post: + summary: Unisolate host Action + operationId: EndpointUnisolateHostAction + x-codegen-enabled: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/BaseActionSchema' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + + /api/endpoint/action/kill_process: + post: + summary: Kill process Action + operationId: EndpointKillProcessAction + x-codegen-enabled: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/ProcessActionSchemas' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + + + /api/endpoint/action/suspend_process: + post: + summary: Suspend process Action + operationId: EndpointSuspendProcessAction + x-codegen-enabled: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/ProcessActionSchemas' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/actions_status.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/actions_status.schema.yaml new file mode 100644 index 0000000000000..88f911a49a714 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/actions_status.schema.yaml @@ -0,0 +1,27 @@ +openapi: 3.0.0 +info: + title: Get Action status schema + version: '2023-10-31' +paths: + /api/endpoint/action_status: + get: + summary: Get Actions status schema + operationId: EndpointGetActionsStatus + x-codegen-enabled: false + parameters: + - name: query + in: query + required: true + schema: + type: object + properties: + agent_ids: + $ref: '../model/schema/common.schema.yaml#/components/schemas/AgentIds' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/audit_log.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/audit_log.gen.ts new file mode 100644 index 0000000000000..a36476054dfdc --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/audit_log.gen.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +import { Page, PageSize, StartDate, EndDate, AgentId } from '../model/schema/common.gen'; + +export type AuditLogRequestQuery = z.infer; +export const AuditLogRequestQuery = z.object({ + page: Page.optional(), + page_size: PageSize.optional(), + start_date: StartDate.optional(), + end_date: EndDate.optional(), +}); + +export type AuditLogRequestParams = z.infer; +export const AuditLogRequestParams = z.object({ + agent_id: AgentId.optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/audit_log.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/audit_log.schema.yaml new file mode 100644 index 0000000000000..7deb6dab317a8 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/audit_log.schema.yaml @@ -0,0 +1,48 @@ +openapi: 3.0.0 +info: + title: Audit Log Schema + version: '2023-10-31' +paths: + /api/endpoint/action_log/{agent_id}: + get: + summary: Get action audit log schema + operationId: EndpointGetActionAuditLog + x-codegen-enabled: false + parameters: + - name: query + in: query + required: true + schema: + $ref: '#/components/schemas/AuditLogRequestQuery' + - name: query + in: path + required: true + schema: + $ref: '#/components/schemas/AuditLogRequestParams' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + +components: + schemas: + AuditLogRequestQuery: + type: object + properties: + page: + $ref: '../model/schema/common.schema.yaml#/components/schemas/Page' + page_size: + $ref: '../model/schema/common.schema.yaml#/components/schemas/PageSize' + start_date: + $ref: '../model/schema/common.schema.yaml#/components/schemas/StartDate' + end_date: + $ref: '../model/schema/common.schema.yaml#/components/schemas/EndDate' + + AuditLogRequestParams: + type: object + properties: + agent_id: + $ref: '../model/schema/common.schema.yaml#/components/schemas/AgentId' diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/details.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/details.gen.ts new file mode 100644 index 0000000000000..b9557d98e87f9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/details.gen.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +export type DetailsRequestParams = z.infer; +export const DetailsRequestParams = z.object({ + action_id: z.string().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/details.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/details.schema.yaml new file mode 100644 index 0000000000000..4f37c0a23c149 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/details.schema.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Details Schema + version: '2023-10-31' +paths: + /api/endpoint/action/{action_id}: + get: + summary: Get Action details schema + operationId: EndpointGetActionsDetails + x-codegen-enabled: false + parameters: + - name: query + in: path + required: true + schema: + $ref: '#/components/schemas/DetailsRequestParams' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' +components: + schemas: + DetailsRequestParams: + type: object + properties: + action_id: + type: string + diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/execute.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/execute.gen.ts new file mode 100644 index 0000000000000..e1176c167fcf4 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/execute.gen.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +import { BaseActionSchema, Command, Timeout } from '../model/schema/common.gen'; + +export type ExecuteActionRequestBody = z.infer; +export const ExecuteActionRequestBody = BaseActionSchema.and( + z.object({ + parameters: z.object({ + command: Command, + timeout: Timeout.optional(), + }), + }) +); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/execute.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/execute.schema.yaml new file mode 100644 index 0000000000000..1af01e1b7876b --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/execute.schema.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: Execute Action Schema + version: '2023-10-31' +paths: + /api/endpoint/action/execute: + post: + summary: Execute Action + operationId: EndpointExecuteAction + x-codegen-enabled: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExecuteActionRequestBody' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + +components: + schemas: + ExecuteActionRequestBody: + allOf: + - $ref: '../model/schema/common.schema.yaml#/components/schemas/BaseActionSchema' + - type: object + required: + - parameters + properties: + parameters: + required: + - command + type: object + properties: + command: + $ref: '../model/schema/common.schema.yaml#/components/schemas/Command' + timeout: + $ref: '../model/schema/common.schema.yaml#/components/schemas/Timeout' diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/file_download.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_download.gen.ts new file mode 100644 index 0000000000000..0b70a7676e069 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_download.gen.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +export type FileDownloadRequestParams = z.infer; +export const FileDownloadRequestParams = z.object({ + action_id: z.string(), + file_id: z.string(), +}); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/file_download.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_download.schema.yaml new file mode 100644 index 0000000000000..d34aaaf2a50ab --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_download.schema.yaml @@ -0,0 +1,36 @@ +openapi: 3.0.0 +info: + title: File Download Schema + version: '2023-10-31' +paths: + /api/endpoint/action/{action_id}/file/{file_id}/download`: + get: + summary: File Download schema + operationId: EndpointFileDownload + x-codegen-enabled: false + parameters: + - name: query + in: path + required: true + schema: + $ref: '#/components/schemas/FileDownloadRequestParams' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' +components: + schemas: + FileDownloadRequestParams: + type: object + required: + - action_id + - file_id + properties: + action_id: + type: string + file_id: + type: string + diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/file_info.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_info.gen.ts new file mode 100644 index 0000000000000..1e4e7813d35b8 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_info.gen.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +export type FileInfoRequestParams = z.infer; +export const FileInfoRequestParams = z.object({ + action_id: z.string(), + file_id: z.string(), +}); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/file_info.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_info.schema.yaml new file mode 100644 index 0000000000000..892c2df012cbd --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_info.schema.yaml @@ -0,0 +1,37 @@ +openapi: 3.0.0 +info: + title: File Info Schema + version: '2023-10-31' +paths: + /api/endpoint/action/{action_id}/file/{file_id}`: + get: + summary: File Info schema + operationId: EndpointFileInfo + x-codegen-enabled: false + parameters: + - name: query + in: path + required: true + schema: + $ref: '#/components/schemas/FileInfoRequestParams' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + +components: + schemas: + FileInfoRequestParams: + type: object + required: + - action_id + - file_id + properties: + action_id: + type: string + file_id: + type: string + diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/file_upload.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_upload.gen.ts new file mode 100644 index 0000000000000..af03b239d3913 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_upload.gen.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +import { BaseActionSchema } from '../model/schema/common.gen'; + +export type FileUploadActionRequestBody = z.infer; +export const FileUploadActionRequestBody = BaseActionSchema.and( + z.object({ + parameters: z.object({ + overwrite: z.boolean().optional().default(false), + }), + file: z.string(), + }) +); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/file_upload.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_upload.schema.yaml new file mode 100644 index 0000000000000..e62ffee79b7e7 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/file_upload.schema.yaml @@ -0,0 +1,44 @@ +openapi: 3.0.0 +info: + title: File Upload Schema + version: '2023-10-31' +paths: + /api/endpoint/action/upload: + post: + summary: Upload Action + operationId: EndpointUploadAction + x-codegen-enabled: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FileUploadActionRequestBody' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + +components: + schemas: + FileUploadActionRequestBody: + allOf: + - $ref: '../model/schema/common.schema.yaml#/components/schemas/BaseActionSchema' + - type: object + required: + - parameters + - file + properties: + parameters: + type: object + properties: + overwrite: + type: boolean + default: false + # File extends Blob - any binary data will be base-64 encoded + file: + type: string + format: binary diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/get_file.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/get_file.gen.ts new file mode 100644 index 0000000000000..d8109d433fab4 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/get_file.gen.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +import { BaseActionSchema } from '../model/schema/common.gen'; + +export type GetFileActionRequestBody = z.infer; +export const GetFileActionRequestBody = BaseActionSchema.and( + z.object({ + parameters: z.object({ + path: z.string(), + }), + }) +); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/get_file.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/get_file.schema.yaml new file mode 100644 index 0000000000000..87b7b834e2077 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/get_file.schema.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: Get File Schema + version: '2023-10-31' +paths: + /api/endpoint/action/get_file: + post: + summary: Get File Action + operationId: EndpointGetFileAction + x-codegen-enabled: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GetFileActionRequestBody' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + +components: + schemas: + GetFileActionRequestBody: + allOf: + - $ref: '../model/schema/common.schema.yaml#/components/schemas/BaseActionSchema' + - type: object + required: + - parameters + - file + properties: + parameters: + required: + - path + type: object + properties: + path: + type: string + diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/list.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/list.gen.ts new file mode 100644 index 0000000000000..f734092c22058 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/list.gen.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +import { + AgentIds, + Commands, + Page, + StartDate, + EndDate, + UserIds, + Types, + WithOutputs, +} from '../model/schema/common.gen'; + +export type ListRequestQuery = z.infer; +export const ListRequestQuery = z.object({ + agentIds: AgentIds.optional(), + commands: Commands.optional(), + page: Page.optional(), + /** + * Number of items per page + */ + pageSize: z.number().min(1).max(10000).optional().default(10), + startDate: StartDate.optional(), + endDate: EndDate.optional(), + userIds: UserIds.optional(), + types: Types.optional(), + withOutputs: WithOutputs.optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/list.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/actions/list.schema.yaml new file mode 100644 index 0000000000000..c07ad4eb253b0 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/list.schema.yaml @@ -0,0 +1,50 @@ +openapi: 3.0.0 +info: + title: Actions List Schema + version: '2023-10-31' +paths: + /api/endpoint/action: + get: + summary: Get Actions List schema + operationId: EndpointGetActionsList + x-codegen-enabled: false + parameters: + - name: query + in: query + required: true + schema: + $ref: '#/components/schemas/ListRequestQuery' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' +components: + schemas: + ListRequestQuery: + type: object + properties: + agentIds: + $ref: '../model/schema/common.schema.yaml#/components/schemas/AgentIds' + commands: + $ref: '../model/schema/common.schema.yaml#/components/schemas/Commands' + page: + $ref: '../model/schema/common.schema.yaml#/components/schemas/Page' + pageSize: + type: integer + default: 10 + minimum: 1 + maximum: 10000 + description: Number of items per page + startDate: + $ref: '../model/schema/common.schema.yaml#/components/schemas/StartDate' + endDate: + $ref: '../model/schema/common.schema.yaml#/components/schemas/EndDate' + userIds: + $ref: '../model/schema/common.schema.yaml#/components/schemas/UserIds' + types: + $ref: '../model/schema/common.schema.yaml#/components/schemas/Types' + withOutputs: + $ref: '../model/schema/common.schema.yaml#/components/schemas/WithOutputs' diff --git a/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata.gen.ts new file mode 100644 index 0000000000000..fca1370559ada --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata.gen.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +export type ListRequestQuery = z.infer; +export const ListRequestQuery = z.object({ + /** + * Page number + */ + page: z.number().min(0).optional().default(0), + /** + * Number of items per page + */ + pageSize: z.number().min(1).max(10000).optional().default(10), + kuery: z.string().nullable().optional(), + sortField: z + .enum([ + 'enrolled_at', + 'metadata.host.hostname', + 'host_status', + 'metadata.Endpoint.policy.applied.name', + 'metadata.Endpoint.policy.applied.status', + 'metadata.host.os.name', + 'metadata.host.ip', + 'metadata.agent.version', + 'last_checkin', + ]) + .optional(), + sortDirection: z.enum(['asc', 'desc']).nullable().optional(), + hostStatuses: z.array(z.enum(['healthy', 'offline', 'updating', 'inactive', 'unenrolled'])), +}); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata.schema.yaml new file mode 100644 index 0000000000000..295d8cf3ca143 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata.schema.yaml @@ -0,0 +1,54 @@ +openapi: 3.0.0 +info: + title: List Metadata Schema + version: '2023-10-31' +paths: { } +components: + schemas: + ListRequestQuery: + type: object + required: + - hostStatuses + properties: + page: + type: integer + default: 0 + minimum: 0 + description: Page number + pageSize: + type: integer + default: 10 + minimum: 1 + maximum: 10000 + description: Number of items per page + kuery: + type: string + nullable: true + sortField: + type: string + enum: + - enrolled_at + - metadata.host.hostname + - host_status + - metadata.Endpoint.policy.applied.name + - metadata.Endpoint.policy.applied.status + - metadata.host.os.name + - metadata.host.ip + - metadata.agent.version + - last_checkin + sortDirection: + type: string + enum: + - 'asc' + - 'desc' + nullable: true + hostStatuses: + type: array + items: + type: string + enum: + - healthy + - offline + - updating + - inactive + - unenrolled diff --git a/x-pack/plugins/security_solution/common/api/endpoint/metadata/metadata.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/metadata/metadata.schema.yaml new file mode 100644 index 0000000000000..55a3cfc57c351 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/metadata/metadata.schema.yaml @@ -0,0 +1,58 @@ +openapi: 3.0.0 +info: + title: Endpoint Metadata Schema + version: '2023-10-31' +paths: + /api/endpoint/metadata: + get: + summary: Get Metadata List schema + operationId: GetEndpointMetadataList + x-codegen-enabled: false + parameters: + - name: query + in: query + required: true + schema: + $ref: './list_metadata.schema.yaml#/components/schemas/ListRequestQuery' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + + /api/endpoint/metadata/transforms: + get: + summary: Get Metadata Transform schema + operationId: GetEndpointMetadataTransform + x-codegen-enabled: false + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + + /api/endpoint/metadata/{id}: + get: + summary: Get Metadata schema + operationId: GetEndpointMetadata + x-codegen-enabled: false + parameters: + - name: query + in: path + required: true + schema: + type: object + properties: + id: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' diff --git a/x-pack/plugins/security_solution/common/api/endpoint/model/schema/common.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/model/schema/common.gen.ts new file mode 100644 index 0000000000000..89f15504c4be5 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/model/schema/common.gen.ts @@ -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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +export type Id = z.infer; +export const Id = z.string(); + +export type IdOrUndefined = z.infer; +export const IdOrUndefined = Id.nullable(); + +/** + * Page number + */ +export type Page = z.infer; +export const Page = z.number().min(1).default(1); + +/** + * Number of items per page + */ +export type PageSize = z.infer; +export const PageSize = z.number().min(1).max(100).default(10); + +/** + * Start date + */ +export type StartDate = z.infer; +export const StartDate = z.string(); + +/** + * End date + */ +export type EndDate = z.infer; +export const EndDate = z.string(); + +/** + * Agent ID + */ +export type AgentId = z.infer; +export const AgentId = z.string(); + +export type AgentIds = z.infer; +export const AgentIds = z.union([z.array(z.string().min(1)).min(1).max(50), z.string().min(1)]); + +/** + * The command to be executed (cannot be an empty string) + */ +export type Command = z.infer; +export const Command = z.enum([ + 'isolate', + 'unisolate', + 'kill-process', + 'suspend-process', + 'running-processes', + 'get-file', + 'execute', + 'upload', +]); + +export type Commands = z.infer; +export const Commands = z.array(Command); + +/** + * The maximum timeout value in milliseconds (optional) + */ +export type Timeout = z.infer; +export const Timeout = z.number().min(1); + +export type Status = z.infer; +export const Status = z.enum(['failed', 'pending', 'successful']); + +export type Statuses = z.infer; +export const Statuses = z.array(Status); + +/** + * User IDs + */ +export type UserIds = z.infer; +export const UserIds = z.union([z.array(z.string().min(1)).min(1), z.string().min(1)]); + +/** + * With Outputs + */ +export type WithOutputs = z.infer; +export const WithOutputs = z.union([z.array(z.string().min(1)).min(1), z.string().min(1)]); + +export type Type = z.infer; +export const Type = z.enum(['automated', 'manual']); + +export type Types = z.infer; +export const Types = z.array(Type); + +/** + * List of endpoint IDs (cannot contain empty strings) + */ +export type EndpointIds = z.infer; +export const EndpointIds = z.array(z.string().min(1)).min(1); + +/** + * If defined, any case associated with the given IDs will be updated (cannot contain empty strings) + */ +export type AlertIds = z.infer; +export const AlertIds = z.array(z.string().min(1)).min(1); + +/** + * Case IDs to be updated (cannot contain empty strings) + */ +export type CaseIds = z.infer; +export const CaseIds = z.array(z.string().min(1)).min(1); + +/** + * Optional comment + */ +export type Comment = z.infer; +export const Comment = z.string(); + +/** + * Optional parameters object + */ +export type Parameters = z.infer; +export const Parameters = z.object({}); + +export type BaseActionSchema = z.infer; +export const BaseActionSchema = z.object({ + endpoint_ids: EndpointIds.optional(), + alert_ids: AlertIds.optional(), + case_ids: CaseIds.optional(), + comment: Comment.optional(), + parameters: Parameters.optional(), +}); + +export type ProcessActionSchemas = z.infer; +export const ProcessActionSchemas = BaseActionSchema.and( + z.object({ + parameters: z.union([ + z.object({ + pid: z.number().min(1).optional(), + }), + z.object({ + entity_id: z.string().min(1).optional(), + }), + ]), + }) +); + +export type SuccessResponse = z.infer; +export const SuccessResponse = z.object({}); diff --git a/x-pack/plugins/security_solution/common/api/endpoint/model/schema/common.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/model/schema/common.schema.yaml new file mode 100644 index 0000000000000..15d69f3639d1b --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/model/schema/common.schema.yaml @@ -0,0 +1,184 @@ +openapi: 3.0.0 +info: + title: Common Endpoint Attributes + version: '2023-10-31' +paths: { } +components: + schemas: + Id: + type: string + IdOrUndefined: + $ref: '#/components/schemas/Id' + nullable: true + Page: + type: integer + default: 1 + minimum: 1 + description: Page number + PageSize: + type: integer + default: 10 + minimum: 1 + maximum: 100 + description: Number of items per page + StartDate: + type: string + description: Start date + EndDate: + type: string + description: End date + AgentId: + type: string + description: Agent ID + + AgentIds: + oneOf: + - type: array + items: + type: string + minLength: 1 + minItems: 1 + maxItems: 50 + - type: string + minLength: 1 + minLength: 1 + + Command: + type: string + enum: + - isolate + - unisolate + - kill-process + - suspend-process + - running-processes + - get-file + - execute + - upload + minLength: 1 + description: The command to be executed (cannot be an empty string) + + Commands: + type: array + items: + $ref: '#/components/schemas/Command' + + Timeout: + type: integer + minimum: 1 + description: The maximum timeout value in milliseconds (optional) + + Status: + type: string + enum: + - failed + - pending + - successful + + Statuses: + type: array + items: + $ref: '#/components/schemas/Status' + minLength: 1 + maxLength: 3 + + UserIds: + oneOf: + - type: array + items: + type: string + minLength: 1 + minItems: 1 + - type: string + minLength: 1 + description: User IDs + + WithOutputs: + oneOf: + - type: array + items: + type: string + minLength: 1 + minItems: 1 + - type: string + minLength: 1 + description: With Outputs + + Type: + type: string + enum: + - automated + - manual + + Types: + type: array + items: + $ref: '#/components/schemas/Type' + minLength: 1 + maxLength: 2 + + EndpointIds: + type: array + items: + type: string + minLength: 1 + minItems: 1 + description: List of endpoint IDs (cannot contain empty strings) + AlertIds: + type: array + items: + type: string + minLength: 1 + minItems: 1 + description: If defined, any case associated with the given IDs will be updated (cannot contain empty strings) + CaseIds: + type: array + items: + type: string + minLength: 1 + minItems: 1 + description: Case IDs to be updated (cannot contain empty strings) + Comment: + type: string + description: Optional comment + Parameters: + type: object + description: Optional parameters object + + BaseActionSchema: + type: object + properties: + endpoint_ids: + $ref: '#/components/schemas/EndpointIds' + alert_ids: + $ref: '#/components/schemas/AlertIds' + case_ids: + $ref: '#/components/schemas/CaseIds' + comment: + $ref: '#/components/schemas/Comment' + parameters: + $ref: '#/components/schemas/Parameters' + + ProcessActionSchemas: + allOf: + - $ref: '#/components/schemas/BaseActionSchema' + - type: object + required: + - parameters + properties: + parameters: + oneOf: + - type: object + properties: + pid: + type: integer + minimum: 1 + - type: object + properties: + entity_id: + type: string + minLength: 1 + SuccessResponse: + type: object + properties: {} + # Define properties for the success response if needed + diff --git a/x-pack/plugins/security_solution/common/api/endpoint/policy/policy.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/policy/policy.gen.ts new file mode 100644 index 0000000000000..3e511f7b4aad4 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/policy/policy.gen.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +import { SuccessResponse, AgentId } from '../model/schema/common.gen'; + +export type GetAgentPolicySummaryRequestQuery = z.infer; +export const GetAgentPolicySummaryRequestQuery = z.object({ + query: z.object({ + package_name: z.string().optional(), + policy_id: z.string().nullable().optional(), + }), +}); +export type GetAgentPolicySummaryRequestQueryInput = z.input< + typeof GetAgentPolicySummaryRequestQuery +>; + +export type GetAgentPolicySummaryResponse = z.infer; +export const GetAgentPolicySummaryResponse = SuccessResponse; +export type GetPolicyResponseRequestQuery = z.infer; +export const GetPolicyResponseRequestQuery = z.object({ + query: z.object({ + agentId: AgentId.optional(), + }), +}); +export type GetPolicyResponseRequestQueryInput = z.input; + +export type GetPolicyResponseResponse = z.infer; +export const GetPolicyResponseResponse = SuccessResponse; diff --git a/x-pack/plugins/security_solution/common/api/endpoint/policy/policy.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/policy/policy.schema.yaml new file mode 100644 index 0000000000000..c054e54d99c7f --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/policy/policy.schema.yaml @@ -0,0 +1,52 @@ +openapi: 3.0.0 +info: + title: Endpoint Policy Schema + version: '2023-10-31' +paths: + /api/endpoint/policy/summaries: + get: + summary: Get Agent Policy Summary schema + operationId: GetAgentPolicySummary + x-codegen-enabled: true + parameters: + - name: query + in: query + required: true + schema: + type: object + properties: + package_name: + type: string + policy_id: + type: string + nullable: true + + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' + + /api/endpoint/policy_response: + get: + summary: Get Policy Response schema + operationId: GetPolicyResponse + x-codegen-enabled: true + parameters: + - name: query + in: query + required: true + schema: + type: object + properties: + agentId: + $ref: '../model/schema/common.schema.yaml#/components/schemas/AgentId' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' diff --git a/x-pack/plugins/security_solution/common/api/endpoint/suggestions/get_suggestions.gen.ts b/x-pack/plugins/security_solution/common/api/endpoint/suggestions/get_suggestions.gen.ts new file mode 100644 index 0000000000000..a827c94d3b5fd --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/suggestions/get_suggestions.gen.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator `yarn openapi:generate`. + */ + +import { SuccessResponse } from '../model/schema/common.gen'; + +export type GetEndpointSuggestionsRequestParams = z.infer< + typeof GetEndpointSuggestionsRequestParams +>; +export const GetEndpointSuggestionsRequestParams = z.object({ + query: z.object({ + suggestion_type: z.enum(['eventFilters']).optional(), + }), +}); +export type GetEndpointSuggestionsRequestParamsInput = z.input< + typeof GetEndpointSuggestionsRequestParams +>; + +export type GetEndpointSuggestionsRequestBody = z.infer; +export const GetEndpointSuggestionsRequestBody = z.object({ + field: z.string().optional(), + query: z.string().optional(), + filters: z.unknown(), + fieldMeta: z.unknown(), +}); +export type GetEndpointSuggestionsRequestBodyInput = z.input< + typeof GetEndpointSuggestionsRequestBody +>; + +export type GetEndpointSuggestionsResponse = z.infer; +export const GetEndpointSuggestionsResponse = SuccessResponse; diff --git a/x-pack/plugins/security_solution/common/api/endpoint/suggestions/get_suggestions.schema.yaml b/x-pack/plugins/security_solution/common/api/endpoint/suggestions/get_suggestions.schema.yaml new file mode 100644 index 0000000000000..d4db065121768 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/endpoint/suggestions/get_suggestions.schema.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.0 +info: + title: Get Suggestions Schema + version: '2023-10-31' +paths: + /api/endpoint/suggestions/{suggestion_type}: + post: + summary: Get suggestions + operationId: GetEndpointSuggestions + x-codegen-enabled: true + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - parameters + properties: + field: + type: string + query: + type: string + filters: {} + fieldMeta: {} + parameters: + - name: query + in: path + required: true + schema: + type: object + properties: + suggestion_type: + type: string + enum: + - eventFilters + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../model/schema/common.schema.yaml#/components/schemas/SuccessResponse' diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 8a6a70cbee96b..78a788c482654 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -605,6 +605,7 @@ export const installFleetPackage = ({ epmRouteService.getInstallPath(packageName, packageVersion), { query: { prerelease }, + version: '2023-10-31', body: JSON.stringify({ force }), } ); @@ -631,6 +632,7 @@ export const bulkInstallFleetPackages = ({ epmRouteService.getBulkInstallPath(), { query: { prerelease }, + version: '2023-10-31', body: JSON.stringify({ packages }), } ); diff --git a/x-pack/plugins/security_solution/public/flyout/index.tsx b/x-pack/plugins/security_solution/public/flyout/index.tsx index 922c7e55219e0..c5da39105d929 100644 --- a/x-pack/plugins/security_solution/public/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/index.tsx @@ -11,6 +11,9 @@ import { type ExpandableFlyoutProps, ExpandableFlyoutProvider, } from '@kbn/expandable-flyout'; +import type { IsolateHostPanelProps } from './isolate_host'; +import { IsolateHostPanel, IsolateHostPanelKey } from './isolate_host'; +import { IsolateHostPanelProvider } from './isolate_host/context'; import type { RightPanelProps } from './right'; import { RightPanel, RightPanelKey } from './right'; import { RightPanelProvider } from './right/context'; @@ -54,6 +57,14 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels'] ), }, + { + key: IsolateHostPanelKey, + component: (props) => ( + + + + ), + }, ]; const OuterProviders: FC = ({ children }) => { diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/content.tsx b/x-pack/plugins/security_solution/public/flyout/isolate_host/content.tsx new file mode 100644 index 0000000000000..7f3671cc60805 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/content.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FC } from 'react'; +import React, { useCallback } from 'react'; +import { useExpandableFlyoutContext } from '@kbn/expandable-flyout'; +import { EuiPanel } from '@elastic/eui'; +import { RightPanelKey } from '../right'; +import { useBasicDataFromDetailsData } from '../../timelines/components/side_panel/event_details/helpers'; +import { EndpointIsolateSuccess } from '../../common/components/endpoint/host_isolation'; +import { useHostIsolationTools } from '../../timelines/components/side_panel/event_details/use_host_isolation_tools'; +import { useIsolateHostPanelContext } from './context'; +import { HostIsolationPanel } from '../../detections/components/host_isolation'; + +/** + * Document details expandable flyout section content for the isolate host component, displaying the form or the success banner + */ +export const PanelContent: FC = () => { + const { openRightPanel } = useExpandableFlyoutContext(); + const { dataFormattedForFieldBrowser, eventId, scopeId, indexName, isolateAction } = + useIsolateHostPanelContext(); + + const { isIsolateActionSuccessBannerVisible, handleIsolationActionSuccess } = + useHostIsolationTools(); + + const { alertId, hostName } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + + const showAlertDetails = useCallback( + () => + openRightPanel({ + id: RightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }), + [eventId, indexName, scopeId, openRightPanel] + ); + + return ( + + {isIsolateActionSuccessBannerVisible && ( + + )} + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/context.tsx b/x-pack/plugins/security_solution/public/flyout/isolate_host/context.tsx new file mode 100644 index 0000000000000..6451437646a53 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/context.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { css } from '@emotion/react'; +import React, { createContext, memo, useContext, useMemo } from 'react'; +import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +import { useTimelineEventsDetails } from '../../timelines/containers/details'; +import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers'; +import { useSpaceId } from '../../common/hooks/use_space_id'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; +import { SecurityPageName } from '../../../common/constants'; +import { SourcererScopeName } from '../../common/store/sourcerer/model'; +import { useSourcererDataView } from '../../common/containers/sourcerer'; +import type { IsolateHostPanelProps } from '.'; + +export interface IsolateHostPanelContext { + /** + * Id of the document + */ + eventId: string; + /** + * Name of the index used in the parent's page + */ + indexName: string; + /** + * Maintain backwards compatibility // TODO remove when possible + */ + scopeId: string; + /** + * An array of field objects with category and value + */ + dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null; + /** + * Isolate action, either 'isolateHost' or 'unisolateHost' + */ + isolateAction: 'isolateHost' | 'unisolateHost'; +} + +export const IsolateHostPanelContext = createContext( + undefined +); + +export type IsolateHostPanelProviderProps = { + /** + * React components to render + */ + children: React.ReactNode; +} & Partial; + +export const IsolateHostPanelProvider = memo( + ({ id, indexName, scopeId, isolateAction, children }: IsolateHostPanelProviderProps) => { + const currentSpaceId = useSpaceId(); + // TODO Replace getAlertIndexAlias way to retrieving the eventIndex with the GET /_alias + // https://github.com/elastic/kibana/issues/113063 + const eventIndex = indexName ? getAlertIndexAlias(indexName, currentSpaceId) ?? indexName : ''; + const [{ pageName }] = useRouteSpy(); + const sourcererScope = + pageName === SecurityPageName.detections + ? SourcererScopeName.detections + : SourcererScopeName.default; + const sourcererDataView = useSourcererDataView(sourcererScope); + const [loading, dataFormattedForFieldBrowser] = useTimelineEventsDetails({ + indexName: eventIndex, + eventId: id ?? '', + runtimeMappings: sourcererDataView.runtimeMappings, + skip: !id, + }); + + const contextValue = useMemo( + () => + id && indexName && scopeId && isolateAction + ? { + eventId: id, + indexName, + scopeId, + dataFormattedForFieldBrowser, + isolateAction, + } + : undefined, + [id, indexName, scopeId, dataFormattedForFieldBrowser, isolateAction] + ); + + if (loading) { + return ( + + + + ); + } + + return ( + + {children} + + ); + } +); + +IsolateHostPanelProvider.displayName = 'IsolateHostPanelProvider'; + +export const useIsolateHostPanelContext = (): IsolateHostPanelContext => { + const contextValue = useContext(IsolateHostPanelContext); + + if (!contextValue) { + throw new Error( + 'IsolateHostPanelContext can only be used within IsolateHostPanelContext provider' + ); + } + + return contextValue; +}; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/header.tsx b/x-pack/plugins/security_solution/public/flyout/isolate_host/header.tsx new file mode 100644 index 0000000000000..168175878d802 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/header.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; +import type { FC } from 'react'; +import React from 'react'; +import { useIsolateHostPanelContext } from './context'; +import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids'; +import { PANEL_HEADER_ISOLATE_TITLE, PANEL_HEADER_RELEASE_TITLE } from './translations'; + +/** + * Document details expandable right section header for the isolate host panel + */ +export const PanelHeader: FC = () => { + const { isolateAction } = useIsolateHostPanelContext(); + + const title = + isolateAction === 'isolateHost' ? PANEL_HEADER_ISOLATE_TITLE : PANEL_HEADER_RELEASE_TITLE; + + return ( + + +

{title}

+
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/index.tsx b/x-pack/plugins/security_solution/public/flyout/isolate_host/index.tsx new file mode 100644 index 0000000000000..ff02d7b78a115 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/index.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FC } from 'react'; +import React from 'react'; +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { PanelContent } from './content'; +import { PanelHeader } from './header'; + +export const IsolateHostPanelKey: IsolateHostPanelProps['key'] = 'document-details-isolate-host'; + +export interface IsolateHostPanelProps extends FlyoutPanelProps { + key: 'document-details-isolate-host'; + params?: { + id: string; + indexName: string; + scopeId: string; + isolateAction: 'isolateHost' | 'unisolateHost' | undefined; + }; +} + +/** + * Panel to be displayed right section in the document details expandable flyout when isolate host is clicked in the + * take action button + */ +export const IsolateHostPanel: FC> = () => { + return ( + <> + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/isolate_host/test_ids.ts new file mode 100644 index 0000000000000..a40891c2c3d63 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/test_ids.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const FLYOUT_HEADER_TITLE_TEST_ID = 'securitySolutionDocumentDetailsFlyoutHeaderTitle'; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/translations.ts b/x-pack/plugins/security_solution/public/flyout/isolate_host/translations.ts new file mode 100644 index 0000000000000..84ec8d62c09de --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const PANEL_HEADER_ISOLATE_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.documentDetails.isolateHostPanelHeaderIsolateTitle', + { + defaultMessage: `Isolate host`, + } +); + +export const PANEL_HEADER_RELEASE_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.documentDetails.isolateHostPanelHeaderReleaseTitle', + { + defaultMessage: `Release host`, + } +); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/response_details.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/response_details.tsx index 6281a34e0d78f..03e16c0118cfa 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/response_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/left/components/response_details.tsx @@ -65,16 +65,16 @@ export const ResponseDetails: React.FC = () => { ), diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/translations.ts b/x-pack/plugins/security_solution/public/flyout/right/components/translations.ts index f623d4ee2f7db..ea83a8f3a1a23 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/translations.ts +++ b/x-pack/plugins/security_solution/public/flyout/right/components/translations.ts @@ -284,7 +284,7 @@ export const RESPONSE_TITLE = i18n.translate( ); export const RESPONSE_EMPTY = i18n.translate('xpack.securitySolution.flyout.response.empty', { - defaultMessage: 'This alert did not generate an external notification.', + defaultMessage: 'There are no response actions defined for this event.', }); export const TECHNICAL_PREVIEW_TITLE = i18n.translate( diff --git a/x-pack/plugins/security_solution/public/flyout/right/footer.tsx b/x-pack/plugins/security_solution/public/flyout/right/footer.tsx index d0980141ebfcc..b411470ee386f 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/footer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/footer.tsx @@ -6,7 +6,7 @@ */ import type { FC } from 'react'; -import React, { memo } from 'react'; +import React, { useCallback } from 'react'; import { useExpandableFlyoutContext } from '@kbn/expandable-flyout'; import { FlyoutFooter } from '../../timelines/components/side_panel/event_details/flyout'; import { useRightPanelContext } from './context'; @@ -15,13 +15,35 @@ import { useHostIsolationTools } from '../../timelines/components/side_panel/eve /** * */ -export const PanelFooter: FC = memo(() => { - const { closeFlyout } = useExpandableFlyoutContext(); - const { dataFormattedForFieldBrowser, dataAsNestedObject, refetchFlyoutData, scopeId } = - useRightPanelContext(); +export const PanelFooter: FC = () => { + const { closeFlyout, openRightPanel } = useExpandableFlyoutContext(); + const { + eventId, + indexName, + dataFormattedForFieldBrowser, + dataAsNestedObject, + refetchFlyoutData, + scopeId, + } = useRightPanelContext(); const { isHostIsolationPanelOpen, showHostIsolationPanel } = useHostIsolationTools(); + const showHostIsolationPanelCallback = useCallback( + (action: 'isolateHost' | 'unisolateHost' | undefined) => { + showHostIsolationPanel(action); + openRightPanel({ + id: 'document-details-isolate-host', + params: { + id: eventId, + indexName, + scopeId, + isolateAction: action, + }, + }); + }, + [eventId, indexName, openRightPanel, scopeId, showHostIsolationPanel] + ); + if (!dataFormattedForFieldBrowser || !dataAsNestedObject) { return null; } @@ -34,11 +56,9 @@ export const PanelFooter: FC = memo(() => { isHostIsolationPanelOpen={isHostIsolationPanelOpen} isReadOnly={false} loadingEventDetails={false} - onAddIsolationStatusClick={showHostIsolationPanel} + onAddIsolationStatusClick={showHostIsolationPanelCallback} scopeId={scopeId} refetchFlyoutData={refetchFlyoutData} /> ); -}); - -PanelFooter.displayName = 'PanelFooter'; +}; diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_bulk_delete_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_bulk_delete_artifact.test.tsx index b4f4e3ffc67d3..403731566ef3f 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_bulk_delete_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_bulk_delete_artifact.test.tsx @@ -17,6 +17,8 @@ import { import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { act } from '@testing-library/react-hooks'; +const apiVersion = '2023-10-31'; + describe('Bulk delete artifact hook', () => { let result: ReturnType; @@ -56,6 +58,7 @@ describe('Bulk delete artifact hook', () => { expect(onSuccessMock).toHaveBeenCalledTimes(1); expect(fakeHttpServices.delete).toHaveBeenCalledTimes(2); expect(fakeHttpServices.delete).toHaveBeenNthCalledWith(1, '/api/exception_lists/items', { + version: apiVersion, query: { id: 'fakeId-1', item_id: undefined, @@ -63,6 +66,7 @@ describe('Bulk delete artifact hook', () => { }, }); expect(fakeHttpServices.delete).toHaveBeenNthCalledWith(2, '/api/exception_lists/items', { + version: apiVersion, query: { id: undefined, item_id: 'fakeId-2', diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_bulk_update_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_bulk_update_artifact.test.tsx index 57a1f77243b5d..5db9734c61f9f 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_bulk_update_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_bulk_update_artifact.test.tsx @@ -17,6 +17,8 @@ import { import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { act } from '@testing-library/react-hooks'; +const apiVersion = '2023-10-31'; + describe('Bulk update artifact hook', () => { let result: ReturnType; @@ -57,9 +59,11 @@ describe('Bulk update artifact hook', () => { expect(fakeHttpServices.put).toHaveBeenCalledTimes(2); expect(fakeHttpServices.put).toHaveBeenNthCalledWith(1, '/api/exception_lists/items', { body: JSON.stringify(ExceptionsListApiClient.cleanExceptionsBeforeUpdate(exceptionItem1)), + version: apiVersion, }); expect(fakeHttpServices.put).toHaveBeenNthCalledWith(2, '/api/exception_lists/items', { body: JSON.stringify(ExceptionsListApiClient.cleanExceptionsBeforeUpdate(exceptionItem2)), + version: apiVersion, }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_create_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_create_artifact.test.tsx index a40ad85dff53c..853ea0df96a3d 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_create_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_create_artifact.test.tsx @@ -54,6 +54,7 @@ describe('Create artifact hook', () => { expect(onSuccessMock).toHaveBeenCalledTimes(1); expect(fakeHttpServices.post).toHaveBeenCalledTimes(1); expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/exception_lists/items', { + version: '2023-10-31', body: JSON.stringify(exceptionItem), }); }); diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_delete_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_delete_artifact.test.tsx index f717546be7ead..9473a50fa8a33 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_delete_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_delete_artifact.test.tsx @@ -54,6 +54,7 @@ describe('Delete artifact hook', () => { expect(onSuccessMock).toHaveBeenCalledTimes(1); expect(fakeHttpServices.delete).toHaveBeenCalledTimes(1); expect(fakeHttpServices.delete).toHaveBeenCalledWith('/api/exception_lists/items', { + version: '2023-10-31', query: { id: 'fakeId', namespace_type: 'agnostic', diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_get_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_get_artifact.test.tsx index dc444e75816c0..86acf7944b258 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_get_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_get_artifact.test.tsx @@ -49,6 +49,7 @@ describe('Get artifact hook', () => { expect(result.data).toBe(apiResponse); expect(fakeHttpServices.get).toHaveBeenCalledTimes(1); expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items', { + version: '2023-10-31', query: { item_id: 'fakeId', namespace_type: 'agnostic', diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.test.tsx index d7d68e82c9d3e..8f6e5c4353206 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.test.tsx @@ -68,6 +68,7 @@ describe('List artifact hook', () => { expect(result.data).toBe(apiResponse); expect(fakeHttpServices.get).toHaveBeenCalledTimes(1); expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + version: '2023-10-31', query: { filter: '((exception-list-agnostic.attributes.tags:"policy:policy-1" OR exception-list-agnostic.attributes.tags:"policy:all")) AND ((exception-list-agnostic.attributes.field-1:(*test*) OR exception-list-agnostic.attributes.field-1.field-2:(*test*) OR exception-list-agnostic.attributes.field-2:(*test*)))', diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx index f0e8abd533fce..310e4d0e1830e 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx @@ -62,6 +62,7 @@ describe('Summary artifact hook', () => { expect(result.data).toBe(apiResponse); expect(fakeHttpServices.get).toHaveBeenCalledTimes(1); expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/summary', { + version: '2023-10-31', query: { filter: '((exception-list-agnostic.attributes.tags:"policy:policy-1" OR exception-list-agnostic.attributes.tags:"policy:all")) AND ((exception-list-agnostic.attributes.field-1:(*test*) OR exception-list-agnostic.attributes.field-1.field-2:(*test*) OR exception-list-agnostic.attributes.field-2:(*test*)))', diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_update_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_update_artifact.test.tsx index 8a718ec6a292e..14607a33f2098 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_update_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_update_artifact.test.tsx @@ -54,6 +54,7 @@ describe('Update artifact hook', () => { expect(onSuccessMock).toHaveBeenCalledTimes(1); expect(fakeHttpServices.put).toHaveBeenCalledTimes(1); expect(fakeHttpServices.put).toHaveBeenCalledWith('/api/exception_lists/items', { + version: '2023-10-31', body: JSON.stringify(ExceptionsListApiClient.cleanExceptionsBeforeUpdate(exceptionItem)), }); }); diff --git a/x-pack/plugins/security_solution/public/management/links.test.ts b/x-pack/plugins/security_solution/public/management/links.test.ts index 28d3c727ce2a4..6a8c5525b8d58 100644 --- a/x-pack/plugins/security_solution/public/management/links.test.ts +++ b/x-pack/plugins/security_solution/public/management/links.test.ts @@ -115,6 +115,7 @@ describe('links', () => { }); describe('Host Isolation Exception', () => { + const apiVersion = '2023-10-31'; it('should return HIE if user has access permission (licensed)', async () => { (calculateEndpointAuthz as jest.Mock).mockReturnValue( getEndpointAuthzInitialStateMock({ canAccessHostIsolationExceptions: true }) @@ -154,6 +155,7 @@ describe('links', () => { expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + version: apiVersion, query: expect.objectContaining({ list_id: [ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id], }), @@ -174,6 +176,7 @@ describe('links', () => { expect(filteredLinks).toEqual(links); expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + version: apiVersion, query: expect.objectContaining({ list_id: [ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id], }), diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts index 7fd2718b45c18..a69e54bf7776d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts @@ -57,7 +57,7 @@ export class EventFiltersApiClient extends ExceptionsListApiClient { const result: string[] = await this.getHttp().post( resolvePathVariables(SUGGESTIONS_ROUTE, { suggestion_type: 'eventFilters' }), { - version: '2023-10-31', + version: this.version, body: JSON.stringify(body), } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx index 034c723c1111f..c3930506bb081 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx @@ -110,6 +110,7 @@ describe.each(listType)('Policy details %s artifact delete modal', (type) => { }) ), path: '/api/exception_lists/items', + version: '2023-10-31', }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx index c93776ef0abac..48d133cd41f29 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx @@ -28,8 +28,11 @@ import { cleanEventFilterToUpdate } from '../../../../event_filters/service/serv import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { POLICY_ARTIFACT_FLYOUT_LABELS } from './translations'; +const apiVersion = '2023-10-31'; + const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ path: '/api/exception_lists/items/_find', + version: apiVersion, query: { filter: customFilter, list_id: ['endpoint_event_filters'], @@ -217,6 +220,7 @@ describe('Policy details artifacts flyout', () => { // verify the request with the new tag await waitFor(() => { expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalledWith({ + version: apiVersion, body: JSON.stringify( getCleanedExceptionWithNewTags(exceptions.data[0], testTags, policy) ), @@ -244,6 +248,7 @@ describe('Policy details artifacts flyout', () => { await waitFor(() => { // first exception expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalledWith({ + version: apiVersion, body: JSON.stringify( getCleanedExceptionWithNewTags(exceptions.data[0], testTags, policy) ), @@ -251,6 +256,7 @@ describe('Policy details artifacts flyout', () => { }); // second exception expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalledWith({ + version: apiVersion, body: JSON.stringify( getCleanedExceptionWithNewTags(exceptions.data[0], testTags, policy) ), diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx index 2d5e74439f0f6..1ad26fd171c28 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx @@ -25,6 +25,7 @@ import { EventFiltersApiClient } from '../../../../event_filters/service/api_cli const endpointGenerator = new EndpointDocGenerator('seed'); const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ path: '/api/exception_lists/items/_find', + version: '2023-10-31', query: { filter: customFilter, list_id: ['endpoint_event_filters'], diff --git a/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts b/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts index 4439ac771fda3..213b8da54dcd0 100644 --- a/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts +++ b/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.test.ts @@ -32,6 +32,8 @@ const getQueryParams = () => ({ sortOrder: 'asc', }); +const apiVersion = '2023-10-31'; + describe('Exceptions List Api Client', () => { let fakeCoreStart: jest.Mocked; let fakeHttpServices: jest.Mocked; @@ -136,6 +138,7 @@ describe('Exceptions List Api Client', () => { expect(fakeHttpServices.get).toHaveBeenCalledTimes(1); const expectedQueryParams = getQueryParams(); expect(fakeHttpServices.get).toHaveBeenCalledWith(`${EXCEPTION_LIST_ITEM_URL}/_find`, { + version: apiVersion, query: { page: expectedQueryParams.page, per_page: expectedQueryParams.perPage, @@ -156,6 +159,7 @@ describe('Exceptions List Api Client', () => { expect(fakeHttpServices.get).toHaveBeenCalledTimes(1); expect(fakeHttpServices.get).toHaveBeenCalledWith(EXCEPTION_LIST_ITEM_URL, { + version: apiVersion, query: { item_id: fakeItemId, id: undefined, @@ -175,6 +179,7 @@ describe('Exceptions List Api Client', () => { expect(fakeHttpServices.post).toHaveBeenCalledTimes(1); expect(fakeHttpServices.post).toHaveBeenCalledWith(EXCEPTION_LIST_ITEM_URL, { + version: apiVersion, body: JSON.stringify(exceptionItem), }); }); @@ -202,6 +207,7 @@ describe('Exceptions List Api Client', () => { expect(fakeHttpServices.put).toHaveBeenCalledTimes(1); expect(fakeHttpServices.put).toHaveBeenCalledWith(EXCEPTION_LIST_ITEM_URL, { + version: apiVersion, body: JSON.stringify(ExceptionsListApiClient.cleanExceptionsBeforeUpdate(exceptionItem)), }); }); @@ -214,6 +220,7 @@ describe('Exceptions List Api Client', () => { expect(fakeHttpServices.delete).toHaveBeenCalledTimes(1); expect(fakeHttpServices.delete).toHaveBeenCalledWith(EXCEPTION_LIST_ITEM_URL, { + version: apiVersion, query: { item_id: fakeItemId, id: undefined, @@ -230,6 +237,7 @@ describe('Exceptions List Api Client', () => { expect(fakeHttpServices.get).toHaveBeenCalledTimes(1); expect(fakeHttpServices.get).toHaveBeenCalledWith(`${EXCEPTION_LIST_URL}/summary`, { + version: apiVersion, query: { filter: fakeQklFilter, list_id: getFakeListId(), @@ -248,6 +256,7 @@ describe('Exceptions List Api Client', () => { await expect(exceptionsListApiClientInstance.hasData()).resolves.toBe(true); expect(fakeHttpServices.get).toHaveBeenCalledWith(`${EXCEPTION_LIST_ITEM_URL}/_find`, { + version: apiVersion, query: expect.objectContaining({ page: 1, per_page: 1, diff --git a/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts b/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts index 4576fa74e5386..8716f4bee93ab 100644 --- a/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts +++ b/x-pack/plugins/security_solution/public/management/services/exceptions_list/exceptions_list_api_client.ts @@ -43,9 +43,11 @@ export class ExceptionsListApiClient { T extends CreateExceptionListItemSchema | UpdateExceptionListItemSchema >( item: T - ) => T + ) => T, + public readonly version?: string ) { this.ensureListExists = this.createExceptionList(); + this.version = version ?? '2023-10-31'; } /** @@ -184,6 +186,7 @@ export class ExceptionsListApiClient { const result = await this.http.get( `${EXCEPTION_LIST_ITEM_URL}/_find`, { + version: this.version, query: { page, per_page: perPage, @@ -214,6 +217,7 @@ export class ExceptionsListApiClient { await this.ensureListExists; let result = await this.http.get(EXCEPTION_LIST_ITEM_URL, { + version: this.version, query: { id, item_id: itemId, @@ -243,6 +247,7 @@ export class ExceptionsListApiClient { } return this.http.post(EXCEPTION_LIST_ITEM_URL, { + version: this.version, body: JSON.stringify(transformedException), }); } @@ -260,6 +265,7 @@ export class ExceptionsListApiClient { } return this.http.put(EXCEPTION_LIST_ITEM_URL, { + version: this.version, body: JSON.stringify( ExceptionsListApiClient.cleanExceptionsBeforeUpdate(transformedException) ), @@ -277,6 +283,7 @@ export class ExceptionsListApiClient { await this.ensureListExists; return this.http.delete(EXCEPTION_LIST_ITEM_URL, { + version: this.version, query: { id, item_id: itemId, @@ -292,6 +299,7 @@ export class ExceptionsListApiClient { async summary(filter?: string): Promise { await this.ensureListExists; return this.http.get(`${EXCEPTION_LIST_URL}/summary`, { + version: this.version, query: { filter, list_id: this.listId, diff --git a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering.ts b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering.ts index e7e1ef475f24f..c2770a53ff41d 100644 --- a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering.ts +++ b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering.ts @@ -4,21 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { - CSPM_POLICY_TEMPLATE, - KSPM_POLICY_TEMPLATE, - CNVM_POLICY_TEMPLATE, -} from '@kbn/cloud-security-posture-plugin/common/constants'; import { ProductLine } from '../../common/product'; import { getCloudSecurityUsageRecord } from './cloud_security_metering_task'; -import type { PostureType } from './types'; +import { CLOUD_DEFEND, CNVM, CSPM, KSPM } from './constants'; +import type { CloudSecuritySolutions } from './types'; import type { MeteringCallbackInput, Tier, UsageRecord } from '../types'; import type { ServerlessSecurityConfig } from '../config'; -export const CLOUD_SECURITY_TASK_TYPE = 'cloud_security'; -export const AGGREGATION_PRECISION_THRESHOLD = 40000; - export const cloudSecurityMetringCallback = async ({ esClient, cloudSetup, @@ -36,28 +28,26 @@ export const cloudSecurityMetringCallback = async ({ const tier: Tier = getCloudProductTier(config); try { - const postureTypes: PostureType[] = [ - CSPM_POLICY_TEMPLATE, - KSPM_POLICY_TEMPLATE, - CNVM_POLICY_TEMPLATE, - ]; + const cloudSecuritySolutions: CloudSecuritySolutions[] = [CSPM, KSPM, CNVM, CLOUD_DEFEND]; const cloudSecurityUsageRecords = await Promise.all( - postureTypes.map((postureType) => + cloudSecuritySolutions.map((cloudSecuritySolution) => getCloudSecurityUsageRecord({ esClient, projectId, logger, taskId, lastSuccessfulReport, - postureType, + cloudSecuritySolution, tier, }) ) ); // remove any potential undefined values from the array, - return cloudSecurityUsageRecords.filter(Boolean) as UsageRecord[]; + return cloudSecurityUsageRecords + .filter((record) => record !== undefined && record.length > 0) + .flatMap((record) => record) as UsageRecord[]; } catch (err) { logger.error(`Failed to fetch Cloud Security metering data ${err}`); return []; diff --git a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.test.ts b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.test.ts index 66cb9bc748c09..8e03c61050439 100644 --- a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.test.ts +++ b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.test.ts @@ -6,40 +6,33 @@ */ import Chance from 'chance'; import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; -import { - CSPM_POLICY_TEMPLATE, - KSPM_POLICY_TEMPLATE, - CNVM_POLICY_TEMPLATE, -} from '@kbn/cloud-security-posture-plugin/common/constants'; -import { CLOUD_SECURITY_TASK_TYPE, getCloudProductTier } from './cloud_security_metering'; + +import { getCloudProductTier } from './cloud_security_metering'; import { getCloudSecurityUsageRecord } from './cloud_security_metering_task'; import type { ServerlessSecurityConfig } from '../config'; -import type { PostureType } from './types'; +import type { CloudSecuritySolutions } from './types'; import type { ProductTier } from '../../common/product'; +import { CLOUD_SECURITY_TASK_TYPE, CSPM, KSPM, CNVM } from './constants'; const mockEsClient = elasticsearchServiceMock.createStart().client.asInternalUser; const logger: ReturnType = loggingSystemMock.createLogger(); const chance = new Chance(); -const postureTypes: PostureType[] = [ - CSPM_POLICY_TEMPLATE, - KSPM_POLICY_TEMPLATE, - CNVM_POLICY_TEMPLATE, -]; +const cloudSecuritySolutions: CloudSecuritySolutions[] = [CSPM, KSPM, CNVM]; describe('getCloudSecurityUsageRecord', () => { beforeEach(() => { jest.resetAllMocks(); }); - it('should return undefined if postureType is missing', async () => { + it('should return undefined if cloudSecuritySolution is missing', async () => { // Mock Elasticsearch search to throw an error mockEsClient.search.mockRejectedValue({}); const projectId = chance.guid(); const taskId = chance.guid(); - const postureType = CSPM_POLICY_TEMPLATE; + const cloudSecuritySolution = CSPM; const tier = 'essentials' as ProductTier; @@ -49,16 +42,16 @@ describe('getCloudSecurityUsageRecord', () => { logger, taskId, lastSuccessfulReport: new Date(), - postureType, + cloudSecuritySolution, tier, }); expect(result).toBeUndefined(); }); - test.each(postureTypes)( - 'should return usageRecords with correct values for cspm and kspm when Elasticsearch response has aggregations', - async (postureType) => { + test.each(cloudSecuritySolutions)( + 'should return usageRecords with correct values for cspm, kspm, and cnvm when Elasticsearch response has aggregations', + async (cloudSecuritySolution) => { // @ts-ignore mockEsClient.search.mockResolvedValueOnce({ hits: { hits: [{ _id: 'someRecord', _index: 'mockIndex' }] }, // mocking for indexHasDataInDateRange @@ -87,28 +80,32 @@ describe('getCloudSecurityUsageRecord', () => { logger, taskId, lastSuccessfulReport: new Date(), - postureType, + cloudSecuritySolution, tier, }); - expect(result).toEqual({ - id: expect.stringContaining(`${CLOUD_SECURITY_TASK_TYPE}_${postureType}_${projectId}`), - usage_timestamp: '2023-07-30T15:11:41.738Z', - creation_timestamp: expect.any(String), // Expect a valid ISO string - usage: { - type: CLOUD_SECURITY_TASK_TYPE, - sub_type: postureType, - quantity: 10, - period_seconds: expect.any(Number), - }, - source: { - id: taskId, - instance_group_id: projectId, - metadata: { - tier: 'essentials', + expect(result).toEqual([ + { + id: expect.stringContaining( + `${CLOUD_SECURITY_TASK_TYPE}_${cloudSecuritySolution}_${projectId}` + ), + usage_timestamp: '2023-07-30T15:11:41.738Z', + creation_timestamp: expect.any(String), // Expect a valid ISO string + usage: { + type: CLOUD_SECURITY_TASK_TYPE, + sub_type: cloudSecuritySolution, + quantity: 10, + period_seconds: expect.any(Number), + }, + source: { + id: taskId, + instance_group_id: projectId, + metadata: { + tier: 'essentials', + }, }, }, - }); + ]); } ); @@ -118,7 +115,7 @@ describe('getCloudSecurityUsageRecord', () => { const projectId = chance.guid(); const taskId = chance.guid(); - const postureType = CSPM_POLICY_TEMPLATE; + const cloudSecuritySolution = CSPM; const tier = 'essentials' as ProductTier; @@ -128,7 +125,7 @@ describe('getCloudSecurityUsageRecord', () => { logger, taskId, lastSuccessfulReport: new Date(), - postureType, + cloudSecuritySolution, tier, }); @@ -141,7 +138,7 @@ describe('getCloudSecurityUsageRecord', () => { const projectId = chance.guid(); const taskId = chance.guid(); - const postureType = CSPM_POLICY_TEMPLATE; + const cloudSecuritySolution = CSPM; const tier = 'essentials' as ProductTier; @@ -151,7 +148,7 @@ describe('getCloudSecurityUsageRecord', () => { logger, taskId, lastSuccessfulReport: new Date(), - postureType, + cloudSecuritySolution, tier, }); @@ -175,6 +172,100 @@ describe('should return the relevant product tier', () => { expect(tier).toBe('complete'); }); + it('should return usageRecords with correct values for cloud defend', async () => { + const cloudSecuritySolution = 'cloud_defend'; + // @ts-ignore + mockEsClient.search.mockResolvedValueOnce({ + hits: { hits: [{ _id: 'someRecord', _index: 'mockIndex' }] }, // mocking for indexHasDataInDateRange + }); + + // @ts-ignore + mockEsClient.search.mockResolvedValueOnce({ + aggregations: { + asset_count_groups: { + buckets: [ + { + key_as_string: 'true', + unique_assets: { + value: 10, + }, + min_timestamp: { + value_as_string: '2023-07-30T15:11:41.738Z', + }, + }, + { + key_as_string: 'false', + unique_assets: { + value: 5, + }, + min_timestamp: { + value_as_string: '2023-07-30T15:11:41.738Z', + }, + }, + ], + }, + }, + }); + + const projectId = chance.guid(); + const taskId = chance.guid(); + + const tier = 'essentials' as ProductTier; + + const result = await getCloudSecurityUsageRecord({ + esClient: mockEsClient, + projectId, + logger, + taskId, + lastSuccessfulReport: new Date(), + cloudSecuritySolution, + tier, + }); + + expect(result).toEqual([ + { + id: expect.stringContaining( + `${CLOUD_SECURITY_TASK_TYPE}_${cloudSecuritySolution}_${projectId}` + ), + usage_timestamp: '2023-07-30T15:11:41.738Z', + creation_timestamp: expect.any(String), // Expect a valid ISO string + usage: { + type: CLOUD_SECURITY_TASK_TYPE, + sub_type: `${cloudSecuritySolution}_block_action_enabled_true`, + quantity: 10, + period_seconds: expect.any(Number), + }, + source: { + id: taskId, + instance_group_id: projectId, + metadata: { + tier: 'essentials', + }, + }, + }, + { + id: expect.stringContaining( + `${CLOUD_SECURITY_TASK_TYPE}_${cloudSecuritySolution}_${projectId}` + ), + usage_timestamp: '2023-07-30T15:11:41.738Z', + creation_timestamp: expect.any(String), // Expect a valid ISO string + usage: { + type: CLOUD_SECURITY_TASK_TYPE, + sub_type: `${cloudSecuritySolution}_block_action_enabled_false`, + quantity: 5, + period_seconds: expect.any(Number), + }, + source: { + id: taskId, + instance_group_id: projectId, + metadata: { + tier: 'essentials', + }, + }, + }, + ]); + }); + it('should return none tier in case cloud product line is missing ', async () => { const serverlessSecurityConfig = { enabled: true, diff --git a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.ts b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.ts index 95fe58df0f174..1a9501424ba00 100644 --- a/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.ts +++ b/x-pack/plugins/security_solution_serverless/server/cloud_security/cloud_security_metering_task.ts @@ -5,88 +5,65 @@ * 2.0. */ -import { - CNVM_POLICY_TEMPLATE, - CSPM_POLICY_TEMPLATE, - KSPM_POLICY_TEMPLATE, - LATEST_FINDINGS_INDEX_PATTERN, - LATEST_VULNERABILITIES_INDEX_PATTERN, -} from '@kbn/cloud-security-posture-plugin/common/constants'; +import type { Logger } from '@kbn/core/server'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; -import type { UsageRecord } from '../types'; - import { AGGREGATION_PRECISION_THRESHOLD, + ASSETS_SAMPLE_GRANULARITY, + CLOUD_DEFEND, CLOUD_SECURITY_TASK_TYPE, -} from './cloud_security_metering'; -import { cloudSecurityMetringTaskProperties } from './cloud_security_metering_task_config'; + CNVM, + CSPM, + KSPM, + METERING_CONFIGS, + THRESHOLD_MINUTES, +} from './constants'; +import type { Tier, UsageRecord } from '../types'; import type { CloudSecurityMeteringCallbackInput, - PostureType, - ResourceCountAggregation, + CloudSecuritySolutions, + AssetCountAggregation, + CloudDefendAssetCountAggregation, } from './types'; -const ASSETS_SAMPLE_GRANULARITY = '24h'; - -const queryParams = { - [CSPM_POLICY_TEMPLATE]: { - index: LATEST_FINDINGS_INDEX_PATTERN, - assets_identifier: 'resource.id', - }, - [KSPM_POLICY_TEMPLATE]: { - index: LATEST_FINDINGS_INDEX_PATTERN, - assets_identifier: 'agent.id', - }, - [CNVM_POLICY_TEMPLATE]: { - index: LATEST_VULNERABILITIES_INDEX_PATTERN, - assets_identifier: 'cloud.instance.id', - }, -}; - -export const getCloudSecurityUsageRecord = async ({ - esClient, - projectId, - logger, - taskId, - postureType, - tier, -}: CloudSecurityMeteringCallbackInput): Promise => { - try { - if (!postureType) { - logger.error('posture type is missing'); - return; - } - - if (!(await indexHasDataInDateRange(esClient, postureType))) return; - - const response = await esClient.search( - getAggQueryByPostureType(postureType) - ); +export const getUsageRecords = ( + assetCountAggregations: AssetCountAggregation[], + cloudSecuritySolution: CloudSecuritySolutions, + taskId: string, + tier: Tier, + projectId: string, + periodSeconds: number, + logger: Logger +): UsageRecord[] => { + const usageRecords = assetCountAggregations.map((assetCountAggregation) => { + const assetCount = assetCountAggregation.unique_assets.value; - if (!response.aggregations) { - return; - } - const resourceCount = response.aggregations.unique_assets.value; - if (resourceCount > AGGREGATION_PRECISION_THRESHOLD) { + if (assetCount > AGGREGATION_PRECISION_THRESHOLD) { logger.warn( - `The number of unique resources for {${postureType}} is ${resourceCount}, which is higher than the AGGREGATION_PRECISION_THRESHOLD of ${AGGREGATION_PRECISION_THRESHOLD}.` + `The number of unique resources for {${cloudSecuritySolution}} is ${assetCount}, which is higher than the AGGREGATION_PRECISION_THRESHOLD of ${AGGREGATION_PRECISION_THRESHOLD}.` ); } - const minTimestamp = response.aggregations - ? new Date(response.aggregations.min_timestamp.value_as_string).toISOString() - : new Date().toISOString(); + + const minTimestamp = new Date( + assetCountAggregation.min_timestamp.value_as_string + ).toISOString(); const creationTimestamp = new Date().toISOString(); - const usageRecord = { - id: `${CLOUD_SECURITY_TASK_TYPE}_${postureType}_${projectId}_${creationTimestamp}`, + const subType = + cloudSecuritySolution === CLOUD_DEFEND + ? `${CLOUD_DEFEND}_block_action_enabled_${assetCountAggregation.key_as_string}` + : cloudSecuritySolution; + + const usageRecord: UsageRecord = { + id: `${CLOUD_SECURITY_TASK_TYPE}_${cloudSecuritySolution}_${projectId}_${creationTimestamp}`, usage_timestamp: minTimestamp, creation_timestamp: creationTimestamp, usage: { type: CLOUD_SECURITY_TASK_TYPE, - sub_type: postureType, - quantity: resourceCount, - period_seconds: cloudSecurityMetringTaskProperties.periodSeconds, + sub_type: subType, + quantity: assetCount, + period_seconds: periodSeconds, }, source: { id: taskId, @@ -95,40 +72,85 @@ export const getCloudSecurityUsageRecord = async ({ }, }; - logger.debug(`Fetched ${postureType} metring data`); - return usageRecord; - } catch (err) { - logger.error(`Failed to fetch ${postureType} metering data ${err}`); - } + }); + return usageRecords; }; -const indexHasDataInDateRange = async (esClient: ElasticsearchClient, postureType: PostureType) => { - const response = await esClient.search({ - index: queryParams[postureType].index, - size: 1, - _source: false, - query: getSearchQueryByPostureType(postureType), - }); +export const getAggregationByCloudSecuritySolution = ( + cloudSecuritySolution: CloudSecuritySolutions +) => { + if (cloudSecuritySolution === CLOUD_DEFEND) { + return { + asset_count_groups: { + terms: { + field: 'cloud_defend.block_action_enabled', + }, + aggs: { + unique_assets: { + cardinality: { + field: METERING_CONFIGS[cloudSecuritySolution].assets_identifier, + }, + }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, + }, + }, + }; + } - return response.hits.hits.length > 0; + return { + unique_assets: { + cardinality: { + field: METERING_CONFIGS[cloudSecuritySolution].assets_identifier, + precision_threshold: AGGREGATION_PRECISION_THRESHOLD, + }, + }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, + }; }; -export const getSearchQueryByPostureType = (postureType: PostureType) => { +export const getSearchQueryByCloudSecuritySolution = ( + cloudSecuritySolution: CloudSecuritySolutions, + searchFrom: Date +) => { const mustFilters = []; - mustFilters.push({ - range: { - '@timestamp': { - gte: `now-${ASSETS_SAMPLE_GRANULARITY}`, + if (cloudSecuritySolution === CLOUD_DEFEND) { + mustFilters.push({ + range: { + '@timestamp': { + gt: searchFrom.toISOString(), + }, }, - }, - }); + }); + } + + if ( + cloudSecuritySolution === CSPM || + cloudSecuritySolution === KSPM || + cloudSecuritySolution === CNVM + ) { + mustFilters.push({ + range: { + '@timestamp': { + gte: `now-${ASSETS_SAMPLE_GRANULARITY}`, + }, + }, + }); + } - if (postureType === CSPM_POLICY_TEMPLATE || postureType === KSPM_POLICY_TEMPLATE) { + if (cloudSecuritySolution === CSPM || cloudSecuritySolution === KSPM) { mustFilters.push({ term: { - 'rule.benchmark.posture_type': postureType, + 'rule.benchmark.posture_type': cloudSecuritySolution, }, }); } @@ -140,25 +162,111 @@ export const getSearchQueryByPostureType = (postureType: PostureType) => { }; }; -export const getAggQueryByPostureType = (postureType: PostureType) => { - const query = { - index: queryParams[postureType].index, - query: getSearchQueryByPostureType(postureType), +export const getAssetAggQueryByCloudSecuritySolution = ( + cloudSecuritySolution: CloudSecuritySolutions, + searchFrom: Date +) => { + const query = getSearchQueryByCloudSecuritySolution(cloudSecuritySolution, searchFrom); + const aggs = getAggregationByCloudSecuritySolution(cloudSecuritySolution); + + return { + index: METERING_CONFIGS[cloudSecuritySolution].index, + query, size: 0, - aggs: { - unique_assets: { - cardinality: { - field: queryParams[postureType].assets_identifier, - precision_threshold: AGGREGATION_PRECISION_THRESHOLD, - }, - }, - min_timestamp: { - min: { - field: '@timestamp', - }, - }, - }, + aggs, }; +}; + +export const getAssetAggByCloudSecuritySolution = async ( + esClient: ElasticsearchClient, + cloudSecuritySolution: CloudSecuritySolutions, + searchFrom: Date +): Promise => { + const assetsAggQuery = getAssetAggQueryByCloudSecuritySolution(cloudSecuritySolution, searchFrom); + + if (cloudSecuritySolution === CLOUD_DEFEND) { + const response = await esClient.search( + assetsAggQuery + ); + + if (!response.aggregations || !response.aggregations.asset_count_groups.buckets.length) + return []; + return response.aggregations.asset_count_groups.buckets; + } + + const response = await esClient.search(assetsAggQuery); + if (!response.aggregations) return []; + + return [response.aggregations]; +}; + +const indexHasDataInDateRange = async ( + esClient: ElasticsearchClient, + cloudSecuritySolution: CloudSecuritySolutions, + searchFrom: Date +) => { + const response = await esClient.search({ + index: METERING_CONFIGS[cloudSecuritySolution].index, + size: 1, + _source: false, + query: getSearchQueryByCloudSecuritySolution(cloudSecuritySolution, searchFrom), + }); + + return response.hits.hits.length > 0; +}; + +const getSearchStartDate = (lastSuccessfulReport: Date): Date => { + const initialDate = new Date(); + const thresholdDate = new Date(initialDate.getTime() - THRESHOLD_MINUTES * 60 * 1000); + + let lastSuccessfulReport1; + + if (lastSuccessfulReport) { + lastSuccessfulReport1 = new Date(lastSuccessfulReport); + + const searchFrom = + lastSuccessfulReport && lastSuccessfulReport1 > thresholdDate + ? lastSuccessfulReport1 + : thresholdDate; + return searchFrom; + } + return thresholdDate; +}; + +export const getCloudSecurityUsageRecord = async ({ + esClient, + projectId, + taskId, + lastSuccessfulReport, + cloudSecuritySolution, + tier, + logger, +}: CloudSecurityMeteringCallbackInput): Promise => { + try { + const searchFrom = getSearchStartDate(lastSuccessfulReport); + + if (!(await indexHasDataInDateRange(esClient, cloudSecuritySolution, searchFrom))) return; + + const periodSeconds = Math.floor((new Date().getTime() - searchFrom.getTime()) / 1000); - return query; + const assetCountAggregations = await getAssetAggByCloudSecuritySolution( + esClient, + cloudSecuritySolution, + searchFrom + ); + + const usageRecords = await getUsageRecords( + assetCountAggregations, + cloudSecuritySolution, + taskId, + tier, + projectId, + periodSeconds, + logger + ); + + return usageRecords; + } catch (err) { + logger.error(`Failed to fetch ${cloudSecuritySolution} metering data ${err}`); + } }; diff --git a/x-pack/plugins/security_solution_serverless/server/cloud_security/constants.ts b/x-pack/plugins/security_solution_serverless/server/cloud_security/constants.ts new file mode 100644 index 0000000000000..9203a7fdff287 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/server/cloud_security/constants.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CNVM_POLICY_TEMPLATE, + CSPM_POLICY_TEMPLATE, + KSPM_POLICY_TEMPLATE, + LATEST_FINDINGS_INDEX_PATTERN, + LATEST_VULNERABILITIES_INDEX_PATTERN, +} from '@kbn/cloud-security-posture-plugin/common/constants'; +import { INTEGRATION_PACKAGE_NAME } from '@kbn/cloud-defend-plugin/common/constants'; + +const CLOUD_DEFEND_HEARTBEAT_INDEX = 'metrics-cloud_defend.heartbeat'; +export const CLOUD_SECURITY_TASK_TYPE = 'cloud_security'; +export const AGGREGATION_PRECISION_THRESHOLD = 40000; +export const ASSETS_SAMPLE_GRANULARITY = '124h'; +export const THRESHOLD_MINUTES = 30; + +export const CSPM = CSPM_POLICY_TEMPLATE; +export const KSPM = KSPM_POLICY_TEMPLATE; +export const CNVM = CNVM_POLICY_TEMPLATE; +export const CLOUD_DEFEND = INTEGRATION_PACKAGE_NAME; + +export const METERING_CONFIGS = { + [CSPM]: { + index: LATEST_FINDINGS_INDEX_PATTERN, + assets_identifier: 'resource.id', + }, + [KSPM]: { + index: LATEST_FINDINGS_INDEX_PATTERN, + assets_identifier: 'agent.id', + }, + [CNVM]: { + index: LATEST_VULNERABILITIES_INDEX_PATTERN, + assets_identifier: 'cloud.instance.id', + }, + [CLOUD_DEFEND]: { + index: CLOUD_DEFEND_HEARTBEAT_INDEX, + assets_identifier: 'agent.id', + }, +}; diff --git a/x-pack/plugins/security_solution_serverless/server/cloud_security/types.ts b/x-pack/plugins/security_solution_serverless/server/cloud_security/types.ts index be4dbeebf52bd..62ded11d5ad1e 100644 --- a/x-pack/plugins/security_solution_serverless/server/cloud_security/types.ts +++ b/x-pack/plugins/security_solution_serverless/server/cloud_security/types.ts @@ -5,14 +5,17 @@ * 2.0. */ -import type { - CSPM_POLICY_TEMPLATE, - KSPM_POLICY_TEMPLATE, - CNVM_POLICY_TEMPLATE, -} from '@kbn/cloud-security-posture-plugin/common/constants'; +import type { CSPM, KSPM, CNVM, CLOUD_DEFEND } from './constants'; import type { MeteringCallbackInput, Tier } from '../types'; -export interface ResourceCountAggregation { +export interface CloudDefendAssetCountAggregation { + asset_count_groups: AssetCountAggregationBucket; +} +export interface AssetCountAggregationBucket { + buckets: AssetCountAggregation[]; +} +export interface AssetCountAggregation { + key_as_string: string; min_timestamp: MinTimestamp; unique_assets: { value: number; @@ -24,14 +27,11 @@ export interface MinTimestamp { value_as_string: string; } -export type PostureType = - | typeof CSPM_POLICY_TEMPLATE - | typeof KSPM_POLICY_TEMPLATE - | typeof CNVM_POLICY_TEMPLATE; +export type CloudSecuritySolutions = typeof CSPM | typeof KSPM | typeof CNVM | typeof CLOUD_DEFEND; export interface CloudSecurityMeteringCallbackInput extends Omit { projectId: string; - postureType: PostureType; + cloudSecuritySolution: CloudSecuritySolutions; tier: Tier; } diff --git a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts index 673bda309aec7..a5ea75c615a3b 100644 --- a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts +++ b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts @@ -131,7 +131,7 @@ export class SecurityUsageReportingTask { config: this.config, }); } catch (err) { - this.logger.error(`failed to retrieve usage records: ${JSON.stringify(err)}`); + this.logger.error(`failed to retrieve usage records: ${err}`); return; } @@ -153,7 +153,7 @@ export class SecurityUsageReportingTask { `usage records report was sent successfully: ${usageReportResponse.status}, ${usageReportResponse.statusText}` ); } catch (err) { - this.logger.error(`Failed to send usage records report ${JSON.stringify(err)} `); + this.logger.error(`Failed to send usage records report ${err} `); } } diff --git a/x-pack/plugins/security_solution_serverless/tsconfig.json b/x-pack/plugins/security_solution_serverless/tsconfig.json index 0f517caa077aa..2ad30566e55cd 100644 --- a/x-pack/plugins/security_solution_serverless/tsconfig.json +++ b/x-pack/plugins/security_solution_serverless/tsconfig.json @@ -41,6 +41,7 @@ "@kbn/cases-plugin", "@kbn/fleet-plugin", "@kbn/core-elasticsearch-server", - "@kbn/usage-collection-plugin" + "@kbn/usage-collection-plugin", + "@kbn/cloud-defend-plugin" ] } diff --git a/x-pack/plugins/serverless_search/public/application/components/overview.tsx b/x-pack/plugins/serverless_search/public/application/components/overview.tsx index a01ebdb941eda..d39346ab2b1b2 100644 --- a/x-pack/plugins/serverless_search/public/application/components/overview.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/overview.tsx @@ -52,7 +52,7 @@ export const ElasticsearchOverview = () => { const [selectedLanguage, setSelectedLanguage] = useState(javascriptDefinition); const [clientApiKey, setClientApiKey] = useState(API_KEY_PLACEHOLDER); - const { application, cloud, http, userProfile, share } = useKibanaServices(); + const { application, cloud, http, user, share } = useKibanaServices(); const elasticsearchURL = useMemo(() => { return cloud?.elasticsearchUrl ?? ELASTICSEARCH_URL_PLACEHOLDER; @@ -73,7 +73,7 @@ export const ElasticsearchOverview = () => { - + diff --git a/x-pack/plugins/serverless_search/public/application/hooks/use_kibana.tsx b/x-pack/plugins/serverless_search/public/application/hooks/use_kibana.tsx index 3a11ee645ffad..48df795609213 100644 --- a/x-pack/plugins/serverless_search/public/application/hooks/use_kibana.tsx +++ b/x-pack/plugins/serverless_search/public/application/hooks/use_kibana.tsx @@ -9,12 +9,12 @@ import { CloudStart } from '@kbn/cloud-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import { useKibana as useKibanaBase } from '@kbn/kibana-react-plugin/public'; -import { GetUserProfileResponse, UserProfileData } from '@kbn/security-plugin/common'; +import { AuthenticatedUser } from '@kbn/security-plugin/common'; export interface ServerlessSearchContext { cloud: CloudStart; share: SharePluginStart; - userProfile: GetUserProfileResponse; + user?: AuthenticatedUser; } type ServerlessSearchKibanaContext = CoreStart & ServerlessSearchContext; diff --git a/x-pack/plugins/serverless_search/public/plugin.ts b/x-pack/plugins/serverless_search/public/plugin.ts index c3faeb15f4384..6bd5b384fd581 100644 --- a/x-pack/plugins/serverless_search/public/plugin.ts +++ b/x-pack/plugins/serverless_search/public/plugin.ts @@ -8,6 +8,7 @@ import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { appIds } from '@kbn/management-cards-navigation'; +import { AuthenticatedUser } from '@kbn/security-plugin/common'; import { createServerlessSearchSideNavComponent as createComponent } from './layout/nav'; import { docLinks } from '../common/doc_links'; import { @@ -41,10 +42,15 @@ export class ServerlessSearchPlugin const [coreStart, services] = await core.getStartServices(); const { security } = services; docLinks.setDocLinks(coreStart.docLinks.links); + let user: AuthenticatedUser | undefined; + try { + const response = await security.authc.getCurrentUser(); + user = response; + } catch { + user = undefined; + } - const userProfile = await security.userProfiles.getCurrent(); - - return await renderApp(element, coreStart, { userProfile, ...services }); + return await renderApp(element, coreStart, { user, ...services }); }, }); @@ -58,12 +64,9 @@ export class ServerlessSearchPlugin async mount({ element }: AppMountParameters) { const { renderApp } = await import('./application/connectors'); const [coreStart, services] = await core.getStartServices(); - const { security } = services; - docLinks.setDocLinks(coreStart.docLinks.links); - const userProfile = await security.userProfiles.getCurrent(); - - return await renderApp(element, coreStart, { userProfile, ...services }); + docLinks.setDocLinks(coreStart.docLinks.links); + return await renderApp(element, coreStart, { ...services }); }, }); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx index 5a26839a7b284..5cc79bdf3503d 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx @@ -150,7 +150,7 @@ export const EsqlQueryExpression: React.FC<
diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/query_form_type_chooser.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/query_form_type_chooser.tsx index a2d45c78de9c9..63d0b0d36fe53 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/query_form_type_chooser.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/query_form_type_chooser.tsx @@ -73,13 +73,13 @@ export const QueryFormTypeChooser: React.FC = ({ label: i18n.translate( 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.esqlFormTypeLabel', { - defaultMessage: 'ESQL', + defaultMessage: 'ES|QL', } ), description: i18n.translate( 'xpack.stackAlerts.esQuery.ui.selectQueryFormType.esqlFormTypeDescription', { - defaultMessage: 'Use ESQL to define a text-based query.', + defaultMessage: 'Use ES|QL to define a text-based query.', } ), }); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.test.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.test.ts index f43adeab3a3a8..90df11eb0c557 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.test.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.test.ts @@ -289,7 +289,7 @@ describe('expression params validation', () => { searchType: SearchType.esqlQuery, } as EsQueryRuleParams; expect(validateExpression(initialParams).errors.esqlQuery.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.esqlQuery[0]).toBe(`ESQL query is required.`); + expect(validateExpression(initialParams).errors.esqlQuery[0]).toBe(`ES|QL query is required.`); }); test('if esqlQuery timeField property is not defined should return proper error message', () => { diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts index 7d7c34a76927c..f2d1de5ef8695 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts @@ -231,7 +231,7 @@ const validateEsqlQueryParams = (ruleParams: EsQueryRuleParams({ method: 'POST', diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts index 6adb6e092d56f..8c63e0e0ac7f0 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts @@ -113,7 +113,7 @@ describe('ruleType', () => { "name": "index", }, Object { - "description": "ESQL query field used to fetch data from Elasticsearch.", + "description": "ES|QL query field used to fetch data from Elasticsearch.", "name": "esqlQuery", }, ], diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts index d0f7f7b816d8e..fc70403b174d3 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts @@ -134,7 +134,7 @@ export function getRuleType( const actionVariableEsqlQueryLabel = i18n.translate( 'xpack.stackAlerts.esQuery.actionVariableContextEsqlQueryLabel', { - defaultMessage: 'ESQL query field used to fetch data from Elasticsearch.', + defaultMessage: 'ES|QL query field used to fetch data from Elasticsearch.', } ); diff --git a/x-pack/plugins/threat_intelligence/cypress/cypress.config.ts b/x-pack/plugins/threat_intelligence/cypress/cypress.config.ts index fd438bf8c4d2c..611e00f08686e 100644 --- a/x-pack/plugins/threat_intelligence/cypress/cypress.config.ts +++ b/x-pack/plugins/threat_intelligence/cypress/cypress.config.ts @@ -38,6 +38,8 @@ export default defineCypressConfig({ viewportHeight: 946, viewportWidth: 1680, env: { + grepFilterSpecs: true, + grepTags: '@ess', protocol: 'http', hostname: 'localhost', configport: '5601', @@ -45,6 +47,10 @@ export default defineCypressConfig({ e2e: { baseUrl: 'http://localhost:5601', experimentalMemoryManagement: true, - specPattern: './cypress/e2e/**/*.cy.ts', + setupNodeEvents(on, config) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('@cypress/grep/src/plugin')(config); + return config; + }, }, }); diff --git a/x-pack/plugins/threat_intelligence/cypress/e2e/block_list.cy.ts b/x-pack/plugins/threat_intelligence/cypress/e2e/block_list.cy.ts index ffe13397e0ebb..63f58acf99484 100644 --- a/x-pack/plugins/threat_intelligence/cypress/e2e/block_list.cy.ts +++ b/x-pack/plugins/threat_intelligence/cypress/e2e/block_list.cy.ts @@ -35,7 +35,7 @@ const FIRST_BLOCK_LIST_NEW_DESCRIPTION = 'the first description'; const SECOND_BLOCK_LIST_NEW_NAME = 'second blocklist entry'; const SECOND_BLOCK_LIST_NEW_DESCRIPTION = 'the second description'; -describe('Block list with invalid indicators', () => { +describe('Block list with invalid indicators', { tags: '@ess' }, () => { beforeEach(() => { esArchiverLoad('threat_intelligence/invalid_indicators_data'); login(); @@ -56,7 +56,7 @@ describe('Block list with invalid indicators', () => { }); }); -describe('Block list interactions', () => { +describe('Block list interactions', { tags: '@ess' }, () => { beforeEach(() => { esArchiverLoad('threat_intelligence/indicators_data'); login(); diff --git a/x-pack/plugins/threat_intelligence/cypress/e2e/cases.cy.ts b/x-pack/plugins/threat_intelligence/cypress/e2e/cases.cy.ts index 39dffd2da0a32..18caadc01319d 100644 --- a/x-pack/plugins/threat_intelligence/cypress/e2e/cases.cy.ts +++ b/x-pack/plugins/threat_intelligence/cypress/e2e/cases.cy.ts @@ -32,7 +32,7 @@ import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; const THREAT_INTELLIGENCE = '/app/security/threat_intelligence/indicators'; -describe('Cases with invalid indicators', () => { +describe('Cases with invalid indicators', { tags: '@ess' }, () => { beforeEach(() => { esArchiverLoad('threat_intelligence/invalid_indicators_data'); login(); @@ -58,7 +58,7 @@ describe('Cases with invalid indicators', () => { }); }); -describe('Cases interactions', () => { +describe('Cases interactions', { tags: '@ess' }, () => { beforeEach(() => { esArchiverLoad('threat_intelligence/indicators_data'); login(); diff --git a/x-pack/plugins/threat_intelligence/cypress/e2e/empty_page.cy.ts b/x-pack/plugins/threat_intelligence/cypress/e2e/empty_page.cy.ts index ddb688ff6b7c1..b7c6083480c38 100644 --- a/x-pack/plugins/threat_intelligence/cypress/e2e/empty_page.cy.ts +++ b/x-pack/plugins/threat_intelligence/cypress/e2e/empty_page.cy.ts @@ -14,7 +14,7 @@ import { const THREAT_INTEL_PATH = '/app/security/threat_intelligence/'; -describe('Empty Page', () => { +describe('Empty Page', { tags: '@ess' }, () => { beforeEach(() => { login(); visit(THREAT_INTEL_PATH); diff --git a/x-pack/plugins/threat_intelligence/cypress/e2e/indicators.cy.ts b/x-pack/plugins/threat_intelligence/cypress/e2e/indicators.cy.ts index 3b7e360813c0e..cbb95e9a610f1 100644 --- a/x-pack/plugins/threat_intelligence/cypress/e2e/indicators.cy.ts +++ b/x-pack/plugins/threat_intelligence/cypress/e2e/indicators.cy.ts @@ -56,7 +56,7 @@ const THREAT_INTELLIGENCE = '/app/security/threat_intelligence/indicators'; const URL_WITH_CONTRADICTORY_FILTERS = '/app/security/threat_intelligence/indicators?indicators=(filterQuery:(language:kuery,query:%27%27),filters:!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,index:%27%27,key:threat.indicator.type,negate:!f,params:(query:file),type:phrase),query:(match_phrase:(threat.indicator.type:file))),(%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,index:%27%27,key:threat.indicator.type,negate:!f,params:(query:url),type:phrase),query:(match_phrase:(threat.indicator.type:url)))),timeRange:(from:now/d,to:now/d))'; -describe('Invalid Indicators', () => { +describe('Invalid Indicators', { tags: '@ess' }, () => { describe('verify the grid loads even with missing fields', () => { beforeEach(() => { esArchiverLoad('threat_intelligence/invalid_indicators_data'); diff --git a/x-pack/plugins/threat_intelligence/cypress/e2e/query_bar.cy.ts b/x-pack/plugins/threat_intelligence/cypress/e2e/query_bar.cy.ts index 379147b900a47..9ee37105d1bc0 100644 --- a/x-pack/plugins/threat_intelligence/cypress/e2e/query_bar.cy.ts +++ b/x-pack/plugins/threat_intelligence/cypress/e2e/query_bar.cy.ts @@ -31,7 +31,7 @@ import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; const THREAT_INTELLIGENCE = '/app/security/threat_intelligence/indicators'; -describe('Indicators query bar interaction', () => { +describe('Indicators query bar interaction', { tags: '@ess' }, () => { beforeEach(() => { esArchiverLoad('threat_intelligence/indicators_data'); login(); diff --git a/x-pack/plugins/threat_intelligence/cypress/e2e/timeline.cy.ts b/x-pack/plugins/threat_intelligence/cypress/e2e/timeline.cy.ts index 7c2fcdc2b98b2..51f87abcba5bd 100644 --- a/x-pack/plugins/threat_intelligence/cypress/e2e/timeline.cy.ts +++ b/x-pack/plugins/threat_intelligence/cypress/e2e/timeline.cy.ts @@ -26,7 +26,7 @@ import { login, visit } from '../tasks/login'; const THREAT_INTELLIGENCE = '/app/security/threat_intelligence/indicators'; -describe('Timeline', () => { +describe('Timeline', { tags: '@ess' }, () => { beforeEach(() => { esArchiverLoad('threat_intelligence/indicators_data'); login(); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 804e6f38ef412..29fd5965bf442 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -23930,17 +23930,11 @@ "xpack.ml.entityFilter.addFilterTooltip": "Ajouter un filtre", "xpack.ml.entityFilter.removeFilterTooltip": "Supprimer le filtre", "xpack.ml.logRateAnalysis.pageHeader": "Expliquer les pics de taux de log", - "xpack.ml.explorer.addToDashboard.anomalyCharts.dashboardsTitle": "Ajouter des graphiques d'anomalies aux tableaux de bord", "xpack.ml.explorer.addToDashboard.anomalyCharts.maxSeriesToPlotLabel": "Nombre maximal de séries à tracer", - "xpack.ml.explorer.addToDashboard.cancelButtonLabel": "Annuler", - "xpack.ml.explorer.addToDashboard.selectDashboardsLabel": "Sélectionner les tableaux de bord :", - "xpack.ml.explorer.addToDashboard.swimlanes.dashboardsTitle": "Ajouter un couloir à un tableau de bord", - "xpack.ml.explorer.addToDashboard.swimlanes.selectSwimlanesLabel": "Sélectionner la vue de couloir :", "xpack.ml.explorer.addToDashboardLabel": "Ajouter au tableau de bord", "xpack.ml.explorer.annotationsErrorCallOutTitle": "Une erreur s'est produite lors du chargement des annotations :", "xpack.ml.explorer.annotationsErrorTitle": "Annotations", "xpack.ml.explorer.anomalies.actionsAriaLabel": "Actions", - "xpack.ml.explorer.anomalies.actionsPopoverLabel": "Graphiques d'anomalies", "xpack.ml.explorer.anomalies.addToDashboardLabel": "Ajouter au tableau de bord", "xpack.ml.explorer.anomaliesTitle": "Anomalies", "xpack.ml.explorer.anomalyTimelinePopoverAdvancedExplanation": "Les scores d'anomalies affichés dans chaque section d'Anomaly Explorer (Explorateur d'anomalies) peuvent varier légèrement. Cette disparité s'explique par le fait que, pour chaque tâche, sont consignés les résultats de groupe, les résultats de groupe généraux, les résultats d'influenceur et les résultats d'enregistrements. Les scores d'anomalies sont générés pour chaque type de résultat. Le couloir général affiche le score maximal des groupes globaux pour chaque bloc. Lorsque vous affichez un couloir par tâche, il montre le score maximal du groupe dans chaque bloc. Lorsque vous choisissez l'affichage par influenceur, il montre le score maximal d'influenceur dans chaque bloc.", @@ -23962,10 +23956,6 @@ "xpack.ml.explorer.charts.viewInMapsLabel": "Afficher", "xpack.ml.explorer.charts.viewLabel": "Afficher", "xpack.ml.explorer.clearSelectionLabel": "Effacer la sélection", - "xpack.ml.explorer.dashboardsTable.actionsHeader": "Actions", - "xpack.ml.explorer.dashboardsTable.descriptionColumnHeader": "Description", - "xpack.ml.explorer.dashboardsTable.editActionName": "Ajouter au tableau de bord", - "xpack.ml.explorer.dashboardsTable.titleColumnHeader": "Titre", "xpack.ml.explorer.distributionChart.anomalyScoreLabel": "score d'anomalies", "xpack.ml.explorer.distributionChart.entityLabel": "entité", "xpack.ml.explorer.distributionChart.typicalLabel": "typique", @@ -27693,7 +27683,6 @@ "xpack.observability.threshold.rule.createInventoryRuleButton": "Créer une règle d'inventaire", "xpack.observability.threshold.rule.createThresholdRuleButton": "Créer une règle de seuil", "xpack.observability.threshold.rule.groupByKeysActionVariableDescription": "Objet contenant les groupes qui fournissent les données", - "xpack.observability.threshold.rule.homePage.toolbar.kqlSearchFieldPlaceholder": "Rechercher des données d'infrastructure… (par exemple host.name:host-1)", "xpack.observability.threshold.rule.hostActionVariableDescription": "Objet hôte défini par ECS s'il est disponible dans la source.", "xpack.observability.threshold.rule.infrastructureDropdownMenu": "Infrastructure", "xpack.observability.threshold.rule.infrastructureDropdownTitle": "Règles d'infrastructure", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 93352cb7778af..7c8ed5b0a5813 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23930,17 +23930,11 @@ "xpack.ml.entityFilter.addFilterTooltip": "フィルターを追加します", "xpack.ml.entityFilter.removeFilterTooltip": "フィルターを削除", "xpack.ml.logRateAnalysis.pageHeader": "ログレートスパイクを説明", - "xpack.ml.explorer.addToDashboard.anomalyCharts.dashboardsTitle": "異常グラフをダッシュボードに追加", "xpack.ml.explorer.addToDashboard.anomalyCharts.maxSeriesToPlotLabel": "プロットする最大系列数", - "xpack.ml.explorer.addToDashboard.cancelButtonLabel": "キャンセル", - "xpack.ml.explorer.addToDashboard.selectDashboardsLabel": "ダッシュボードを選択:", - "xpack.ml.explorer.addToDashboard.swimlanes.dashboardsTitle": "スイムレーンをダッシュボードに追加", - "xpack.ml.explorer.addToDashboard.swimlanes.selectSwimlanesLabel": "スイムレーンビューを選択:", "xpack.ml.explorer.addToDashboardLabel": "ダッシュボードに追加", "xpack.ml.explorer.annotationsErrorCallOutTitle": "注釈の読み込み中にエラーが発生しました。", "xpack.ml.explorer.annotationsErrorTitle": "注釈", "xpack.ml.explorer.anomalies.actionsAriaLabel": "アクション", - "xpack.ml.explorer.anomalies.actionsPopoverLabel": "異常グラフ", "xpack.ml.explorer.anomalies.addToDashboardLabel": "ダッシュボードに追加", "xpack.ml.explorer.anomaliesTitle": "異常", "xpack.ml.explorer.anomalyTimelinePopoverAdvancedExplanation": "異常エクスプローラーの各セクションに表示される異常スコアは少し異なる場合があります。各ジョブではバケット結果、全体的なバケット結果、影響因子結果、レコード結果があるため、このような不一致が発生します。各タイプの結果の異常スコアが生成されます。全体的なスイムレーンは、各ブロックの最大全体バケットスコアの最大値を示します。ジョブでスイムレーンを表示するときには、各ブロックに最大バケットスコアが表示されます。影響因子別に表示するときには、各ブロックに最大影響因子スコアが表示されます。", @@ -23962,10 +23956,6 @@ "xpack.ml.explorer.charts.viewInMapsLabel": "表示", "xpack.ml.explorer.charts.viewLabel": "表示", "xpack.ml.explorer.clearSelectionLabel": "選択した項目をクリア", - "xpack.ml.explorer.dashboardsTable.actionsHeader": "アクション", - "xpack.ml.explorer.dashboardsTable.descriptionColumnHeader": "説明", - "xpack.ml.explorer.dashboardsTable.editActionName": "ダッシュボードに追加", - "xpack.ml.explorer.dashboardsTable.titleColumnHeader": "タイトル", "xpack.ml.explorer.distributionChart.anomalyScoreLabel": "異常スコア", "xpack.ml.explorer.distributionChart.entityLabel": "エンティティ", "xpack.ml.explorer.distributionChart.typicalLabel": "通常", @@ -27693,7 +27683,6 @@ "xpack.observability.threshold.rule.createInventoryRuleButton": "インベントリルールの作成", "xpack.observability.threshold.rule.createThresholdRuleButton": "しきい値ルールを作成", "xpack.observability.threshold.rule.groupByKeysActionVariableDescription": "データを報告しているグループを含むオブジェクト", - "xpack.observability.threshold.rule.homePage.toolbar.kqlSearchFieldPlaceholder": "インフラストラクチャーデータを検索…(例:host.name:host-1)", "xpack.observability.threshold.rule.hostActionVariableDescription": "ソースで使用可能な場合に、ECSで定義されたホストオブジェクト。", "xpack.observability.threshold.rule.infrastructureDropdownMenu": "インフラストラクチャー", "xpack.observability.threshold.rule.infrastructureDropdownTitle": "インフラストラクチャールール", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 78e6f5b54c600..b4899f5bd0b4c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23929,17 +23929,11 @@ "xpack.ml.entityFilter.addFilterTooltip": "添加筛选", "xpack.ml.entityFilter.removeFilterTooltip": "移除筛选", "xpack.ml.logRateAnalysis.pageHeader": "解释日志速率峰值", - "xpack.ml.explorer.addToDashboard.anomalyCharts.dashboardsTitle": "将异常图表添加到仪表板", "xpack.ml.explorer.addToDashboard.anomalyCharts.maxSeriesToPlotLabel": "要绘制的最大序列数目", - "xpack.ml.explorer.addToDashboard.cancelButtonLabel": "取消", - "xpack.ml.explorer.addToDashboard.selectDashboardsLabel": "选择仪表板:", - "xpack.ml.explorer.addToDashboard.swimlanes.dashboardsTitle": "将泳道添加到仪表板", - "xpack.ml.explorer.addToDashboard.swimlanes.selectSwimlanesLabel": "选择泳道视图:", "xpack.ml.explorer.addToDashboardLabel": "添加到仪表板", "xpack.ml.explorer.annotationsErrorCallOutTitle": "加载注释时发生错误:", "xpack.ml.explorer.annotationsErrorTitle": "标注", "xpack.ml.explorer.anomalies.actionsAriaLabel": "操作", - "xpack.ml.explorer.anomalies.actionsPopoverLabel": "异常图表", "xpack.ml.explorer.anomalies.addToDashboardLabel": "添加到仪表板", "xpack.ml.explorer.anomaliesTitle": "异常", "xpack.ml.explorer.anomalyTimelinePopoverAdvancedExplanation": "在 Anomaly Explorer 的每个部分中看到的异常分数可能略微不同。这种差异之所以发生,是因为每个作业都有存储桶结果、总体存储桶结果、影响因素结果和记录结果。每个结果类型都会生成异常分数。总体泳道显示每个块的最大总体存储桶分数。按作业查看泳道时,其在每个块中显示最大存储桶分数。按影响因素查看泳道时,其在每个块中显示最大影响因素分数。", @@ -23961,10 +23955,6 @@ "xpack.ml.explorer.charts.viewInMapsLabel": "查看", "xpack.ml.explorer.charts.viewLabel": "查看", "xpack.ml.explorer.clearSelectionLabel": "清除所选内容", - "xpack.ml.explorer.dashboardsTable.actionsHeader": "操作", - "xpack.ml.explorer.dashboardsTable.descriptionColumnHeader": "描述", - "xpack.ml.explorer.dashboardsTable.editActionName": "添加到仪表板", - "xpack.ml.explorer.dashboardsTable.titleColumnHeader": "标题", "xpack.ml.explorer.distributionChart.anomalyScoreLabel": "异常分数", "xpack.ml.explorer.distributionChart.entityLabel": "实体", "xpack.ml.explorer.distributionChart.typicalLabel": "典型", @@ -27691,7 +27681,6 @@ "xpack.observability.threshold.rule.createInventoryRuleButton": "创建库存规则", "xpack.observability.threshold.rule.createThresholdRuleButton": "创建阈值规则", "xpack.observability.threshold.rule.groupByKeysActionVariableDescription": "包含正报告数据的组的对象", - "xpack.observability.threshold.rule.homePage.toolbar.kqlSearchFieldPlaceholder": "搜索基础设施数据……(例如 host.name:host-1)", "xpack.observability.threshold.rule.hostActionVariableDescription": "ECS 定义的主机对象(如果在源中可用)。", "xpack.observability.threshold.rule.infrastructureDropdownMenu": "基础设施", "xpack.observability.threshold.rule.infrastructureDropdownTitle": "基础设施规则", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/translations.ts index dd7a2b8cec3a2..f678bdbad9462 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/translations.ts @@ -154,6 +154,13 @@ export const ALERT_WARNING_MAX_EXECUTABLE_ACTIONS_REASON = i18n.translate( } ); +export const ALERT_WARNING_MAX_QUEUED_ACTIONS_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.ruleWarningReasonMaxQueuedActions', + { + defaultMessage: 'Queued action limit exceeded.', + } +); + export const ALERT_WARNING_MAX_ALERTS_REASON = i18n.translate( 'xpack.triggersActionsUI.sections.rulesList.ruleWarningReasonMaxAlerts', { @@ -182,6 +189,7 @@ export const rulesErrorReasonTranslationsMapping = { export const rulesWarningReasonTranslationsMapping = { maxExecutableActions: ALERT_WARNING_MAX_EXECUTABLE_ACTIONS_REASON, maxAlerts: ALERT_WARNING_MAX_ALERTS_REASON, + maxQueuedActions: ALERT_WARNING_MAX_QUEUED_ACTIONS_REASON, unknown: ALERT_WARNING_UNKNOWN_REASON, }; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 7509a8842af52..823ef92ea32ca 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -343,6 +343,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) '--notifications.connectors.default.email=notification-email', '--xpack.task_manager.allow_reading_invalid_state=false', '--xpack.task_manager.requeue_invalid_tasks.enabled=true', + '--xpack.actions.queued.max=500', ], }, }; diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts index 76ebd2cf20af9..c7104b3b489a7 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts @@ -344,6 +344,7 @@ export function defineRoutes( ) : null, params: req.body.params, + actionTypeId: req.params.id, }, ]); return res.noContent(); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts index 7a77ebcb1a432..1c735f75f5001 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts @@ -31,6 +31,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./type_not_enabled')); loadTestFile(require.resolve('./schedule_unsecured_action')); loadTestFile(require.resolve('./check_registered_connector_types')); + loadTestFile(require.resolve('./max_queued_actions_circuit_breaker')); // note that this test will destroy existing spaces loadTestFile(require.resolve('./migrations')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/max_queued_actions_circuit_breaker.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/max_queued_actions_circuit_breaker.ts new file mode 100644 index 0000000000000..beeb7a35a13a0 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/max_queued_actions_circuit_breaker.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; +import { getEventLog, getTestRuleData, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('max queued actions circuit breaker', () => { + const objectRemover = new ObjectRemover(supertest); + const retry = getService('retry'); + + after(() => objectRemover.removeAll()); + + it('completes execution and reports back whether it reached the limit', async () => { + const response = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }); + + expect(response.status).to.eql(200); + const actionId = response.body.id; + objectRemover.add('default', actionId, 'action', 'actions'); + + const actions = []; + for (let i = 0; i < 510; i++) { + actions.push({ + id: actionId, + group: 'default', + params: { + index: ES_TEST_INDEX_NAME, + reference: 'test', + message: '', + }, + frequency: { + summary: false, + throttle: null, + notify_when: 'onActiveAlert', + }, + }); + } + + const resp = await supertest + .post('/api/alerting/rule') + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.always-firing-alert-as-data', + schedule: { interval: '1h' }, + throttle: undefined, + notify_when: undefined, + params: { + index: ES_TEST_INDEX_NAME, + reference: 'test', + }, + actions, + }) + ); + + expect(resp.status).to.eql(200); + const ruleId = resp.body.id; + objectRemover.add('default', ruleId, 'rule', 'alerting'); + + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'alert', + id: ruleId, + provider: 'alerting', + actions: new Map([['execute', { gte: 1 }]]), + }); + }); + + // check that there's a warning in the execute event + const executeEvent = events[0]; + expect(executeEvent?.event?.outcome).to.eql('success'); + expect(executeEvent?.event?.reason).to.eql('maxQueuedActions'); + expect(executeEvent?.kibana?.alerting?.status).to.eql('warning'); + expect(executeEvent?.message).to.eql( + 'The maximum number of queued actions was reached; excess actions were not triggered.' + ); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/management/index_management/create_index.ts b/x-pack/test/api_integration/apis/management/index_management/create_index.ts new file mode 100644 index 0000000000000..b5e0527e2a196 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/index_management/create_index.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { INTERNAL_API_BASE_PATH } from '@kbn/index-management-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + + describe('create index', async () => { + const testIndices = ['my-test-index-001', 'my-test-index-002']; + before(async () => { + await esDeleteAllIndices(testIndices); + }); + after(async () => { + await esDeleteAllIndices(testIndices); + }); + + it('can create an index', async () => { + const indexName = testIndices[0]; + await supertest + .put(`${INTERNAL_API_BASE_PATH}/indices/create`) + .set('kbn-xsrf', 'xxx') + .send({ + indexName, + }) + .expect(200); + + // Make sure the index is created + const { + body: [cat1], + } = await es.cat.indices({ index: indexName, format: 'json' }, { meta: true }); + expect(cat1.status).to.be('open'); + }); + + it(`throws 400 when index already created`, async () => { + const indexName = testIndices[1]; + await supertest + .put(`${INTERNAL_API_BASE_PATH}/indices/create`) + .set('kbn-xsrf', 'xxx') + .send({ + indexName, + }) + .expect(200); + + // Make sure the index is created + const { + body: [cat1], + } = await es.cat.indices({ index: indexName, format: 'json' }, { meta: true }); + expect(cat1.status).to.be('open'); + + await supertest + .put(`${INTERNAL_API_BASE_PATH}/indices/create`) + .set('kbn-xsrf', 'xxx') + .send({ + indexName, + }) + .expect(400); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/ml/modules/get_module.ts b/x-pack/test/api_integration/apis/ml/modules/get_module.ts index 7c72f18f918e3..488714f13c399 100644 --- a/x-pack/test/api_integration/apis/ml/modules/get_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/get_module.ts @@ -49,7 +49,8 @@ export default ({ getService }: FtrProviderContext) => { return body; } - describe('get_module', function () { + // FLAKY: https://github.com/elastic/kibana/issues/164420 + describe.skip('get_module', function () { before(async () => { await ml.testResources.setKibanaTimeZoneToUTC(); }); diff --git a/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts b/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts index 179ac28a0c939..765138034f772 100644 --- a/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts +++ b/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts @@ -24,7 +24,7 @@ import { comparePolicies, getTestSyntheticsPolicy } from './sample_data/test_pol export default function ({ getService }: FtrProviderContext) { // FLAKY: https://github.com/elastic/kibana/issues/162594 // Failing: See https://github.com/elastic/kibana/issues/162594 - describe.skip('SyncGlobalParams', function () { + describe('SyncGlobalParams', function () { this.tags('skipCloud'); const supertestAPI = getService('supertest'); const kServer = getService('kibanaServer'); @@ -43,6 +43,7 @@ export default function ({ getService }: FtrProviderContext) { const params: Record = {}; before(async () => { + await kServer.savedObjects.cleanStandardList(); await testPrivateLocations.installSyntheticsPackage(); _browserMonitorJson = getFixtureJson('browser_monitor'); diff --git a/x-pack/test/cases_api_integration/common/lib/api/index.ts b/x-pack/test/cases_api_integration/common/lib/api/index.ts index f32a578103584..6a539457849e6 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/index.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/index.ts @@ -433,6 +433,7 @@ export const updateCase = async ({ const { body: cases } = await apiCall .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'foo') .set(headers) .send(params) .expect(expectedHttpCode); diff --git a/x-pack/test/functional/apps/infra/hosts_view.ts b/x-pack/test/functional/apps/infra/hosts_view.ts index a225ea4c476e4..e8f7730522f42 100644 --- a/x-pack/test/functional/apps/infra/hosts_view.ts +++ b/x-pack/test/functional/apps/infra/hosts_view.ts @@ -305,9 +305,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); - it('should render 8 charts in the Metrics section', async () => { + it('should render 9 charts in the Metrics section', async () => { const hosts = await pageObjects.assetDetails.getAssetDetailsMetricsCharts(); - expect(hosts.length).to.equal(8); + expect(hosts.length).to.equal(9); }); it('should show alerts', async () => { diff --git a/x-pack/test/functional/services/cases/test_resources.ts b/x-pack/test/functional/services/cases/test_resources.ts index 135bf13a3a2d6..f3ef6ed29832e 100644 --- a/x-pack/test/functional/services/cases/test_resources.ts +++ b/x-pack/test/functional/services/cases/test_resources.ts @@ -12,13 +12,18 @@ export function CasesTestResourcesServiceProvider({ getService }: FtrProviderCon return { async installKibanaSampleData(sampleDataId: 'ecommerce' | 'flights' | 'logs') { - await supertest.post(`/api/sample_data/${sampleDataId}`).set('kbn-xsrf', 'true').expect(200); + await supertest + .post(`/api/sample_data/${sampleDataId}`) + .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'foo') + .expect(200); }, async removeKibanaSampleData(sampleDataId: 'ecommerce' | 'flights' | 'logs') { await supertest .delete(`/api/sample_data/${sampleDataId}`) .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'foo') .expect(204); }, }; diff --git a/x-pack/test/functional/services/ml/anomaly_explorer.ts b/x-pack/test/functional/services/ml/anomaly_explorer.ts index b3353556b1cc6..cb183eee0c543 100644 --- a/x-pack/test/functional/services/ml/anomaly_explorer.ts +++ b/x-pack/test/functional/services/ml/anomaly_explorer.ts @@ -112,10 +112,15 @@ export function MachineLearningAnomalyExplorerProvider( await testSubjects.click(`mlAnomaliesTableEntityCellRemoveFilterButton-${influencerValue}`); }, - async openAddToDashboardControl() { + async openAddToDashboardControl(swimLaneType: SwimlaneType = 'overall') { await testSubjects.click('mlAnomalyTimelinePanelMenu'); await testSubjects.click('mlAnomalyTimelinePanelAddToDashboardButton'); - await testSubjects.existOrFail('mlAddToDashboardModal'); + if (swimLaneType === 'overall') { + await testSubjects.click('mlAnomalyTimelinePanelAddOverallToDashboardButton'); + } else { + await testSubjects.click('mlAnomalyTimelinePanelAddViewByToDashboardButton'); + } + await testSubjects.existOrFail('savedObjectSaveModal'); }, async attachSwimLaneToCase(swimLaneType: SwimlaneType = 'overall', params: CreateCaseParams) { @@ -132,14 +137,24 @@ export function MachineLearningAnomalyExplorerProvider( async addAndEditSwimlaneInDashboard(dashboardTitle: string) { await retry.tryForTime(30 * 1000, async () => { - await this.filterDashboardSearchWithSearchString(dashboardTitle); - await testSubjects.clickWhenNotDisabledWithoutRetry('~mlEmbeddableAddAndEditDashboard'); + const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); + const label = await dashboardSelector.findByCssSelector( + `label[for="new-dashboard-option"]` + ); + await label.click(); + await testSubjects.click('confirmSaveSavedObjectButton'); + await retry.waitForWithTimeout('Save modal to disappear', 1000, () => + testSubjects + .missingOrFail('confirmSaveSavedObjectButton') + .then(() => true) + .catch(() => false) + ); // make sure the dashboard page actually loaded const dashboardItemCount = await dashboardPage.getSharedItemsCount(); expect(dashboardItemCount).to.not.eql(undefined); }); - // changing to the dashboard app might take sime time + // changing to the dashboard app might take some time const embeddable = await testSubjects.find('mlAnomalySwimlaneEmbeddableWrapper', 30 * 1000); const swimlane = await embeddable.findByClassName('mlSwimLaneContainer'); expect(await swimlane.isDisplayed()).to.eql( @@ -156,23 +171,6 @@ export function MachineLearningAnomalyExplorerProvider( await testSubjects.existOrFail('mlDashboardSelectionTable loaded', { timeout: 60 * 1000 }); }, - async filterDashboardSearchWithSearchString(filter: string, expectedRowCount: number = 1) { - await retry.tryForTime(20 * 1000, async () => { - await this.waitForDashboardsToLoad(); - const searchBarInput = await testSubjects.find('mlDashboardsSearchBox'); - await searchBarInput.clearValueWithKeyboard(); - await searchBarInput.type(filter); - await this.assertDashboardSearchInputValue(filter); - await this.waitForDashboardsToLoad(); - - const dashboardRows = await testSubjects.findAll('~mlDashboardSelectionTableRow', 2000); - expect(dashboardRows.length).to.eql( - expectedRowCount, - `Dashboard table should have ${expectedRowCount} rows, got ${dashboardRows.length}` - ); - }); - }, - async assertDashboardSearchInputValue(expectedSearchValue: string) { const searchBarInput = await testSubjects.find('mlDashboardsSearchBox'); const actualSearchValue = await searchBarInput.getAttribute('value'); @@ -232,6 +230,7 @@ export function MachineLearningAnomalyExplorerProvider( async attachAnomalyChartsToCase(params: CreateCaseParams) { await testSubjects.click('mlExplorerAnomalyPanelMenu'); await testSubjects.click('mlAnomalyAttachChartsToCasesButton'); + await testSubjects.click('mlAnomalyChartsSubmitAttachment'); await cases.create.createCaseFromModal(params); }, diff --git a/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/es_query_rule.ts b/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/es_query_rule.ts index f6f41a61f1bb2..3a4cae246d46f 100644 --- a/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/es_query_rule.ts +++ b/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/es_query_rule.ts @@ -6,6 +6,7 @@ */ import { FtrProviderContext } from '../../../ftr_provider_context'; +import { esQueryRuleName } from '.'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); @@ -106,7 +107,63 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 1400, 1500 ); + // Create an email connector action + await testSubjects.click('.email-alerting-ActionTypeSelectOption'); + await testSubjects.scrollIntoView('addAlertActionButton'); + await commonScreenshots.takeScreenshot( + 'es-query-rule-action-query-matched', + screenshotDirectories, + 1400, + 1024 + ); + await testSubjects.click('messageAddVariableButton'); + await commonScreenshots.takeScreenshot( + 'es-query-rule-action-variables', + screenshotDirectories, + 1400, + 1024 + ); + await browser.pressKeys(browser.keys.ESCAPE); await testSubjects.click('cancelSaveRuleButton'); }); + + it('example elasticsearch query rule conditions and actions', async () => { + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.header.waitUntilLoadingHasFinished(); + // Edit the rule that was created as part of startup + await testSubjects.setValue('ruleSearchField', esQueryRuleName); + await browser.pressKeys(browser.keys.ENTER); + const actionPanel = await testSubjects.find('collapsedItemActions'); + await actionPanel.click(); + const editRuleMenu = await testSubjects.find('editRule'); + await editRuleMenu.click(); + await pageObjects.header.waitUntilLoadingHasFinished(); + await commonScreenshots.takeScreenshot( + 'es-query-rule-conditions', + screenshotDirectories, + 1400, + 1700 + ); + /* Reposition so that the details are visible for the first action */ + await testSubjects.scrollIntoView('alertActionAccordion-0'); + await commonScreenshots.takeScreenshot( + 'es-query-rule-action-summary', + screenshotDirectories, + 1400, + 1024 + ); + /* Reposition so that the details are visible for the second action */ + await testSubjects.scrollIntoView('alertActionAccordion-1'); + await commonScreenshots.takeScreenshot( + 'es-query-rule-recovery-action', + screenshotDirectories, + 1400, + 1024 + ); + const cancelEditButton = await testSubjects.find('cancelSaveEditedRuleButton'); + await cancelEditButton.click(); + const confirmCancelButton = await testSubjects.find('confirmModalConfirmButton'); + await confirmCancelButton.click(); + }); }); } diff --git a/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/index.ts b/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/index.ts index e4a2877173834..43596aa81d675 100644 --- a/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/index.ts +++ b/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/index.ts @@ -8,18 +8,34 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export const indexThresholdRuleName = 'kibana sites - low bytes'; -export const metricThresholdRuleName = 'network metric packets'; +export const esQueryRuleName = 'sample logs query rule'; export default function ({ loadTestFile, getService }: FtrProviderContext) { const browser = getService('browser'); const actions = getService('actions'); const rules = getService('rules'); + const emailConnectorName = 'Email connector 1'; + const validQueryJson = JSON.stringify({ + query: { + bool: { + filter: [ + { + term: { + 'host.keyword': 'www.elastic.co', + }, + }, + ], + }, + }, + }); describe('stack alerting', function () { let itRuleId: string; - let mtRuleId: string; + let esRuleId: string; let serverLogConnectorId: string; + let emailConnectorId: string; before(async () => { + // Create server log connector await browser.setWindowSize(1920, 1080); ({ id: serverLogConnectorId } = await actions.api.createConnector({ name: 'my-server-log-connector', @@ -27,6 +43,22 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { secrets: {}, connectorTypeId: '.server-log', })); + // Create email connector + ({ id: emailConnectorId } = await actions.api.createConnector({ + name: emailConnectorName, + config: { + service: 'other', + from: 'bob@example.com', + host: 'some.non.existent.com', + port: 25, + }, + secrets: { + user: 'bob', + password: 'supersecret', + }, + connectorTypeId: '.email', + })); + // Create index threshold rule ({ id: itRuleId } = await rules.api.createRule({ consumer: 'alerts', name: indexThresholdRuleName, @@ -57,35 +89,48 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { }, ], })); - ({ id: mtRuleId } = await rules.api.createRule({ - consumer: 'infrastructure', - name: metricThresholdRuleName, - notifyWhen: 'onActionGroupChange', + // Create Elasticsearch query rule + ({ id: esRuleId } = await rules.api.createRule({ + consumer: 'alerts', + name: esQueryRuleName, params: { - criteria: [ - { - aggType: 'max', - comparator: '>', - threshold: [0], - timeSize: 3, - timeUnit: 's', - metric: 'network.packets', - }, - ], - sourceId: 'default', - alertOnNoData: false, - alertOnGroupDisappear: false, - groupBy: ['network.name'], + index: ['kibana_sample_data_logs'], + timeField: '@timestamp', + timeWindowSize: 1, + timeWindowUnit: 'd', + thresholdComparator: '>', + threshold: [100], + size: 100, + esQuery: validQueryJson, }, - ruleTypeId: 'metrics.alert.threshold', - schedule: { interval: '1m' }, + ruleTypeId: '.es-query', + schedule: { interval: '1d' }, actions: [ { - group: 'metrics.threshold.fired', + group: 'query matched', + id: emailConnectorId, + frequency: { + throttle: '2d', + summary: true, + notify_when: 'onThrottleInterval', + }, + params: { + to: ['test@example.com'], + subject: 'Alert summary', + message: + 'The system has detected {{alerts.new.count}} new, {{alerts.ongoing.count}} ongoing, and {{alerts.recovered.count}} recovered alerts.', + }, + }, + { + group: 'recovered', id: serverLogConnectorId, + frequency: { + summary: false, + notify_when: 'onActionGroupChange', + }, params: { level: 'info', - message: 'Test Metric Threshold rule', + message: '{{alert.id}} has recovered.', }, }, ], @@ -94,7 +139,7 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { after(async () => { await rules.api.deleteRule(itRuleId); - await rules.api.deleteRule(mtRuleId); + await rules.api.deleteRule(esRuleId); await rules.api.deleteAllRules(); await actions.api.deleteConnector(serverLogConnectorId); await actions.api.deleteAllConnectors(); @@ -103,7 +148,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { loadTestFile(require.resolve('./es_query_rule')); loadTestFile(require.resolve('./index_threshold_rule')); loadTestFile(require.resolve('./list_view')); - loadTestFile(require.resolve('./metrics_threshold_rule')); loadTestFile(require.resolve('./tracking_containment_rule')); }); } diff --git a/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/metrics_threshold_rule.ts b/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/metrics_threshold_rule.ts deleted file mode 100644 index e091ab4da27ed..0000000000000 --- a/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_alerting/metrics_threshold_rule.ts +++ /dev/null @@ -1,91 +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 { FtrProviderContext } from '../../../ftr_provider_context'; -import { metricThresholdRuleName } from '.'; - -export default function ({ getService, getPageObjects }: FtrProviderContext) { - const actions = getService('actions'); - const browser = getService('browser'); - const commonScreenshots = getService('commonScreenshots'); - const testSubjects = getService('testSubjects'); - const pageObjects = getPageObjects(['common', 'header']); - const screenshotDirectories = ['response_ops_docs', 'stack_alerting']; - const emailConnectorName = 'Email connector 1'; - - describe('metric threshold rule', function () { - let emailConnectorId: string; - before(async () => { - ({ id: emailConnectorId } = await actions.api.createConnector({ - name: emailConnectorName, - config: { - service: 'other', - from: 'bob@example.com', - host: 'some.non.existent.com', - port: 25, - }, - secrets: { - user: 'bob', - password: 'supersecret', - }, - connectorTypeId: '.email', - })); - }); - - after(async () => { - await actions.api.deleteConnector(emailConnectorId); - }); - - it('example metric threshold rule conditions and actions', async () => { - await pageObjects.common.navigateToApp('triggersActions'); - await pageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.setValue('ruleSearchField', metricThresholdRuleName); - await browser.pressKeys(browser.keys.ENTER); - const actionPanel = await testSubjects.find('collapsedItemActions'); - await actionPanel.click(); - const editRuleMenu = await testSubjects.find('editRule'); - await editRuleMenu.click(); - const expandExpression = await testSubjects.find('expandRow'); - await expandExpression.click(); - await pageObjects.header.waitUntilLoadingHasFinished(); - await commonScreenshots.takeScreenshot( - 'rule-flyout-rule-conditions', - screenshotDirectories, - 1400, - 1500 - ); - - const serverLogAction = await testSubjects.find('alertActionAccordion-0'); - const removeConnectorButton = await serverLogAction.findByCssSelector( - '[aria-label="Delete"]' - ); - await removeConnectorButton.click(); - - await testSubjects.click('.email-alerting-ActionTypeSelectOption'); - await testSubjects.scrollIntoView('addAlertActionButton'); - await commonScreenshots.takeScreenshot( - 'rule-flyout-action-details', - screenshotDirectories, - 1400, - 1024 - ); - await testSubjects.scrollIntoView('addAlertActionButton'); - await testSubjects.click('messageAddVariableButton'); - await commonScreenshots.takeScreenshot( - 'rule-flyout-action-variables', - screenshotDirectories, - 1400, - 1024 - ); - - const cancelEditButton = await testSubjects.find('cancelSaveEditedRuleButton'); - await cancelEditButton.click(); - const confirmCancelButton = await testSubjects.find('confirmModalConfirmButton'); - await confirmCancelButton.click(); - }); - }); -} diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/exceptions/shared_exception_lists_management/manage_exceptions.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/exceptions/shared_exception_lists_management/manage_exceptions.cy.ts index 20baf9de25e10..e0f932d81ac88 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/exceptions/shared_exception_lists_management/manage_exceptions.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/exceptions/shared_exception_lists_management/manage_exceptions.cy.ts @@ -64,7 +64,7 @@ describe( const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; const EXCEPTION_LIST_NAME = 'Newly created list'; - describe('Add, Edit and delete Exception item', { tags: ['@ess', '@serverless'] }, () => { + describe('Add, Edit and delete Exception item', () => { it('should create exception item from Shared Exception List page and linked to a Rule', () => { // Click on "Create shared exception list" button on the header // Click on "Create exception item" diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/pagination/pagination.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/pagination/pagination.cy.ts index 1e7f7d7514404..f7257d4666d69 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/pagination/pagination.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/pagination/pagination.cy.ts @@ -20,7 +20,8 @@ import { ALL_HOSTS_TABLE } from '../../../screens/hosts/all_hosts'; import { ALL_USERS_TABLE } from '../../../screens/users/all_users'; import { goToTablePage, sortFirstTableColumn } from '../../../tasks/table_pagination'; -describe('Pagination', { tags: ['@ess', '@serverless'] }, () => { +// FLAKY: https://github.com/elastic/kibana/issues/165968 +describe('Pagination', { tags: ['@ess', '@serverless', '@brokenInServerless'] }, () => { describe('Host uncommon processes table)', () => { before(() => { cy.task('esArchiverLoad', { archiveName: 'host_uncommon_processes' }); diff --git a/x-pack/test_serverless/api_integration/test_suites/common/data_views/es_errors/errors.js b/x-pack/test_serverless/api_integration/test_suites/common/data_views/es_errors/errors.js index ddbc88cce8045..f8556727d9c17 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/data_views/es_errors/errors.js +++ b/x-pack/test_serverless/api_integration/test_suites/common/data_views/es_errors/errors.js @@ -22,7 +22,8 @@ export default function ({ getService }) { const es = getService('es'); const esArchiver = getService('esArchiver'); - describe('index_patterns/* error handler', () => { + // FLAKY: https://github.com/elastic/kibana/issues/165944 + describe.skip('index_patterns/* error handler', () => { let indexNotFoundError; let docNotFoundError; before(async () => { diff --git a/x-pack/test_serverless/api_integration/test_suites/common/data_views/fields_for_wildcard_route/conflicts.ts b/x-pack/test_serverless/api_integration/test_suites/common/data_views/fields_for_wildcard_route/conflicts.ts index d1d08c1e88832..a358bb8a3d469 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/data_views/fields_for_wildcard_route/conflicts.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/data_views/fields_for_wildcard_route/conflicts.ts @@ -16,7 +16,8 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const svlCommonApi = getService('svlCommonApi'); - describe('conflicts', () => { + // FLAKY: https://github.com/elastic/kibana/issues/165972 + describe.skip('conflicts', () => { before(() => esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/conflicts') ); diff --git a/x-pack/test_serverless/api_integration/test_suites/common/data_views/fields_for_wildcard_route/params.ts b/x-pack/test_serverless/api_integration/test_suites/common/data_views/fields_for_wildcard_route/params.ts index 035ca1af4b5a8..66aa266810530 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/data_views/fields_for_wildcard_route/params.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/data_views/fields_for_wildcard_route/params.ts @@ -16,7 +16,8 @@ export default function ({ getService }: FtrProviderContext) { const randomness = getService('randomness'); const svlCommonApi = getService('svlCommonApi'); - describe('params', () => { + // FLAKY https://github.com/elastic/kibana/issues/165942 + describe.skip('params', () => { before(() => esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index') ); diff --git a/x-pack/test_serverless/api_integration/test_suites/common/index_management/indices.ts b/x-pack/test_serverless/api_integration/test_suites/common/index_management/indices.ts index cc165c517f6cf..28b88190b2f53 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/index_management/indices.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/index_management/indices.ts @@ -80,5 +80,50 @@ export default function ({ getService }: FtrProviderContext) { .expect(404); }); }); + + describe('create index', () => { + const createIndexName = 'a-test-index'; + after(async () => { + // Cleanup index created for testing purposes + try { + await es.indices.delete({ + index: createIndexName, + }); + } catch (err) { + log.debug('[Cleanup error] Error deleting "a-test-index" index'); + throw err; + } + }); + + it('can create a new index', async () => { + await supertest + .put(`${INTERNAL_API_BASE_PATH}/indices/create`) + .set('kbn-xsrf', 'xxx') + .send({ + indexName: createIndexName, + }) + .expect(200); + + const { body: index } = await supertest + .get(`${INTERNAL_API_BASE_PATH}/indices/${createIndexName}`) + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'xxx') + .expect(200); + + expect(index).toBeTruthy(); + + expect(Object.keys(index).sort()).toEqual(expectedKeys); + }); + + it('fails to re-create the same index', async () => { + await supertest + .put(`${INTERNAL_API_BASE_PATH}/indices/create`) + .set('kbn-xsrf', 'xxx') + .send({ + indexName: createIndexName, + }) + .expect(400); + }); + }); }); } diff --git a/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts b/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts index 184b7b5d788a0..2b8effe5fcd58 100644 --- a/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts +++ b/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts @@ -51,7 +51,9 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await PageObjects.header.waitUntilLoadingHasFinished(); } - describe('Fields existence info', () => { + // Failing: See https://github.com/elastic/kibana/issues/165938 + // Failing: See https://github.com/elastic/kibana/issues/165927 + describe.skip('Fields existence info', () => { before(async () => { await esArchiver.load( 'test/api_integration/fixtures/es_archiver/index_patterns/constant_keyword' diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts index e8be4ff1cf4d3..f1e7408ebcf68 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts @@ -19,7 +19,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const find = getService('find'); - describe('persistable attachment', () => { + describe('Cases persistable attachments', () => { describe('lens visualization', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts index bc007a7ad4b7b..a0d9cd0e96dd3 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts @@ -16,7 +16,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const toasts = getService('toasts'); - describe('Configure', function () { + describe('Configure Case', function () { before(async () => { await svlObltNavigation.navigateToLandingPage(); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts index 5633538baa085..d7934f57dbc36 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts @@ -15,7 +15,7 @@ import { navigateToCasesApp } from '../../../../shared/lib/cases'; const owner = OBSERVABILITY_OWNER; export default ({ getService, getPageObject }: FtrProviderContext) => { - describe('Create case', function () { + describe('Create Case', function () { const find = getService('find'); const cases = getService('cases'); const testSubjects = getService('testSubjects'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/empty.txt b/x-pack/test_serverless/functional/test_suites/observability/cases/empty.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts index cb4aa44b09c35..d4dfdca15e82d 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts @@ -17,7 +17,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const svlCommonNavigation = getPageObject('svlCommonNavigation'); const svlObltNavigation = getService('svlObltNavigation'); - describe('cases list', () => { + describe('Cases list', () => { before(async () => { await svlObltNavigation.navigateToLandingPage(); await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'observability-overview:cases' }); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts new file mode 100644 index 0000000000000..97271fe33048b --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts @@ -0,0 +1,445 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { v4 as uuidv4 } from 'uuid'; +import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; + +import { OBSERVABILITY_OWNER } from '@kbn/cases-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { + createOneCaseBeforeDeleteAllAfter, + createAndNavigateToCase, +} from '../../../../shared/lib/cases/helpers'; + +const owner = OBSERVABILITY_OWNER; + +export default ({ getPageObject, getService }: FtrProviderContext) => { + const header = getPageObject('header'); + const testSubjects = getService('testSubjects'); + const cases = getService('cases'); + const find = getService('find'); + + const retry = getService('retry'); + const comboBox = getService('comboBox'); + const svlCommonNavigation = getPageObject('svlCommonNavigation'); + + describe('Case View', () => { + describe('page', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('should show the case view page correctly', async () => { + await testSubjects.existOrFail('case-view-title'); + await testSubjects.existOrFail('header-page-supplements'); + + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-title-files'); + await testSubjects.existOrFail('description'); + + await testSubjects.existOrFail('case-view-activity'); + + await testSubjects.existOrFail('case-view-assignees'); + await testSubjects.existOrFail('sidebar-severity'); + await testSubjects.existOrFail('case-view-user-list-reporter'); + await testSubjects.existOrFail('case-view-user-list-participants'); + await testSubjects.existOrFail('case-view-tag-list'); + await testSubjects.existOrFail('cases-categories'); + await testSubjects.existOrFail('sidebar-connectors'); + }); + }); + + describe('properties', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('edits a case title from the case view page', async () => { + const newTitle = `test-${uuidv4()}`; + + await testSubjects.click('editable-title-header-value'); + await testSubjects.setValue('editable-title-input-field', newTitle); + await testSubjects.click('editable-title-submit-btn'); + + // wait for backend response + await retry.tryForTime(5000, async () => { + const title = await find.byCssSelector('[data-test-subj="editable-title-header-value"]'); + expect(await title.getVisibleText()).equal(newTitle); + }); + + // validate user action + await find.byCssSelector('[data-test-subj*="title-update-action"]'); + }); + + it('adds a comment to a case', async () => { + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + + await testSubjects.click('submit-comment'); + + // validate user action + const newComment = await find.byCssSelector( + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' + ); + expect(await newComment.getVisibleText()).equal('Test comment from automation'); + }); + + it('adds a category to a case', async () => { + const category = uuidv4(); + await testSubjects.click('category-edit-button'); + await comboBox.setCustom('comboBoxInput', category); + await testSubjects.click('edit-category-submit'); + + // validate category was added + await testSubjects.existOrFail('category-viewer-' + category); + + // validate user action + await find.byCssSelector('[data-test-subj*="category-update-action"]'); + }); + + it('deletes a category from a case', async () => { + await find.byCssSelector('[data-test-subj*="category-viewer-"]'); + + await testSubjects.click('category-remove-button'); + + await testSubjects.existOrFail('no-categories'); + // validate user action + await find.byCssSelector('[data-test-subj*="category-delete-action"]'); + }); + + it('adds a tag to a case', async () => { + const tag = uuidv4(); + await testSubjects.click('tag-list-edit-button'); + await comboBox.setCustom('comboBoxInput', tag); + await testSubjects.click('edit-tags-submit'); + + // validate tag was added + await testSubjects.existOrFail('tag-' + tag); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-add-action"]'); + }); + + it('deletes a tag from a case', async () => { + await testSubjects.click('tag-list-edit-button'); + // find the tag button and click the close button + const button = await find.byCssSelector('[data-test-subj="comboBoxInput"] button'); + await button.click(); + await testSubjects.click('edit-tags-submit'); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-delete-action"]'); + }); + + describe('status', () => { + it('changes a case status to closed via dropdown-menu', async () => { + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses.closed); + + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="case-status-badge-closed"]' + ); + // validates dropdown tag + await testSubjects.existOrFail( + 'case-view-status-dropdown > case-status-badge-popover-button-closed' + ); + }); + }); + + describe('Severity field', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('shows the severity field on the sidebar', async () => { + await testSubjects.existOrFail('case-severity-selection'); + }); + + it('changes the severity level from the selector', async () => { + await cases.common.selectSeverity(CaseSeverity.MEDIUM); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('case-severity-selection-' + CaseSeverity.MEDIUM); + + // validate user action + await find.byCssSelector('[data-test-subj*="severity-update-action"]'); + }); + }); + }); + + describe('actions', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('deletes the case successfully', async () => { + await cases.singleCase.deleteCase(); + await cases.casesTable.waitForTableToFinishLoading(); + await cases.casesTable.validateCasesTableHasNthRows(0); + }); + }); + + describe('filter activity', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('filters by all by default', async () => { + const allBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge' + ); + + expect(await allBadge.getAttribute('aria-label')).equal('1 active filters'); + }); + + it('filters by comment successfully', async () => { + const commentBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-comments"] span.euiNotificationBadge' + ); + + expect(await commentBadge.getAttribute('aria-label')).equal('0 available filters'); + + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + await testSubjects.click('submit-comment'); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('user-actions-filter-activity-button-comments'); + + expect(await commentBadge.getAttribute('aria-label')).equal('1 active filters'); + }); + + it('filters by history successfully', async () => { + const historyBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-history"] span.euiNotificationBadge' + ); + + expect(await historyBadge.getAttribute('aria-label')).equal('1 available filters'); + + await cases.common.selectSeverity(CaseSeverity.MEDIUM); + + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('user-actions-filter-activity-button-history'); + + expect(await historyBadge.getAttribute('aria-label')).equal('3 active filters'); + }); + + it('sorts by newest first successfully', async () => { + await testSubjects.click('user-actions-filter-activity-button-all'); + + const AllBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge' + ); + + expect(await AllBadge.getVisibleText()).equal('4'); + + const sortDesc = await find.byCssSelector( + '[data-test-subj="user-actions-sort-select"] [value="desc"]' + ); + + await sortDesc.click(); + + await header.waitUntilLoadingHasFinished(); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + const actionList = await userActionsLists[0].findAllByClassName('euiComment'); + + expect(await actionList[0].getAttribute('data-test-subj')).contain('status-update-action'); + }); + }); + + // FLAKY + describe.skip('Lens visualization', () => { + before(async () => { + await cases.testResources.installKibanaSampleData('logs'); + await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.testResources.removeKibanaSampleData('logs'); + await cases.api.deleteAllCases(); + }); + + it('adds lens visualization in description', async () => { + await testSubjects.click('description-edit-icon'); + + await header.waitUntilLoadingHasFinished(); + + const editCommentTextArea = await find.byCssSelector( + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' + ); + + await header.waitUntilLoadingHasFinished(); + + await editCommentTextArea.focus(); + + const editableDescription = await testSubjects.find('editable-markdown-form'); + + const addVisualizationButton = await editableDescription.findByCssSelector( + '[data-test-subj="euiMarkdownEditorToolbarButton"][aria-label="Visualization"]' + ); + await addVisualizationButton.click(); + + await cases.singleCase.findAndSaveVisualization('[Logs] Bytes distribution'); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('editable-save-markdown'); + + await header.waitUntilLoadingHasFinished(); + + const description = await find.byCssSelector('[data-test-subj="description"]'); + + await description.findByCssSelector('[data-test-subj="xyVisChart"]'); + }); + }); + + describe('pagination', async () => { + let createdCase: any; + + before(async () => { + createdCase = await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('initially renders user actions list correctly', async () => { + expect(testSubjects.missingOrFail('cases-show-more-user-actions')); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + expect(userActionsLists).length(1); + }); + + it('shows more actions on button click', async () => { + await cases.api.generateUserActions({ + caseId: createdCase.id, + caseVersion: createdCase.version, + totalUpdates: 4, + }); + + expect(testSubjects.missingOrFail('user-actions-loading')); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('case-refresh'); + + await header.waitUntilLoadingHasFinished(); + + expect(testSubjects.existOrFail('cases-show-more-user-actions')); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + expect(userActionsLists).length(2); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(10); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); + + testSubjects.click('cases-show-more-user-actions'); + + await header.waitUntilLoadingHasFinished(); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(20); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); + }); + }); + + describe('Tabs', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('shows the "activity" tab by default', async () => { + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + }); + + it("shows the 'files' tab when clicked", async () => { + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + }); + }); + + describe('Files', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('adds a file to the case', async () => { + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + + await cases.casesFilesTable.addFile(require.resolve('./empty.txt')); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + + it('search by file name', async () => { + await cases.casesFilesTable.searchByFileName('foobar'); + await cases.casesFilesTable.emptyOrFail(); + await cases.casesFilesTable.searchByFileName('empty'); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + + it('files added to a case can be deleted', async () => { + await cases.casesFilesTable.deleteFile(0); + await cases.casesFilesTable.emptyOrFail(); + }); + + describe('Files User Activity', () => { + it('file user action is displayed correctly', async () => { + await cases.casesFilesTable.addFile(require.resolve('./empty.txt')); + + await testSubjects.click('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + }); + }); + + describe('breadcrumbs', () => { + let createdCase: any; + + before(async () => { + createdCase = await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('should set the cases title', async () => { + svlCommonNavigation.breadcrumbs.expectExists(); + svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ text: createdCase.title }); + }); + }); + + describe('reporter', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('should render the reporter correctly', async () => { + const reporter = await cases.singleCase.getReporter(); + + const reporterText = await reporter.getVisibleText(); + + expect(reporterText).to.be('elastic_serverless'); + }); + }); + }); +}; diff --git a/x-pack/test_serverless/functional/test_suites/observability/index.ts b/x-pack/test_serverless/functional/test_suites/observability/index.ts index e24841e6fbff9..2d4c664085dbb 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/index.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/index.ts @@ -13,8 +13,9 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./navigation')); loadTestFile(require.resolve('./observability_log_explorer')); loadTestFile(require.resolve('./cases/attachment_framework')); + loadTestFile(require.resolve('./cases/view_case')); loadTestFile(require.resolve('./cases/configure')); - loadTestFile(require.resolve('./cases/list_view')); loadTestFile(require.resolve('./cases/create_case_form')); + loadTestFile(require.resolve('./cases/list_view')); }); } diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/app.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/app.ts index 6a9d76a9b594c..d78fc15fed8a8 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/app.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/app.ts @@ -10,7 +10,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['observabilityLogExplorer', 'svlCommonNavigation']); - describe('Application', () => { + // FLAKY: https://github.com/elastic/kibana/issues/165943 + describe.skip('Application', () => { it('is shown in the global search', async () => { await PageObjects.observabilityLogExplorer.navigateTo(); await PageObjects.svlCommonNavigation.search.showSearch(); diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/columns_selection.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/columns_selection.ts index 92ccb09a27f00..05edfb9a29350 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/columns_selection.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/columns_selection.ts @@ -16,7 +16,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const PageObjects = getPageObjects(['discover', 'observabilityLogExplorer']); - describe('Columns selection initialization and update', () => { + // FLAKY: https://github.com/elastic/kibana/issues/165915 + // FLAKY: https://github.com/elastic/kibana/issues/165916 + describe.skip('Columns selection initialization and update', () => { before(async () => { await esArchiver.load( 'x-pack/test/functional/es_archives/observability_log_explorer/data_streams' diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts index c213e48348b67..6fb8c2ae94e44 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts @@ -20,7 +20,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // Failing // Issue: https://github.com/elastic/kibana/issues/165135 - describe.skip('persistable attachment', () => { + describe.skip('Cases persistable attachments', () => { describe('lens visualization', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts index 58931d60d6836..564f14e8353c9 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts @@ -15,7 +15,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const toasts = getService('toasts'); - describe('Configure', function () { + describe('Configure Case', function () { before(async () => { await svlSecNavigation.navigateToLandingPage(); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts index d9d05fcb7420a..0e3fb3d57708a 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts @@ -15,7 +15,7 @@ import { navigateToCasesApp } from '../../../../../shared/lib/cases'; const owner = SECURITY_SOLUTION_OWNER; export default ({ getService, getPageObject }: FtrProviderContext) => { - describe('Create case', function () { + describe('Create Case', function () { const find = getService('find'); const cases = getService('cases'); const testSubjects = getService('testSubjects'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/empty.txt b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/empty.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts index 6854b3df61061..e05c16551982b 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts @@ -16,7 +16,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const svlSecNavigation = getService('svlSecNavigation'); - describe('cases list', () => { + describe('Cases List', () => { before(async () => { await svlSecNavigation.navigateToLandingPage(); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts new file mode 100644 index 0000000000000..bd0e496a1ba4d --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts @@ -0,0 +1,444 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { v4 as uuidv4 } from 'uuid'; +import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; + +import { SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { + createOneCaseBeforeDeleteAllAfter, + createAndNavigateToCase, +} from '../../../../../shared/lib/cases/helpers'; + +const owner = SECURITY_SOLUTION_OWNER; + +export default ({ getPageObject, getService }: FtrProviderContext) => { + const header = getPageObject('header'); + const testSubjects = getService('testSubjects'); + const cases = getService('cases'); + const find = getService('find'); + + const retry = getService('retry'); + const comboBox = getService('comboBox'); + const svlCommonNavigation = getPageObject('svlCommonNavigation'); + + describe('Case View', () => { + describe('page', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('should show the case view page correctly', async () => { + await testSubjects.existOrFail('case-view-title'); + await testSubjects.existOrFail('header-page-supplements'); + + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-title-files'); + await testSubjects.existOrFail('description'); + + await testSubjects.existOrFail('case-view-activity'); + + await testSubjects.existOrFail('case-view-assignees'); + await testSubjects.existOrFail('sidebar-severity'); + await testSubjects.existOrFail('case-view-user-list-reporter'); + await testSubjects.existOrFail('case-view-user-list-participants'); + await testSubjects.existOrFail('case-view-tag-list'); + await testSubjects.existOrFail('cases-categories'); + await testSubjects.existOrFail('sidebar-connectors'); + }); + }); + + describe('properties', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('edits a case title from the case view page', async () => { + const newTitle = `test-${uuidv4()}`; + + await testSubjects.click('editable-title-header-value'); + await testSubjects.setValue('editable-title-input-field', newTitle); + await testSubjects.click('editable-title-submit-btn'); + + // wait for backend response + await retry.tryForTime(5000, async () => { + const title = await find.byCssSelector('[data-test-subj="editable-title-header-value"]'); + expect(await title.getVisibleText()).equal(newTitle); + }); + + // validate user action + await find.byCssSelector('[data-test-subj*="title-update-action"]'); + }); + + it('adds a comment to a case', async () => { + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + + await testSubjects.click('submit-comment'); + + // validate user action + const newComment = await find.byCssSelector( + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' + ); + expect(await newComment.getVisibleText()).equal('Test comment from automation'); + }); + + it('adds a category to a case', async () => { + const category = uuidv4(); + await testSubjects.click('category-edit-button'); + await comboBox.setCustom('comboBoxInput', category); + await testSubjects.click('edit-category-submit'); + + // validate category was added + await testSubjects.existOrFail('category-viewer-' + category); + + // validate user action + await find.byCssSelector('[data-test-subj*="category-update-action"]'); + }); + + it('deletes a category from a case', async () => { + await find.byCssSelector('[data-test-subj*="category-viewer-"]'); + + await testSubjects.click('category-remove-button'); + + await testSubjects.existOrFail('no-categories'); + // validate user action + await find.byCssSelector('[data-test-subj*="category-delete-action"]'); + }); + + it('adds a tag to a case', async () => { + const tag = uuidv4(); + await testSubjects.click('tag-list-edit-button'); + await comboBox.setCustom('comboBoxInput', tag); + await testSubjects.click('edit-tags-submit'); + + // validate tag was added + await testSubjects.existOrFail('tag-' + tag); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-add-action"]'); + }); + + it('deletes a tag from a case', async () => { + await testSubjects.click('tag-list-edit-button'); + // find the tag button and click the close button + const button = await find.byCssSelector('[data-test-subj="comboBoxInput"] button'); + await button.click(); + await testSubjects.click('edit-tags-submit'); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-delete-action"]'); + }); + + describe('status', () => { + it('changes a case status to in-progress via dropdown menu', async () => { + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="case-status-badge-in-progress"]' + ); + // validates dropdown tag + await testSubjects.existOrFail( + 'case-view-status-dropdown > case-status-badge-popover-button-in-progress' + ); + }); + }); + + describe('Severity field', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('shows the severity field on the sidebar', async () => { + await testSubjects.existOrFail('case-severity-selection'); + }); + + it('changes the severity level from the selector', async () => { + await cases.common.selectSeverity(CaseSeverity.MEDIUM); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('case-severity-selection-' + CaseSeverity.MEDIUM); + + // validate user action + await find.byCssSelector('[data-test-subj*="severity-update-action"]'); + }); + }); + }); + + describe('actions', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('deletes the case successfully', async () => { + await cases.singleCase.deleteCase(); + await cases.casesTable.waitForTableToFinishLoading(); + await cases.casesTable.validateCasesTableHasNthRows(0); + }); + }); + + describe('filter activity', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('filters by all by default', async () => { + const allBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge' + ); + + expect(await allBadge.getAttribute('aria-label')).equal('1 active filters'); + }); + + it('filters by comment successfully', async () => { + const commentBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-comments"] span.euiNotificationBadge' + ); + + expect(await commentBadge.getAttribute('aria-label')).equal('0 available filters'); + + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + await testSubjects.click('submit-comment'); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('user-actions-filter-activity-button-comments'); + + expect(await commentBadge.getAttribute('aria-label')).equal('1 active filters'); + }); + + it('filters by history successfully', async () => { + const historyBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-history"] span.euiNotificationBadge' + ); + + expect(await historyBadge.getAttribute('aria-label')).equal('1 available filters'); + + await cases.common.selectSeverity(CaseSeverity.MEDIUM); + + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('user-actions-filter-activity-button-history'); + + expect(await historyBadge.getAttribute('aria-label')).equal('3 active filters'); + }); + + it('sorts by newest first successfully', async () => { + await testSubjects.click('user-actions-filter-activity-button-all'); + + const AllBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge' + ); + + expect(await AllBadge.getVisibleText()).equal('4'); + + const sortDesc = await find.byCssSelector( + '[data-test-subj="user-actions-sort-select"] [value="desc"]' + ); + + await sortDesc.click(); + + await header.waitUntilLoadingHasFinished(); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + const actionList = await userActionsLists[0].findAllByClassName('euiComment'); + + expect(await actionList[0].getAttribute('data-test-subj')).contain('status-update-action'); + }); + }); + + // FLAKY + describe.skip('Lens visualization', () => { + before(async () => { + await cases.testResources.installKibanaSampleData('logs'); + await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.testResources.removeKibanaSampleData('logs'); + await cases.api.deleteAllCases(); + }); + + it('adds lens visualization in description', async () => { + await testSubjects.click('description-edit-icon'); + + await header.waitUntilLoadingHasFinished(); + + const editCommentTextArea = await find.byCssSelector( + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' + ); + + await header.waitUntilLoadingHasFinished(); + + await editCommentTextArea.focus(); + + const editableDescription = await testSubjects.find('editable-markdown-form'); + + const addVisualizationButton = await editableDescription.findByCssSelector( + '[data-test-subj="euiMarkdownEditorToolbarButton"][aria-label="Visualization"]' + ); + await addVisualizationButton.click(); + + await cases.singleCase.findAndSaveVisualization('[Logs] Bytes distribution'); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('editable-save-markdown'); + + await header.waitUntilLoadingHasFinished(); + + const description = await find.byCssSelector('[data-test-subj="description"]'); + + await description.findByCssSelector('[data-test-subj="xyVisChart"]'); + }); + }); + + describe('pagination', async () => { + let createdCase: any; + + before(async () => { + createdCase = await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('initially renders user actions list correctly', async () => { + expect(testSubjects.missingOrFail('cases-show-more-user-actions')); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + expect(userActionsLists).length(1); + }); + + it('shows more actions on button click', async () => { + await cases.api.generateUserActions({ + caseId: createdCase.id, + caseVersion: createdCase.version, + totalUpdates: 4, + }); + + expect(testSubjects.missingOrFail('user-actions-loading')); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('case-refresh'); + + await header.waitUntilLoadingHasFinished(); + + expect(testSubjects.existOrFail('cases-show-more-user-actions')); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + expect(userActionsLists).length(2); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(10); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); + + testSubjects.click('cases-show-more-user-actions'); + + await header.waitUntilLoadingHasFinished(); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(20); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); + }); + }); + + describe('Tabs', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('shows the "activity" tab by default', async () => { + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + }); + + it("shows the 'files' tab when clicked", async () => { + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + }); + }); + + describe('Files', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('adds a file to the case', async () => { + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + + await cases.casesFilesTable.addFile(require.resolve('./empty.txt')); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + + it('search by file name', async () => { + await cases.casesFilesTable.searchByFileName('foobar'); + await cases.casesFilesTable.emptyOrFail(); + await cases.casesFilesTable.searchByFileName('empty'); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + + it('files added to a case can be deleted', async () => { + await cases.casesFilesTable.deleteFile(0); + await cases.casesFilesTable.emptyOrFail(); + }); + + describe('Files User Activity', () => { + it('file user action is displayed correctly', async () => { + await cases.casesFilesTable.addFile(require.resolve('./empty.txt')); + + await testSubjects.click('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + }); + }); + + describe('breadcrumbs', () => { + let createdCase: any; + + before(async () => { + createdCase = await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('should set the cases title', async () => { + svlCommonNavigation.breadcrumbs.expectExists(); + svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ text: createdCase.title }); + }); + }); + + describe('reporter', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('should render the reporter correctly', async () => { + const reporter = await cases.singleCase.getReporter(); + + const reporterText = await reporter.getVisibleText(); + + expect(reporterText).to.be('elastic_serverless'); + }); + }); + }); +}; diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts index 695336b7734fb..998ad9a2096c9 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts @@ -15,7 +15,8 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const browser = getService('browser'); - describe('navigation', function () { + // FLAKY: https://github.com/elastic/kibana/issues/165629 + describe.skip('navigation', function () { before(async () => { await svlSecNavigation.navigateToLandingPage(); }); diff --git a/x-pack/test_serverless/functional/test_suites/security/index.ts b/x-pack/test_serverless/functional/test_suites/security/index.ts index f64b4b8395dad..b4c46b5f4c263 100644 --- a/x-pack/test_serverless/functional/test_suites/security/index.ts +++ b/x-pack/test_serverless/functional/test_suites/security/index.ts @@ -13,8 +13,9 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./ftr/navigation')); loadTestFile(require.resolve('./ftr/management')); loadTestFile(require.resolve('./ftr/cases/attachment_framework')); - loadTestFile(require.resolve('./ftr/cases/list_view')); + loadTestFile(require.resolve('./ftr/cases/view_case')); loadTestFile(require.resolve('./ftr/cases/create_case_form')); loadTestFile(require.resolve('./ftr/cases/configure')); + loadTestFile(require.resolve('./ftr/cases/list_view')); }); } diff --git a/x-pack/test_serverless/shared/lib/assets/elastic_logo.png b/x-pack/test_serverless/shared/lib/assets/elastic_logo.png new file mode 100644 index 0000000000000..085012eac3788 Binary files /dev/null and b/x-pack/test_serverless/shared/lib/assets/elastic_logo.png differ diff --git a/x-pack/test_serverless/shared/lib/cases/helpers.ts b/x-pack/test_serverless/shared/lib/cases/helpers.ts index cbae461f98ca5..98dcfb6d31ebc 100644 --- a/x-pack/test_serverless/shared/lib/cases/helpers.ts +++ b/x-pack/test_serverless/shared/lib/cases/helpers.ts @@ -8,6 +8,57 @@ import { SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; import { FtrProviderContext } from '../../../functional/ftr_provider_context'; +export const createOneCaseBeforeDeleteAllAfter = ( + getPageObject: FtrProviderContext['getPageObject'], + getService: FtrProviderContext['getService'], + owner: string +) => { + const cases = getService('cases'); + + before(async () => { + await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); +}; + +export const createOneCaseBeforeEachDeleteAllAfterEach = ( + getPageObject: FtrProviderContext['getPageObject'], + getService: FtrProviderContext['getService'], + owner: string +) => { + const cases = getService('cases'); + + beforeEach(async () => { + await createAndNavigateToCase(getPageObject, getService, owner); + }); + + afterEach(async () => { + await cases.api.deleteAllCases(); + }); +}; + +export const createAndNavigateToCase = async ( + getPageObject: FtrProviderContext['getPageObject'], + getService: FtrProviderContext['getService'], + owner: string +) => { + const cases = getService('cases'); + + const header = getPageObject('header'); + + await navigateToCasesApp(getPageObject, getService, owner); + + const theCase = await cases.api.createCase({ owner }); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.goToFirstListedCase(); + await header.waitUntilLoadingHasFinished(); + + return theCase; +}; + export const navigateToCasesApp = async ( getPageObject: FtrProviderContext['getPageObject'], getService: FtrProviderContext['getService'],