diff --git a/.buildkite/pull_requests.json b/.buildkite/pull_requests.json index 027c2de8bf915..51f9ab6a34be6 100644 --- a/.buildkite/pull_requests.json +++ b/.buildkite/pull_requests.json @@ -37,6 +37,7 @@ ], "always_require_ci_on_changed": [ "^docs/developer/plugin-list.asciidoc$", + "^\\.github/CODEOWNERS$", "/plugins/[^/]+/readme\\.(md|asciidoc)$" ], "kibana_versions_check": true, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 86f74c0ec222f..2c7d27f19ce8f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -931,6 +931,7 @@ packages/kbn-get-repo-files @elastic/kibana-operations packages/kbn-guided-onboarding @elastic/platform-onboarding packages/kbn-handlebars @elastic/kibana-security packages/kbn-hapi-mocks @elastic/kibana-core +packages/kbn-health-gateway-server @elastic/kibana-core packages/kbn-i18n @elastic/kibana-core packages/kbn-i18n-react @elastic/kibana-core packages/kbn-import-resolver @elastic/kibana-operations diff --git a/docs/concepts/kuery.asciidoc b/docs/concepts/kuery.asciidoc index a7d2e83717411..4e8b6bc4043e0 100644 --- a/docs/concepts/kuery.asciidoc +++ b/docs/concepts/kuery.asciidoc @@ -1,326 +1,186 @@ [[kuery-query]] -=== Kibana Query Language +=== {kib} Query Language -The Kibana Query Language (KQL) is a simple syntax for filtering {es} data using -free text search or field-based search. KQL is only used for filtering data, and has -no role in sorting or aggregating the data. +The {kib} Query Language (KQL) is a simple text-based query language for filtering data. -KQL is able to suggest field names, values, and operators as you type. -The performance of the suggestions is controlled by <>. +* KQL only filters data, and has no role in aggregating, transforming, or sorting data. +* KQL is not to be confused with the <>, which has a different feature set. -KQL has a different set of features than the <>. KQL is able to query -nested fields and <>. KQL does not support regular expressions -or searching with fuzzy terms. +Use KQL to filter documents where a value for a field exists, matches a given value, or is within a given range. [discrete] -=== Terms query +=== Filter for documents where a field exists -A terms query uses *exact search terms*. Spaces separate each search term, and only one term -is required to match the document. Use quotation marks to indicate a *phrase match*. - -To query using *exact search terms*, enter the field name followed by `:` and -then the values separated by spaces: - -[source,yaml] -------------------- -http.response.status_code:400 401 404 -------------------- - -For text fields, this will match any value regardless of order: +To filter documents for which an indexed value exists for a given field, use the `*` operator. +For example, to filter for documents where the `http.request.method` field exists, use the following syntax: [source,yaml] ------------------- -http.response.body.content.text:quick brown fox +http.request.method: * ------------------- -To query for an *exact phrase*, use quotation marks around the values: - -[source,yaml] -------------------- -http.response.body.content.text:"quick brown fox" -------------------- - -Field names are not required by KQL. When a field name is not provided, terms -will be matched by the default fields in your index settings. To search across fields: - -[source,yaml] -------------------- -"quick brown fox" -------------------- +This checks for any indexed value, including an empty string. [discrete] -=== Boolean queries +=== Filter for documents that match a value -KQL supports `or`, `and`, and `not`. By default, `and` has a higher precedence than `or`. -To override the default precedence, group operators in parentheses. These operators can -be upper or lower case. - -To match documents where response is `200`, extension is `php`, or both: - -[source,yaml] -------------------- -response:200 or extension:php -------------------- - -To match documents where response is `200` and extension is `php`: +Use KQL to filter for documents that match a specific number, text, date, or boolean value. +For example, to filter for documents where the `http.request.method` is GET, use the following query: [source,yaml] ------------------- -response:200 and extension:php +http.request.method: GET ------------------- -To match documents where response is `200` or `404`. +The field parameter is optional. If not provided, all fields are searched for the given value. +For example, to search all fields for “Hello”, use the following: [source,yaml] ------------------- -response:(200 or 404) +Hello ------------------- -To match documents where response is `200` and extension is either `php` or `css`: +When querying keyword, numeric, date, or boolean fields, the value must be an exact match, +including punctuation and case. However, when querying text fields, {es} analyzes the +value provided according to the {ref}/analysis.html[field’s mapping settings]. +For example, to search for documents where `http.request.body.content` (a `text` field) +contains the text “null pointer”: [source,yaml] ------------------- -response:200 and (extension:php or extension:css) +http.request.body.content: null pointer ------------------- -To match documents where `response` is 200 and `extension` is -`php` or extension is `css`, and response is anything: +Because this is a `text` field, the order of these search terms does not matter, and +even documents containing “pointer null” are returned. To search `text` fields where the +terms are in the order provided, surround the value in quotation marks, as follows: [source,yaml] ------------------- -response:200 and extension:php or extension:css +http.request.body.content: "null pointer" ------------------- -To match documents where response is not `200`: +Certain characters must be escaped by a backslash (unless surrounded by quotes). +For example, to search for documents where `http.request.referrer` is https://example.com, +use either of the following queries: [source,yaml] ------------------- -not response:200 +http.request.referrer: "https://example.com" +http.request.referrer: https\://example.com ------------------- -To match documents where response is `200` but extension is not `php` or `css`. +You must escape following characters: [source,yaml] ------------------- -response:200 and not (extension:php or extension:css) -------------------- - -To match multi-value fields that contain a list of terms: - -[source,yaml] -------------------- -tags:(success and info and security) +\():<>"* ------------------- [discrete] -=== Range queries +=== Filter for documents within a range -KQL supports `>`, `>=`, `<`, and `<=` on numeric and date types. +To search documents that contain terms within a provided range, use KQL’s range syntax. +For example, to search for all documents for which `http.response.bytes` is less than 10000, +use the following syntax: [source,yaml] ------------------- -account_number >= 100 and items_sold <= 200 +http.response.bytes < 10000 ------------------- -[discrete] -=== Date range queries - -Typically, Kibana's <> is sufficient for setting a time range, -but in some cases you might need to search on dates. Include the date range in quotes. +To search for an inclusive range, combine multiple range queries. +For example, to search for documents where `http.response.bytes` is greater than 10000 +but less than or equal to 20000, use the following syntax: [source,yaml] ------------------- -@timestamp < "2021-01-02T21:55:59" +http.response.bytes > 10000 and http.response.bytes <= 20000 ------------------- -[source,yaml] -------------------- -@timestamp < "2021-01" -------------------- +You can also use range syntax for string values, IP addresses, and timestamps. +For example, to search for documents earlier than two weeks ago, use the following syntax: [source,yaml] ------------------- -@timestamp < "2021" +@timestamp < now-2w ------------------- -KQL supports date math expressions. - -[source,yaml] -------------------- -@timestamp < now-1d -------------------- - -[source,yaml] -------------------- -updated_at > 2022-02-17||+1M/d -------------------- - -Check the -{ref}/common-options.html#date-math[date math documentation] for more examples. +For more examples on acceptable date formats, refer to {ref}/common-options.html#date-math[Date Math]. [discrete] -=== Exist queries +=== Filter for documents using wildcards -An exist query matches documents that contain any value for a field, in this case, -response: +To search for documents matching a pattern, use the wildcard syntax. +For example, to find documents where `http.response.status_code` begins with a 4, use the following syntax: [source,yaml] ------------------- -response:* +http.response.status_code: 4* ------------------- -Existence is defined by {es} and includes all values, including empty text. - -[discrete] -=== Wildcard queries +By default, leading wildcards are not allowed for performance reasons. +You can modify this with the <> advanced setting. -Wildcards queries can be used to *search by a term prefix* or to *search multiple fields*. -The default settings of {kib} *prevent leading wildcards* for performance reasons, -but this can be allowed with an <>. +NOTE: Only `*` is currently supported. This matches zero or more characters. -To match documents where `machine.os` starts with `win`, such -as "windows 7" and "windows 10": - -[source,yaml] -------------------- -machine.os:win* -------------------- +[discrete] +=== Negating a query -To match multiple fields: +To negate or exclude a set of documents, use the `not` keyword (not case-sensitive). +For example, to filter documents where the `http.request.method` is *not* GET, use the following query: [source,yaml] ------------------- -machine.os*:windows 10 +NOT http.request.method: GET ------------------- -This syntax is handy when you have text and keyword -versions of a field. The query checks machine.os and machine.os.keyword -for the term -`windows 10`. - - [discrete] -=== Nested field queries - -A main consideration for querying {ref}/nested.html[nested fields] is how to -match parts of the nested query to the individual nested documents. -You can: - -* *Match parts of the query to a single nested document only.* This is what most users want when querying on a nested field. -* *Match parts of the query to different nested documents.* This is how a regular object field works. - This query is generally less useful than matching to a single document. - -In the following document, `items` is a nested field. Each document in the nested -field contains a name, stock, and category. - -[source,json] ----------------------------------- -{ - "grocery_name": "Elastic Eats", - "items": [ - { - "name": "banana", - "stock": "12", - "category": "fruit" - }, - { - "name": "peach", - "stock": "10", - "category": "fruit" - }, - { - "name": "carrot", - "stock": "9", - "category": "vegetable" - }, - { - "name": "broccoli", - "stock": "5", - "category": "vegetable" - } - ] -} ----------------------------------- +=== Combining multiple queries -[discrete] -==== Match a single document - -To match stores that have more than 10 bananas in stock: +To combine multiple queries, use the `and`/`or` keywords (not case-sensitive). +For example, to find documents where the `http.request.method` is GET *or* the `http.response.status_code` is 400, +use the following query: [source,yaml] ------------------- -items:{ name:banana and stock > 10 } +http.request.method: GET OR http.response.status_code: 400 ------------------- -`items` is the nested path. Everything inside the curly braces (the nested group) -must match a single nested document. - -The following query does not return any matches because no single nested -document has bananas with a stock of 9. +Similarly, to find documents where the `http.request.method` is GET *and* the +`http.response.status_code` is 400, use this query: [source,yaml] ------------------- -items:{ name:banana and stock:9 } +http.request.method: GET AND http.response.status_code: 400 ------------------- -[discrete] -==== Match different documents - -The following subqueries are in separate nested groups -and can match different nested documents: +To specify precedence when combining multiple queries, use parentheses. +For example, to find documents where the `http.request.method` is GET *and* +the `http.response.status_code` is 200, *or* the `http.request.method` is POST *and* +`http.response.status_code` is 400, use the following: [source,yaml] ------------------- -items:{ name:banana } and items:{ stock:9 } +(http.request.method: GET AND http.response.status_code: 200) OR +(http.request.method: POST AND http.response.status_code: 400) ------------------- -`name:banana` matches the first document in the array and `stock:9` -matches the third document in the array. - -[discrete] -==== Match single and different documents - -To find a store with more than 10 -bananas that *also* stocks vegetables: +You can also use parentheses for shorthand syntax when querying multiple values for the same field. +For example, to find documents where the `http.request.method` is GET, POST, *or* DELETE, use the following: [source,yaml] ------------------- -items:{ name:banana and stock > 10 } and items:{ category:vegetable } +http.request.method: (GET OR POST OR DELETE) ------------------- -The first nested group (`name:banana and stock > 10`) must match a single document, but the `category:vegetables` -subquery can match a different nested document because it is in a separate group. - [discrete] -==== Nested fields inside other nested fields - -KQL supports nested fields inside other nested fields—you have to -specify the full path. In this document, -`level1` and `level2` are nested fields: - -[source,json] ----------------------------------- -{ - "level1": [ - { - "level2": [ - { - "prop1": "foo", - "prop2": "bar" - }, - { - "prop1": "baz", - "prop2": "qux" - } - ] - } - ] -} ----------------------------------- - -To match on a single nested document: +=== Matching multiple fields + +Wildcards can also be used to query multiple fields. For example, to search for +documents where any sub-field of `http.response` contains “error”, use the following: [source,yaml] ------------------- -level1.level2:{ prop1:foo and prop2:bar } +http.response.*: error ------------------- diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 415080c12a65f..0cc3caf722467 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -27,6 +27,10 @@ a| <> | Send a message to a Microsoft Teams channel. +a| <> + +| Create or close an alert in Opsgenie. + a| <> | Send an event in PagerDuty. diff --git a/docs/management/connectors/action-types/opsgenie.asciidoc b/docs/management/connectors/action-types/opsgenie.asciidoc new file mode 100644 index 0000000000000..9ca081b1e55f1 --- /dev/null +++ b/docs/management/connectors/action-types/opsgenie.asciidoc @@ -0,0 +1,175 @@ +[role="xpack"] +[[opsgenie-action-type]] +=== Opsgenie connector and action +++++ +Opsgenie +++++ + +The Opsgenie connector uses the https://docs.opsgenie.com/docs/alert-api[Opsgenie alert API]. + +[float] +[[opsgenie-connector-configuration]] +==== Connector configuration + +Opsgenie connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +URL:: The Opsgenie URL. For example, https://api.opsgenie.com or https://api.eu.opsgenie.com. ++ +NOTE: If you are using the <> setting, make sure the hostname is added to the allowed hosts. +API Key:: The Opsgenie API authentication key for HTTP Basic authentication. For more details about generating Opsgenie API keys, refer to https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[Opsgenie documentation]. + +[float] +[[opgenie-connector-networking-configuration]] +==== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. + +[float] +[[Preconfigured-opsgenie-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-opsgenie: + name: preconfigured-opsgenie-connector-type + actionTypeId: .opsgenie + config: + apiUrl: https://api.opsgenie.com + secrets: + apiKey: apikey +-- + +Config defines information for the connector type. + +`apiUrl`:: A string that corresponds to *URL*. + +Secrets defines sensitive information for the connector type. + +`apiKey`:: A string that corresponds to *API Key*. + +[float] +[[define-opsgenie-ui]] +==== Define connector in {stack-manage-app} + +Define Opsgenie connector properties. + +[role="screenshot"] +image::management/connectors/images/opsgenie-connector.png[Opsgenie connector] + +Test Opsgenie action parameters. + +[role="screenshot"] +image::management/connectors/images/opsgenie-params-test.png[Opsgenie params test] + +[float] +[[opsgenie-action-configuration]] +==== Action configuration + +The Opsgenie connector supports two types of actions: Create alert and Close alert. The properties supported for each action are different because Opsgenie defines different properties for each operation. + +When testing the Opsgenie connector, choose the appropriate action from the selector. Each action has different properties that can be configured. + +Action:: Select *Create alert* to configure the actions that occur when a rule's conditions are met. Select *Close alert* to define the recovery actions that occur when a rule's conditions are no longer met. + +[float] +[[opsgenie-action-create-alert-configuration]] +===== Configure the create alert action + +You can configure the create alert action through the form view or using a JSON editor. + +[float] +[[opsgenie-action-create-alert-form-configuration]] +====== Form view + +The create alert action form has the following configuration properties. + +Message:: The message for the alert (required). +Opsgenie tags:: The tags for the alert (optional). +Priority:: The priority level for the alert. +Description:: A description that provides detailed information about the alert (optional). +Alias:: The alert identifier, which is used for alert de-duplication in Opsgenie. For more information, refer to the https://support.atlassian.com/opsgenie/docs/what-is-alert-de-duplication/[Opsgenie documentation] (optional). +Entity:: The domain of the alert (optional). +Source:: The source of the alert (optional). +User:: The display name of the owner (optional). +Note:: Additional information for the alert (optional). + +[float] +[[opsgenie-action-create-alert-json-configuration]] +====== JSON editor + +A JSON editor is provided as an alternative to the form view and supports additional fields not shown in the form view. The JSON editor supports all of the forms configuration properties but as lowercase keys as https://docs.opsgenie.com/docs/alert-api#create-alert[described in the Opsgenie API documentation]. The JSON editor supports the following additional properties: + +responders:: The entities to receive notifications about the alert (optional). +visibleTo:: The teams and users that the alert will be visible to without sending a notification to them (optional). +actions:: The custom actions available to the alert (optional). +details:: The custom properties of the alert (optional). + +[float] +[[opsgenie-action-create-alert-json-example-configuration]] +Example JSON editor contents + +[source,json] +-- +{ + "message": "An example alert message", + "alias": "Life is too short for no alias", + "description":"Every alert needs a description", + "responders":[ + {"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c", "type":"team"}, + {"name":"NOC", "type":"team"}, + {"id":"bb4d9938-c3c2-455d-aaab-727aa701c0d8", "type":"user"}, + {"username":"trinity@opsgenie.com", "type":"user"}, + {"id":"aee8a0de-c80f-4515-a232-501c0bc9d715", "type":"escalation"}, + {"name":"Nightwatch Escalation", "type":"escalation"}, + {"id":"80564037-1984-4f38-b98e-8a1f662df552", "type":"schedule"}, + {"name":"First Responders Schedule", "type":"schedule"} + ], + "visibleTo":[ + {"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"}, + {"name":"rocket_team","type":"team"}, + {"id":"bb4d9938-c3c2-455d-aaab-727aa701c0d8","type":"user"}, + {"username":"trinity@opsgenie.com","type":"user"} + ], + "actions": ["Restart", "AnExampleAction"], + "tags": ["OverwriteQuietHours","Critical"], + "details":{"key1":"value1","key2":"value2"}, + "entity":"An example entity", + "priority":"P1" +} +-- + +[float] +[[opsgenie-action-close-alert-configuration]] +===== Close alert configuration + +The close alert action has the following configuration properties. + +Alias:: The alert identifier, which is used for alert de-duplication in Opsgenie (required). The alias must match the value used when creating the alert. For more information, refer to the https://support.atlassian.com/opsgenie/docs/what-is-alert-de-duplication/[Opsgenie documentation]. +Note:: Additional information for the alert (optional). +Source:: The display name of the source (optional). +User:: The display name of the owner (optional). + +[float] +[[configuring-opsgenie]] +==== Configure an Opsgenie account + +After obtaining an Opsgenie instance, configure the API integration. For details, refer to the https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[Opsgenie documentation]. + +After creating an Opsgenie instance, https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[configure the API integration]. + +If you're using a free trial, go to the `Teams` dashboard and select the appropriate team. + +image::management/connectors/images/opsgenie-teams.png[Opsgenie teams dashboard] + +Select the `Integrations` menu item, then select `Add integration`. + +image::management/connectors/images/opsgenie-integrations.png[Opsgenie teams integrations] + +Search for `API` and select the `API` integration. + +image::management/connectors/images/opsgenie-add-api-integration.png[Opsgenie API integration] + +Configure the integration and ensure you record the `API Key`. This key will be used to populate the `API Key` field when creating the Kibana Opsgenie connector. Click `Save Integration` after you finish configuring the integration. + +image::management/connectors/images/opsgenie-save-integration.png[Opsgenie save integration] diff --git a/docs/management/connectors/images/opsgenie-add-api-integration.png b/docs/management/connectors/images/opsgenie-add-api-integration.png new file mode 100644 index 0000000000000..747afcba8354a Binary files /dev/null and b/docs/management/connectors/images/opsgenie-add-api-integration.png differ diff --git a/docs/management/connectors/images/opsgenie-connector.png b/docs/management/connectors/images/opsgenie-connector.png new file mode 100644 index 0000000000000..ccb08b27d6934 Binary files /dev/null and b/docs/management/connectors/images/opsgenie-connector.png differ diff --git a/docs/management/connectors/images/opsgenie-integrations.png b/docs/management/connectors/images/opsgenie-integrations.png new file mode 100644 index 0000000000000..cf1a48d668016 Binary files /dev/null and b/docs/management/connectors/images/opsgenie-integrations.png differ diff --git a/docs/management/connectors/images/opsgenie-params-test.png b/docs/management/connectors/images/opsgenie-params-test.png new file mode 100644 index 0000000000000..f23cff704f440 Binary files /dev/null and b/docs/management/connectors/images/opsgenie-params-test.png differ diff --git a/docs/management/connectors/images/opsgenie-save-integration.png b/docs/management/connectors/images/opsgenie-save-integration.png new file mode 100644 index 0000000000000..797508fb790cf Binary files /dev/null and b/docs/management/connectors/images/opsgenie-save-integration.png differ diff --git a/docs/management/connectors/images/opsgenie-teams.png b/docs/management/connectors/images/opsgenie-teams.png new file mode 100644 index 0000000000000..38f04a2896015 Binary files /dev/null and b/docs/management/connectors/images/opsgenie-teams.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 5529370171061..d93e36f9e4ca8 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -12,5 +12,6 @@ include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] include::action-types/cases-webhook.asciidoc[leveloffset=+1] +include::action-types/opsgenie.asciidoc[] include::action-types/xmatters.asciidoc[] include::pre-configured-connectors.asciidoc[] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 014ee4e69dca1..9501acbfa22bb 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -131,7 +131,7 @@ A list of allowed email domains which can be used with the email connector. When WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not supported in {kib} 8.0, 8.1 or 8.2. As such, this setting should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly. `xpack.actions.enabledActionTypes` {ess-icon}:: -A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. +A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. + Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. diff --git a/package.json b/package.json index 8085bc68789fe..fad59c5f77c41 100644 --- a/package.json +++ b/package.json @@ -335,6 +335,7 @@ "@kbn/guided-onboarding": "link:bazel-bin/packages/kbn-guided-onboarding", "@kbn/handlebars": "link:bazel-bin/packages/kbn-handlebars", "@kbn/hapi-mocks": "link:bazel-bin/packages/kbn-hapi-mocks", + "@kbn/health-gateway-server": "link:bazel-bin/packages/kbn-health-gateway-server", "@kbn/home-sample-data-card": "link:bazel-bin/packages/home/sample_data_card", "@kbn/home-sample-data-tab": "link:bazel-bin/packages/home/sample_data_tab", "@kbn/home-sample-data-types": "link:bazel-bin/packages/home/sample_data_types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 81aec2ac8a1ba..dc4c82a2c4b94 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -244,6 +244,7 @@ filegroup( "//packages/kbn-guided-onboarding:build", "//packages/kbn-handlebars:build", "//packages/kbn-hapi-mocks:build", + "//packages/kbn-health-gateway-server:build", "//packages/kbn-i18n:build", "//packages/kbn-i18n-react:build", "//packages/kbn-import-resolver:build", @@ -593,6 +594,7 @@ filegroup( "//packages/kbn-guided-onboarding:build_types", "//packages/kbn-handlebars:build_types", "//packages/kbn-hapi-mocks:build_types", + "//packages/kbn-health-gateway-server:build_types", "//packages/kbn-i18n:build_types", "//packages/kbn-i18n-react:build_types", "//packages/kbn-import-resolver:build_types", diff --git a/packages/kbn-health-gateway-server/.gitignore b/packages/kbn-health-gateway-server/.gitignore new file mode 100644 index 0000000000000..bdcfd7bb8629c --- /dev/null +++ b/packages/kbn-health-gateway-server/.gitignore @@ -0,0 +1 @@ +scripts/.env diff --git a/packages/kbn-health-gateway-server/BUILD.bazel b/packages/kbn-health-gateway-server/BUILD.bazel new file mode 100644 index 0000000000000..1273cc7a0d7a0 --- /dev/null +++ b/packages/kbn-health-gateway-server/BUILD.bazel @@ -0,0 +1,127 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "kbn-health-gateway-server" +PKG_REQUIRE_NAME = "@kbn/health-gateway-server" + +SOURCE_FILES = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/*.config.js", + "**/*.mock.*", + "**/*.test.*", + "**/*.stories.*", + "**/__snapshots__", + "**/integration_tests", + "**/mocks", + "**/scripts", + "**/storybook", + "**/test_fixtures", + "**/test_helpers", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +RUNTIME_DEPS = [ + "@npm//@hapi/hapi", + "@npm//node-fetch", + "//packages/kbn-config", + "//packages/kbn-config-mocks", + "//packages/kbn-config-schema", + "//packages/kbn-logging-mocks", + "//packages/kbn-server-http-tools", + "//packages/kbn-utils", + "//packages/core/logging/core-logging-server-internal", +] + +TYPES_DEPS = [ + "@npm//@types/node", + "@npm//@types/jest", + "@npm//@types/hapi__hapi", + "@npm//@types/node-fetch", + "@npm//moment", + "//packages/core/base/core-base-server-internal:npm_module_types", + "//packages/core/logging/core-logging-server-internal:npm_module_types", + "//packages/kbn-config:npm_module_types", + "//packages/kbn-config-mocks:npm_module_types", + "//packages/kbn-config-schema:npm_module_types", + "//packages/kbn-logging:npm_module_types", + "//packages/kbn-logging-mocks:npm_module_types", + "//packages/kbn-server-http-tools:npm_module_types", + "//packages/kbn-utils:npm_module_types", + "//packages/kbn-utility-types:npm_module_types", + "//packages/kbn-utility-types-jest:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = ".", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +js_library( + name = "npm_module_types", + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "build_types", + deps = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-health-gateway-server/README.md b/packages/kbn-health-gateway-server/README.md new file mode 100644 index 0000000000000..70de6130b82ab --- /dev/null +++ b/packages/kbn-health-gateway-server/README.md @@ -0,0 +1,77 @@ +# @kbn/health-gateway-server + +This package runs a small server called the Health Gateway, which exists to query +the status APIs of multiple Kibana instances and return an aggregated result. + +This is used by the Elastic Cloud infrastructure to run two different Kibana processes +with different `node.roles`: one process for handling UI requests, and one for background +tasks. + +## Configuration + +Similar to Kibana, the gateway has a yml configuration file that it reads from. By default +this lives alongside the `kibana.yml` at `/config/gateway.yml`. Like Kibana, +you can provide a `-c` or `--config` CLI argument to override the location of the config +file. + +For example: +```bash +$ yarn start --config /path/to/some/other/config.yml +``` +Here is a sample configuration file recommended for use in development: + +```yaml +# config/gateway.yml +server: + port: 3000 + host: 'localhost' + ssl: + enabled: true + # Using Kibana test certs + key: /path/to/packages/kbn-dev-utils/certs/kibana.key + certificate: /path/to/packages/kbn-dev-utils/certs/kibana.crt + certificateAuthorities: /path/to/packages/kbn-dev-utils/certs/ca.crt + +kibana: + hosts: + - 'https://localhost:5605' + - 'https://localhost:5606' + ssl: + # Using Kibana test certs + certificate: /path/to/packages/kbn-dev-utils/certs/kibana.crt + certificateAuthorities: /path/to/packages/kbn-dev-utils/certs/ca.crt + verificationMode: certificate + +logging: + root: + appenders: ['console'] + level: 'all' +``` + +Note that the gateway supports the same logging configuration as Kibana, including +all of the same appenders. + +## Development & Testing + +To run this locally, first you need to create a `config/gateway.yml` file. There's a +`docker-compose.yml` intended for development, which will run Elasticsearch and +two different Kibana instances for testing. Before using it, you'll want to create +a `.env` file: + +```bash +# From the /packages/kbn-health-gateway-server/scripts directory +$ cp .env.example .env +# (modify the .env settings if desired) +$ docker-compose up +``` + +This will automatically run Kibana on the ports from the sample `gateway.yml` +above (5605-5606). + +Once you have your `gateway.yml` and have started docker-compose, you can run the +server from the `/packages/kbn-health-gateway-server` directory with `yarn start`. Then you should +be able to make requests to the `/api/status` endpoint: + +```bash +$ curl "https://localhost:3000/api/status" +``` diff --git a/packages/kbn-health-gateway-server/index.ts b/packages/kbn-health-gateway-server/index.ts new file mode 100644 index 0000000000000..7d9858d07675a --- /dev/null +++ b/packages/kbn-health-gateway-server/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +export { bootstrap } from './src'; diff --git a/packages/kbn-health-gateway-server/jest.config.js b/packages/kbn-health-gateway-server/jest.config.js new file mode 100644 index 0000000000000..8f78cb28e601f --- /dev/null +++ b/packages/kbn-health-gateway-server/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-health-gateway-server'], +}; diff --git a/packages/kbn-health-gateway-server/kibana.jsonc b/packages/kbn-health-gateway-server/kibana.jsonc new file mode 100644 index 0000000000000..5c31c05c82461 --- /dev/null +++ b/packages/kbn-health-gateway-server/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-server", + "id": "@kbn/health-gateway-server", + "owner": "@elastic/kibana-core", + "runtimeDeps": [], + "typeDeps": [] +} diff --git a/packages/kbn-health-gateway-server/package.json b/packages/kbn-health-gateway-server/package.json new file mode 100644 index 0000000000000..d38191a879a84 --- /dev/null +++ b/packages/kbn-health-gateway-server/package.json @@ -0,0 +1,12 @@ +{ + "name": "@kbn/health-gateway-server", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "author": "Kibana Core", + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "start": "node ../../bazel-bin/packages/kbn-health-gateway-server/target_node/scripts/init.js" + }, + "types": "./target_types/index.d.ts" +} diff --git a/packages/kbn-health-gateway-server/scripts/.env.example b/packages/kbn-health-gateway-server/scripts/.env.example new file mode 100644 index 0000000000000..e63dc7c3700eb --- /dev/null +++ b/packages/kbn-health-gateway-server/scripts/.env.example @@ -0,0 +1,22 @@ +# Password for the 'elastic' user (at least 6 characters) +ELASTIC_PASSWORD=changeme + +# Password for the 'kibana_system' user (at least 6 characters) +KIBANA_PASSWORD=changeme + +# Version of Elastic products +STACK_VERSION=8.4.0 + +# Set to 'basic' or 'trial' to automatically start the 30-day trial +LICENSE=basic + +# Port to expose Elasticsearch HTTP API to the host +ES_PORT=9205 + +# Ports to expose Kibana to the host +KIBANA_01_PORT=5605 +KIBANA_02_PORT=5606 + +# Increase or decrease based on the available host memory (in bytes) +MEM_LIMIT=2147483648 + diff --git a/packages/kbn-health-gateway-server/scripts/docker-compose.yml b/packages/kbn-health-gateway-server/scripts/docker-compose.yml new file mode 100644 index 0000000000000..460119688265a --- /dev/null +++ b/packages/kbn-health-gateway-server/scripts/docker-compose.yml @@ -0,0 +1,89 @@ +version: "3" + +services: + setup: + depends_on: + elasticsearch: + condition: service_healthy + image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION} + # Adapted from https://github.com/elastic/elasticsearch/blob/main/docs/reference/setup/install/docker/docker-compose.yml + command: > + bash -c ' + echo "Setting kibana_system password"; + until curl -s -X POST -u "elastic:${ELASTIC_PASSWORD}" -H "Content-Type: application/json" http://elasticsearch:9200/_security/user/kibana_system/_password -d "{\"password\":\"${KIBANA_PASSWORD}\"}" | grep -q "^{}"; do sleep 10; done; + echo "All done!"; + ' + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION} + volumes: + - ../../kbn-dev-utils/certs:/usr/share/elasticsearch/config/certs + ports: + - ${ES_PORT}:9200 + environment: + node.name: elasticsearch + cluster.name: health-gateway-test-cluster + discovery.type: single-node + ELASTIC_PASSWORD: ${ELASTIC_PASSWORD} + bootstrap.memory_lock: true + xpack.security.enabled: true + xpack.license.self_generated.type: ${LICENSE} + mem_limit: ${MEM_LIMIT} + ulimits: + memlock: + soft: -1 + hard: -1 + healthcheck: + test: + [ + "CMD-SHELL", + "curl -s http://localhost:9200 | grep -q 'missing authentication credentials'", + ] + interval: 10s + timeout: 10s + retries: 120 + + kbn01: + depends_on: + elasticsearch: + condition: service_healthy + image: docker.elastic.co/kibana/kibana:${STACK_VERSION} + volumes: + - ../../kbn-dev-utils/certs:/usr/share/kibana/config/certs + ports: + - ${KIBANA_01_PORT}:5601 + environment: + SERVERNAME: kbn01 + NODE_ROLES: '["ui"]' + STATUS_ALLOWANONYMOUS: true + SERVER_SSL_ENABLED: true + SERVER_SSL_KEY: config/certs/kibana.key + SERVER_SSL_CERTIFICATE: config/certs/kibana.crt + SERVER_SSL_CERTIFICATEAUTHORITIES: config/certs/ca.crt + ELASTICSEARCH_HOSTS: http://elasticsearch:9200 + ELASTICSEARCH_USERNAME: kibana_system + ELASTICSEARCH_PASSWORD: ${KIBANA_PASSWORD} + mem_limit: ${MEM_LIMIT} + + kbn02: + depends_on: + elasticsearch: + condition: service_healthy + image: docker.elastic.co/kibana/kibana:${STACK_VERSION} + volumes: + - ../../kbn-dev-utils/certs:/usr/share/kibana/config/certs + ports: + - ${KIBANA_02_PORT}:5601 + environment: + SERVERNAME: kbn02 + NODE_ROLES: '["background_tasks"]' + STATUS_ALLOWANONYMOUS: true + SERVER_SSL_ENABLED: true + SERVER_SSL_KEY: config/certs/kibana.key + SERVER_SSL_CERTIFICATE: config/certs/kibana.crt + SERVER_SSL_CERTIFICATEAUTHORITIES: config/certs/ca.crt + ELASTICSEARCH_HOSTS: http://elasticsearch:9200 + ELASTICSEARCH_USERNAME: kibana_system + ELASTICSEARCH_PASSWORD: ${KIBANA_PASSWORD} + mem_limit: ${MEM_LIMIT} + diff --git a/packages/kbn-health-gateway-server/scripts/init.ts b/packages/kbn-health-gateway-server/scripts/init.ts new file mode 100644 index 0000000000000..dcef64f3f68b9 --- /dev/null +++ b/packages/kbn-health-gateway-server/scripts/init.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { bootstrap } from '../src'; + +(async () => { + await bootstrap(); +})(); diff --git a/packages/kbn-health-gateway-server/src/config/config_service.test.mocks.ts b/packages/kbn-health-gateway-server/src/config/config_service.test.mocks.ts new file mode 100644 index 0000000000000..80bc0db6c88fa --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/config_service.test.mocks.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 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 { + createTestEnv, + configServiceMock as configMock, + rawConfigServiceMock as rawMock, +} from '@kbn/config-mocks'; + +export const envCreateDefaultMock = jest.fn().mockImplementation(() => createTestEnv); +export const configServiceMock = jest.fn().mockImplementation(() => configMock.create()); +export const rawConfigServiceMock = jest.fn().mockImplementation(() => rawMock.create()); +jest.doMock('@kbn/config', () => ({ + Env: { + createDefault: envCreateDefaultMock, + }, + ConfigService: configServiceMock, + RawConfigService: rawConfigServiceMock, +})); diff --git a/packages/kbn-health-gateway-server/src/config/config_service.test.ts b/packages/kbn-health-gateway-server/src/config/config_service.test.ts new file mode 100644 index 0000000000000..351467085195d --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/config_service.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { + envCreateDefaultMock, + configServiceMock, + rawConfigServiceMock, +} from './config_service.test.mocks'; +import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { fromRoot } from '@kbn/utils'; +import { getConfigService } from './config_service'; + +const DEFAULT_CONFIG_PATH = fromRoot('config/gateway.yml'); + +describe('getConfigService', () => { + let logger: MockedLogger; + + beforeEach(() => { + logger = loggerMock.create(); + }); + + afterEach(() => { + envCreateDefaultMock.mockClear(); + configServiceMock.mockClear(); + rawConfigServiceMock.mockClear(); + }); + + test('instantiates RawConfigService with the default config path', () => { + const oldArgv = process.argv; + process.argv = []; + + getConfigService({ logger }); + expect(rawConfigServiceMock).toHaveBeenCalledTimes(1); + expect(rawConfigServiceMock).toHaveBeenCalledWith([DEFAULT_CONFIG_PATH]); + + process.argv = oldArgv; + }); + + test('instantiates RawConfigService with a custom config path provided via -c flag', () => { + const oldArgv = process.argv; + process.argv = ['-a', 'bc', '-c', 'a/b/c.yml', '-x', 'yz']; + + getConfigService({ logger }); + + expect(rawConfigServiceMock).toHaveBeenCalledTimes(1); + expect(rawConfigServiceMock).toHaveBeenCalledWith(['a/b/c.yml']); + + process.argv = oldArgv; + }); + + test('instantiates RawConfigService with a custom config path provided via --config flag', () => { + const oldArgv = process.argv; + process.argv = ['-a', 'bc', '--config', 'a/b/c.yml', '-x', 'yz']; + + getConfigService({ logger }); + + expect(rawConfigServiceMock).toHaveBeenCalledTimes(1); + expect(rawConfigServiceMock).toHaveBeenCalledWith(['a/b/c.yml']); + + process.argv = oldArgv; + }); + + test('creates default env', async () => { + const oldArgv = process.argv; + process.argv = []; + + getConfigService({ logger }); + expect(envCreateDefaultMock).toHaveBeenCalledTimes(1); + expect(envCreateDefaultMock.mock.calls[0][1].configs).toEqual([DEFAULT_CONFIG_PATH]); + + process.argv = oldArgv; + }); + + test('attempts to load the config', () => { + const mockLoadConfig = jest.fn(); + rawConfigServiceMock.mockImplementationOnce(() => ({ + loadConfig: mockLoadConfig, + })); + getConfigService({ logger }); + expect(mockLoadConfig).toHaveBeenCalledTimes(1); + }); + + test('instantiates the config service', async () => { + getConfigService({ logger }); + expect(configServiceMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/config/config_service.ts b/packages/kbn-health-gateway-server/src/config/config_service.ts new file mode 100644 index 0000000000000..059a1773d29c3 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/config_service.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 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 { fromRoot, REPO_ROOT } from '@kbn/utils'; +import type { LoggerFactory } from '@kbn/logging'; +import { ConfigService as KbnConfigService, CliArgs, Env, RawConfigService } from '@kbn/config'; +import { getArgValues } from './read_argv'; + +const CONFIG_CLI_FLAGS = ['-c', '--config']; +const DEFAULT_CONFIG_PATH = fromRoot('config/gateway.yml'); + +// These `cliArgs` are required by `Env` for use with Kibana, +// however they have no effect on the health gateway. +const KIBANA_CLI_ARGS: CliArgs = { + dev: false, + silent: false, + watch: false, + basePath: false, + disableOptimizer: true, + cache: false, + dist: false, + oss: false, + runExamples: false, +}; + +export function getConfigService({ logger }: { logger: LoggerFactory }) { + const configPathOverride = getArgValues(process.argv, CONFIG_CLI_FLAGS); + const configPath = configPathOverride.length ? configPathOverride : [DEFAULT_CONFIG_PATH]; + + const rawConfigService = new RawConfigService(configPath); + rawConfigService.loadConfig(); + + const env = Env.createDefault(REPO_ROOT, { + configs: configPath, + cliArgs: KIBANA_CLI_ARGS, + }); + + return new KbnConfigService(rawConfigService, env, logger); +} diff --git a/packages/kbn-health-gateway-server/src/config/index.ts b/packages/kbn-health-gateway-server/src/config/index.ts new file mode 100644 index 0000000000000..ce365290e7028 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +export { getConfigService } from './config_service'; diff --git a/packages/kbn-health-gateway-server/src/config/read_argv.test.ts b/packages/kbn-health-gateway-server/src/config/read_argv.test.ts new file mode 100644 index 0000000000000..57208027ec042 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/read_argv.test.ts @@ -0,0 +1,43 @@ +/* + * 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 { getArgValues } from './read_argv'; + +describe('getArgValues', () => { + it('retrieve the arg value from the provided argv arguments', () => { + const argValues = getArgValues( + ['--config', 'my-config', '--foo', '-b', 'bar', '--config', 'other-config', '--baz'], + '--config' + ); + expect(argValues).toEqual(['my-config', 'other-config']); + }); + + it('accept aliases', () => { + const argValues = getArgValues( + ['--config', 'my-config', '--foo', '-b', 'bar', '-c', 'other-config', '--baz'], + ['--config', '-c'] + ); + expect(argValues).toEqual(['my-config', 'other-config']); + }); + + it('returns an empty array when the arg is not found', () => { + const argValues = getArgValues( + ['--config', 'my-config', '--foo', '-b', 'bar', '-c', 'other-config', '--baz'], + '--unicorn' + ); + expect(argValues).toEqual([]); + }); + + it('ignores the flag when no value is provided', () => { + const argValues = getArgValues( + ['-c', 'my-config', '--foo', '-b', 'bar', '--config'], + ['--config', '-c'] + ); + expect(argValues).toEqual(['my-config']); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/config/read_argv.ts b/packages/kbn-health-gateway-server/src/config/read_argv.ts new file mode 100644 index 0000000000000..fd2be4d6d1776 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/read_argv.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 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. + */ + +/** + * Borrowed from @kbn/apm-config-loader. + */ +export const getArgValues = (argv: string[], flag: string | string[]): string[] => { + const flags = typeof flag === 'string' ? [flag] : flag; + const values: string[] = []; + for (let i = 0; i < argv.length; i++) { + if (flags.includes(argv[i]) && argv[i + 1]) { + values.push(argv[++i]); + } + } + return values; +}; diff --git a/packages/kbn-health-gateway-server/src/index.ts b/packages/kbn-health-gateway-server/src/index.ts new file mode 100644 index 0000000000000..e069153eef3ff --- /dev/null +++ b/packages/kbn-health-gateway-server/src/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 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 { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; +import { + config as loggingConfig, + LoggingSystem, + LoggingConfigType, +} from '@kbn/core-logging-server-internal'; +import { getConfigService } from './config'; +import { config as kibanaConfig, KibanaService } from './kibana'; +import { config as serverConfig, Server, ServerStart } from './server'; + +export async function bootstrap() { + const loggingSystem = new LoggingSystem(); + const logger = loggingSystem.asLoggerFactory(); + const configService = getConfigService({ logger }); + + const configDescriptors: ServiceConfigDescriptor[] = [loggingConfig, kibanaConfig, serverConfig]; + for (const { path, schema } of configDescriptors) { + configService.setSchema(path, schema); + } + + await configService.validate(); + + await loggingSystem.upgrade(configService.atPathSync('logging')); + const log = logger.get('root'); + + let server: Server; + let serverStart: ServerStart; + try { + server = new Server({ config: configService, logger }); + serverStart = await server.start(); + } catch (e) { + log.error(`Failed to start Server: ${e}`); + process.exit(1); + } + + let kibanaService: KibanaService; + try { + kibanaService = new KibanaService({ config: configService, logger }); + await kibanaService.start({ server: serverStart }); + } catch (e) { + log.error(`Failed to start Kibana service: ${e}`); + process.exit(1); + } + + const attemptGracefulShutdown = async (exitCode: number = 0) => { + await server.stop(); + kibanaService.stop(); + await loggingSystem.stop(); + process.exit(exitCode); + }; + + process.on('unhandledRejection', async (err: Error) => { + log.error(err); + await attemptGracefulShutdown(1); + }); + + process.on('SIGINT', async () => await attemptGracefulShutdown()); + process.on('SIGTERM', async () => await attemptGracefulShutdown()); +} diff --git a/packages/kbn-health-gateway-server/src/kibana/index.ts b/packages/kbn-health-gateway-server/src/kibana/index.ts new file mode 100644 index 0000000000000..23fb58ebd356e --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +export { config } from './kibana_config'; +export { KibanaService } from './kibana_service'; diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_config.test.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_config.test.ts new file mode 100644 index 0000000000000..841659b445a03 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_config.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { config } from './kibana_config'; + +describe('kibana config', () => { + test('has defaults for config', () => { + const configSchema = config.schema; + const obj = { + hosts: ['http://localhost:5601'], + }; + expect(configSchema.validate(obj)).toMatchInlineSnapshot(` + Object { + "hosts": Array [ + "http://localhost:5601", + ], + "requestTimeout": "PT30S", + "ssl": Object { + "verificationMode": "full", + }, + } + `); + }); + + describe('hosts', () => { + test('accepts valid hosts', () => { + const configSchema = config.schema; + const validHosts = ['http://some.host:1234', 'https://some.other.host']; + expect(configSchema.validate({ hosts: validHosts })).toEqual( + expect.objectContaining({ hosts: validHosts }) + ); + }); + + test('throws if invalid hosts', () => { + const invalidHosts = ['https://localhost:3000', 'abcxyz']; + const configSchema = config.schema; + expect(() => configSchema.validate({ hosts: invalidHosts })).toThrowError( + '[hosts.1]: expected URI with scheme [http|https].' + ); + }); + }); + + describe('ssl', () => { + test('accepts valid ssl config', () => { + const configSchema = config.schema; + const valid = { + certificate: '/herp/derp', + certificateAuthorities: ['/beep/boop'], + verificationMode: 'certificate', + }; + expect( + configSchema.validate({ + hosts: ['http://localhost:5601'], + ssl: valid, + }) + ).toEqual(expect.objectContaining({ ssl: valid })); + }); + + test('throws if invalid ssl config', () => { + const configSchema = config.schema; + const hosts = ['http://localhost:5601']; + const invalid = { + verificationMode: 'nope', + }; + expect(() => configSchema.validate({ hosts, ssl: invalid })) + .toThrowErrorMatchingInlineSnapshot(` + "[ssl.verificationMode]: types that failed validation: + - [ssl.verificationMode.0]: expected value to equal [none] + - [ssl.verificationMode.1]: expected value to equal [certificate] + - [ssl.verificationMode.2]: expected value to equal [full]" + `); + }); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_config.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_config.ts new file mode 100644 index 0000000000000..b44af3cbcd2da --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_config.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { readFileSync } from 'fs'; +import type { Duration } from 'moment'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; + +const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); + +const configSchema = schema.object({ + hosts: schema.arrayOf(hostURISchema, { + minSize: 1, + }), + requestTimeout: schema.duration({ defaultValue: '30s' }), + ssl: schema.object({ + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + certificateAuthorities: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) + ), + certificate: schema.maybe(schema.string()), + }), +}); + +export type KibanaConfigType = TypeOf; + +export const config: ServiceConfigDescriptor = { + path: 'kibana' as const, + schema: configSchema, +}; + +export class KibanaConfig { + /** + * Kibana hosts that the gateway will connect to. + */ + public readonly hosts: string[]; + + /** + * Timeout after which HTTP requests to the Kibana hosts will be aborted. + */ + public readonly requestTimeout: Duration; + + /** + * Settings to configure SSL connection between the gateway and Kibana hosts. + */ + public readonly ssl: SslConfig; + + constructor(rawConfig: KibanaConfigType) { + this.hosts = rawConfig.hosts; + this.requestTimeout = rawConfig.requestTimeout; + + const { verificationMode } = rawConfig.ssl; + const { certificate, certificateAuthorities } = readKeyAndCerts(rawConfig); + + this.ssl = { + certificate, + certificateAuthorities, + verificationMode, + }; + } +} + +interface SslConfig { + verificationMode: 'none' | 'certificate' | 'full'; + certificate?: string; + certificateAuthorities?: string[]; +} + +const readKeyAndCerts = (rawConfig: KibanaConfigType) => { + let certificate: string | undefined; + let certificateAuthorities: string[] | undefined; + + const addCAs = (ca: string[] | undefined) => { + if (ca && ca.length) { + certificateAuthorities = [...(certificateAuthorities || []), ...ca]; + } + }; + + if (rawConfig.ssl.certificate) { + certificate = readFile(rawConfig.ssl.certificate); + } + + const ca = rawConfig.ssl.certificateAuthorities; + if (ca) { + const parsed: string[] = []; + const paths = Array.isArray(ca) ? ca : [ca]; + if (paths.length > 0) { + for (const path of paths) { + parsed.push(readFile(path)); + } + addCAs(parsed); + } + } + + return { + certificate, + certificateAuthorities, + }; +}; + +const readFile = (file: string) => readFileSync(file, 'utf8'); diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.mocks.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.mocks.ts new file mode 100644 index 0000000000000..36934786ac03a --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.mocks.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +export const mockReadFileSync = jest.fn(); +jest.doMock('fs', () => ({ readFileSync: mockReadFileSync })); diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts new file mode 100644 index 0000000000000..5940e8de9682b --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { configServiceMock, IConfigServiceMock } from '@kbn/config-mocks'; +import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import type { ServerStart } from '../server'; +import { serverMock } from '../server/server.mock'; +import { mockReadFileSync } from './kibana_service.test.mocks'; +import { KibanaService } from './kibana_service'; + +describe('KibanaService', () => { + let config: IConfigServiceMock; + let logger: MockedLogger; + let server: ServerStart; + const mockConfig = { + hosts: ['https://localhost:5605', 'https://localhost:5606'], + requestTimeout: '30s', + ssl: { + certificate: '/herp/derp', + certificateAuthorities: '/beep/boop', + verificationMode: 'certificate', + }, + }; + + beforeEach(() => { + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + config = configServiceMock.create(); + config.atPathSync.mockReturnValue(mockConfig); + logger = loggerMock.create(); + server = serverMock.createStartContract(); + }); + + describe('start', () => { + test(`doesn't return a start contract`, async () => { + const kibanaService = new KibanaService({ config, logger }); + const kibanaStart = await kibanaService.start({ server }); + expect(kibanaStart).toBeUndefined(); + }); + + test('registers /api/status route with the server', async () => { + const kibanaService = new KibanaService({ config, logger }); + await kibanaService.start({ server }); + expect(server.addRoute).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/api/status', + }) + ); + }); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts new file mode 100644 index 0000000000000..f1ef43e2b70b2 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { IConfigService } from '@kbn/config'; +import type { Logger, LoggerFactory } from '@kbn/logging'; +import { ServerStart } from '../server'; +import { createStatusRoute } from './routes'; + +interface KibanaServiceStartDependencies { + server: ServerStart; +} + +interface KibanaServiceDependencies { + logger: LoggerFactory; + config: IConfigService; +} + +/** + * A service to interact with the configured `kibana.hosts`. + */ +export class KibanaService { + private readonly log: Logger; + private readonly config: IConfigService; + + constructor({ logger, config }: KibanaServiceDependencies) { + this.log = logger.get('kibana-service'); + this.config = config; + } + + async start({ server }: KibanaServiceStartDependencies) { + server.addRoute(createStatusRoute({ config: this.config, log: this.log })); + } + + stop() { + // nothing to do here yet + } +} diff --git a/packages/kbn-health-gateway-server/src/kibana/routes/index.ts b/packages/kbn-health-gateway-server/src/kibana/routes/index.ts new file mode 100644 index 0000000000000..f7fcbda3c6d6e --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/routes/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +export { createStatusRoute } from './status'; diff --git a/packages/kbn-health-gateway-server/src/kibana/routes/status.ts b/packages/kbn-health-gateway-server/src/kibana/routes/status.ts new file mode 100644 index 0000000000000..1ad66107013e3 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/routes/status.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 https from 'https'; +import { URL } from 'url'; +import type { Request, ResponseToolkit } from '@hapi/hapi'; +import nodeFetch, { RequestInit, Response } from 'node-fetch'; +import type { IConfigService } from '@kbn/config'; +import type { Logger } from '@kbn/logging'; +import type { KibanaConfigType } from '../kibana_config'; +import { KibanaConfig } from '../kibana_config'; + +const HTTPS = 'https:'; + +const GATEWAY_STATUS_ROUTE = '/api/status'; +const KIBANA_STATUS_ROUTE = '/api/status'; + +interface StatusRouteDependencies { + log: Logger; + config: IConfigService; +} + +type Fetch = (path: string) => Promise; + +export function createStatusRoute({ config, log }: StatusRouteDependencies) { + const kibanaConfig = new KibanaConfig(config.atPathSync('kibana')); + const fetch = configureFetch(kibanaConfig); + + return { + method: 'GET', + path: GATEWAY_STATUS_ROUTE, + handler: async (req: Request, h: ResponseToolkit) => { + const responses = await fetchKibanaStatuses({ fetch, kibanaConfig, log }); + const { body, statusCode } = mergeStatusResponses(responses); + return h.response(body).type('application/json').code(statusCode); + }, + }; +} + +async function fetchKibanaStatuses({ + fetch, + kibanaConfig, + log, +}: { + fetch: Fetch; + kibanaConfig: KibanaConfig; + log: Logger; +}) { + const requests = await Promise.allSettled( + kibanaConfig.hosts.map(async (host) => { + log.debug(`Fetching response from ${host}${KIBANA_STATUS_ROUTE}`); + const response = fetch(`${host}${KIBANA_STATUS_ROUTE}`).then((res) => res.json()); + return response; + }) + ); + + return requests.map((r, i) => { + if (r.status === 'rejected') { + log.error(`Unable to retrieve status from ${kibanaConfig.hosts[i]}${KIBANA_STATUS_ROUTE}`); + } else { + log.info( + `Got response from ${kibanaConfig.hosts[i]}${KIBANA_STATUS_ROUTE}: ${JSON.stringify( + r.value.status?.overall ? r.value.status.overall : r.value + )}` + ); + } + return r; + }); +} + +function mergeStatusResponses( + responses: Array | PromiseRejectedResult> +) { + let statusCode = 200; + for (const response of responses) { + if (response.status === 'rejected') { + statusCode = 503; + } + } + + return { + body: {}, // Need to determine what response body, if any, we want to include + statusCode, + }; +} + +function generateAgentConfig(sslConfig: KibanaConfig['ssl']) { + const options: https.AgentOptions = { + ca: sslConfig.certificateAuthorities, + cert: sslConfig.certificate, + }; + + const verificationMode = sslConfig.verificationMode; + switch (verificationMode) { + case 'none': + options.rejectUnauthorized = false; + break; + case 'certificate': + options.rejectUnauthorized = true; + // by default, NodeJS is checking the server identify + options.checkServerIdentity = () => undefined; + break; + case 'full': + options.rejectUnauthorized = true; + break; + default: + throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); + } + + return options; +} + +function configureFetch(kibanaConfig: KibanaConfig) { + let agent: https.Agent; + + return async (url: string) => { + const { protocol } = new URL(url); + if (protocol === HTTPS && !agent) { + agent = new https.Agent(generateAgentConfig(kibanaConfig.ssl)); + } + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + kibanaConfig.requestTimeout.asMilliseconds() + ); + + const fetchOptions: RequestInit = { + ...(protocol === HTTPS && { agent }), + signal: controller.signal, + }; + try { + const response = await nodeFetch(url, fetchOptions); + clearTimeout(timeoutId); + return response; + } catch (e) { + clearTimeout(timeoutId); + throw e; + } + }; +} diff --git a/packages/kbn-health-gateway-server/src/server/index.ts b/packages/kbn-health-gateway-server/src/server/index.ts new file mode 100644 index 0000000000000..698725affdec5 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +export { config } from './server_config'; +export type { ServerStart } from './server'; +export { Server } from './server'; diff --git a/packages/kbn-health-gateway-server/src/server/server.mock.ts b/packages/kbn-health-gateway-server/src/server/server.mock.ts new file mode 100644 index 0000000000000..c62449d321752 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server.mock.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { PublicMethodsOf } from '@kbn/utility-types'; +import type { ServerStart } from './server'; +import { Server } from './server'; + +const createStartMock = (): jest.Mocked => ({ + addRoute: jest.fn(), +}); + +type ServerContract = PublicMethodsOf; +const createMock = (): jest.Mocked => { + const service: jest.Mocked = { + start: jest.fn(), + stop: jest.fn(), + }; + + service.start.mockResolvedValue(createStartMock()); + + return service; +}; + +export const serverMock = { + create: createMock, + createStartContract: createStartMock, +}; diff --git a/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts b/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts new file mode 100644 index 0000000000000..543fe9b29e9cc --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { sslSchema, getServerOptions, getListenerOptions } from '@kbn/server-http-tools'; + +export const hapiStartMock = jest.fn(); +export const hapiStopMock = jest.fn(); +export const hapiRouteMock = jest.fn(); +export const createServerMock = jest.fn().mockImplementation(() => ({ + info: { uri: 'http://localhost:3000' }, + start: hapiStartMock, + stop: hapiStopMock, + route: hapiRouteMock, +})); +export const getServerOptionsMock = jest.fn().mockImplementation(getServerOptions); +export const getListenerOptionsMock = jest.fn().mockImplementation(getListenerOptions); + +jest.doMock('@kbn/server-http-tools', () => ({ + createServer: createServerMock, + getServerOptions: getServerOptionsMock, + getListenerOptions: getListenerOptionsMock, + sslSchema, + SslConfig: jest.fn(), +})); diff --git a/packages/kbn-health-gateway-server/src/server/server.test.ts b/packages/kbn-health-gateway-server/src/server/server.test.ts new file mode 100644 index 0000000000000..e0a65229c3374 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { + createServerMock, + getServerOptionsMock, + getListenerOptionsMock, + hapiStartMock, + hapiStopMock, + hapiRouteMock, +} from './server.test.mocks'; +import { configServiceMock, IConfigServiceMock } from '@kbn/config-mocks'; +import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { Server } from './server'; + +const mockConfig = { + port: 3000, + host: 'localhost', + maxPayload: { getValueInBytes: () => '1048576b' }, + keepaliveTimeout: 120000, + shutdownTimeout: '30s', + socketTimeout: 120000, +}; + +describe('Server', () => { + let config: IConfigServiceMock; + let logger: MockedLogger; + + beforeEach(() => { + config = configServiceMock.create(); + config.atPathSync.mockReturnValue(mockConfig); + logger = loggerMock.create(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('start', () => { + test('logs the uri on server start', async () => { + const server = new Server({ config, logger }); + await server.start(); + expect(logger.info).toHaveBeenCalledWith('Server running on http://localhost:3000'); + }); + + test('provides the correct server options', async () => { + const server = new Server({ config, logger }); + await server.start(); + expect(createServerMock).toHaveBeenCalledTimes(1); + expect(getServerOptionsMock).toHaveBeenCalledTimes(1); + expect(getServerOptionsMock.mock.calls[0][0]).toEqual( + expect.objectContaining({ ...mockConfig }) + ); + expect(getListenerOptionsMock.mock.calls[0][0]).toEqual( + expect.objectContaining({ ...mockConfig }) + ); + }); + + test('starts the Hapi server', async () => { + const server = new Server({ config, logger }); + await server.start(); + expect(hapiStartMock).toHaveBeenCalledTimes(1); + }); + + describe('addRoute', () => { + test('registers route with Hapi', async () => { + const server = new Server({ config, logger }); + const { addRoute } = await server.start(); + addRoute({ + method: 'GET', + path: '/api/whatever', + }); + expect(hapiRouteMock).toHaveBeenCalledTimes(1); + expect(hapiRouteMock).toHaveBeenCalledWith({ + method: 'GET', + path: '/api/whatever', + }); + }); + }); + }); + + describe('stop', () => { + test('attempts graceful shutdown', async () => { + const server = new Server({ config, logger }); + await server.start(); + await server.stop(); + expect(hapiStopMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/server/server.ts b/packages/kbn-health-gateway-server/src/server/server.ts new file mode 100644 index 0000000000000..90123cf70380d --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Server as HapiServer, ServerRoute as HapiServerRoute } from '@hapi/hapi'; +import { createServer, getServerOptions, getListenerOptions } from '@kbn/server-http-tools'; +import type { IConfigService } from '@kbn/config'; +import type { Logger, LoggerFactory } from '@kbn/logging'; +import { ServerConfig } from './server_config'; +import type { ServerConfigType } from './server_config'; + +interface ServerDeps { + logger: LoggerFactory; + config: IConfigService; +} + +type RouteDefinition = HapiServerRoute; + +export interface ServerStart { + addRoute: (routeDefinition: RouteDefinition) => void; +} + +/** + * A very thin wrapper around Hapi, which only exposes the functionality we + * need for this app. + */ +export class Server { + private readonly log: Logger; + private readonly config: IConfigService; + private server?: HapiServer; + + constructor({ logger, config }: ServerDeps) { + this.log = logger.get('server'); + this.config = config; + } + + async start(): Promise { + const serverConfig = new ServerConfig(this.config.atPathSync('server')); + this.server = createServer(getServerOptions(serverConfig), getListenerOptions(serverConfig)); + + await this.server.start(); + this.log.info(`Server running on ${this.server.info.uri}`); + + return { + addRoute: (definition) => { + this.log.debug(`registering route handler for [${definition.path}]`); + this.server!.route(definition); + }, + }; + } + + async stop() { + this.log.debug('Attempting graceful shutdown'); + if (this.server) { + await this.server.stop(); + } + } +} diff --git a/packages/kbn-health-gateway-server/src/server/server_config.test.ts b/packages/kbn-health-gateway-server/src/server/server_config.test.ts new file mode 100644 index 0000000000000..66f82c0f1502b --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server_config.test.ts @@ -0,0 +1,191 @@ +/* + * 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 { config, ServerConfig } from './server_config'; + +describe('server config', () => { + test('has defaults for config', () => { + const configSchema = config.schema; + const obj = {}; + expect(configSchema.validate(obj)).toMatchInlineSnapshot(` + Object { + "host": "localhost", + "keepaliveTimeout": 120000, + "maxPayload": ByteSizeValue { + "valueInBytes": 1048576, + }, + "port": 3000, + "shutdownTimeout": "PT30S", + "socketTimeout": 120000, + "ssl": Object { + "cipherSuites": Array [ + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + "TLS_AES_128_GCM_SHA256", + "ECDHE-RSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-ECDSA-AES256-GCM-SHA384", + "DHE-RSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES128-SHA256", + "DHE-RSA-AES128-SHA256", + "ECDHE-RSA-AES256-SHA384", + "DHE-RSA-AES256-SHA384", + "ECDHE-RSA-AES256-SHA256", + "DHE-RSA-AES256-SHA256", + "HIGH", + "!aNULL", + "!eNULL", + "!EXPORT", + "!DES", + "!RC4", + "!MD5", + "!PSK", + "!SRP", + "!CAMELLIA", + ], + "clientAuthentication": "none", + "enabled": false, + "keystore": Object {}, + "supportedProtocols": Array [ + "TLSv1.1", + "TLSv1.2", + "TLSv1.3", + ], + "truststore": Object {}, + }, + } + `); + }); + + describe('host', () => { + const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost', '0.0.0.0']; + const invalidHostnames = ['asdf$%^', '0']; + + test('accepts valid hostnames', () => { + for (const val of validHostnames) { + const { host } = config.schema.validate({ host: val }); + expect(host).toBe(val); + } + }); + + test('throws if invalid hostname', () => { + for (const host of invalidHostnames) { + const configSchema = config.schema; + expect(() => configSchema.validate({ host })).toThrowError( + '[host]: value must be a valid hostname (see RFC 1123).' + ); + } + }); + }); + + describe('port', () => { + test('accepts valid ports', () => { + const validPorts = [80, 3000, 5601]; + for (const val of validPorts) { + const { port } = config.schema.validate({ port: val }); + expect(port).toBe(val); + } + }); + + test('throws if invalid ports', () => { + const configSchema = config.schema; + expect(() => configSchema.validate({ port: false })).toThrowError( + 'port]: expected value of type [number] but got [boolean]' + ); + expect(() => configSchema.validate({ port: 'oops' })).toThrowError( + 'port]: expected value of type [number] but got [string]' + ); + }); + }); + + describe('maxPayload', () => { + test('can specify max payload as string', () => { + const obj = { + maxPayload: '2mb', + }; + const configValue = config.schema.validate(obj); + expect(configValue.maxPayload.getValueInBytes()).toBe(2 * 1024 * 1024); + }); + }); + + describe('shutdownTimeout', () => { + test('can specify a valid shutdownTimeout', () => { + const configValue = config.schema.validate({ shutdownTimeout: '5s' }); + expect(configValue.shutdownTimeout.asMilliseconds()).toBe(5000); + }); + + test('can specify a valid shutdownTimeout (lower-edge of 1 second)', () => { + const configValue = config.schema.validate({ shutdownTimeout: '1s' }); + expect(configValue.shutdownTimeout.asMilliseconds()).toBe(1000); + }); + + test('can specify a valid shutdownTimeout (upper-edge of 2 minutes)', () => { + const configValue = config.schema.validate({ shutdownTimeout: '2m' }); + expect(configValue.shutdownTimeout.asMilliseconds()).toBe(120000); + }); + + test('should error if below 1s', () => { + expect(() => config.schema.validate({ shutdownTimeout: '100ms' })).toThrow( + '[shutdownTimeout]: the value should be between 1 second and 2 minutes' + ); + }); + + test('should error if over 2 minutes', () => { + expect(() => config.schema.validate({ shutdownTimeout: '3m' })).toThrow( + '[shutdownTimeout]: the value should be between 1 second and 2 minutes' + ); + }); + }); + + describe('with TLS', () => { + test('throws if TLS is enabled but `redirectHttpFromPort` is equal to `port`', () => { + const configSchema = config.schema; + const obj = { + port: 1234, + ssl: { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + redirectHttpFromPort: 1234, + }, + }; + expect(() => configSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"The health gateway does not accept http traffic to [port] when ssl is enabled (only https is allowed), so [ssl.redirectHttpFromPort] cannot be configured to the same value. Both are [1234]."` + ); + }); + }); + + describe('socketTimeout', () => { + test('can specify socket timeouts', () => { + const obj = { + keepaliveTimeout: 1e5, + socketTimeout: 5e5, + }; + const { keepaliveTimeout, socketTimeout } = config.schema.validate(obj); + expect(keepaliveTimeout).toBe(1e5); + expect(socketTimeout).toBe(5e5); + }); + }); + + describe('cors', () => { + test('is always disabled', () => { + const configSchema = config.schema; + const obj = {}; + expect(new ServerConfig(configSchema.validate(obj)).cors).toMatchInlineSnapshot(` + Object { + "allowCredentials": false, + "allowOrigin": Array [ + "*", + ], + "enabled": false, + } + `); + }); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/server/server_config.ts b/packages/kbn-health-gateway-server/src/server/server_config.ts new file mode 100644 index 0000000000000..79c4f760c4408 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server_config.ts @@ -0,0 +1,92 @@ +/* + * 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 type { Duration } from 'moment'; +import { schema, TypeOf, ByteSizeValue } from '@kbn/config-schema'; +import { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; +import type { ISslConfig, ICorsConfig, IHttpConfig } from '@kbn/server-http-tools'; +import { sslSchema, SslConfig } from '@kbn/server-http-tools'; + +const configSchema = schema.object( + { + host: schema.string({ + defaultValue: 'localhost', + hostname: true, + }), + port: schema.number({ + defaultValue: 3000, + }), + maxPayload: schema.byteSize({ + defaultValue: '1048576b', + }), + keepaliveTimeout: schema.number({ + defaultValue: 120000, + }), + shutdownTimeout: schema.duration({ + defaultValue: '30s', + validate: (duration) => { + const durationMs = duration.asMilliseconds(); + if (durationMs < 1000 || durationMs > 2 * 60 * 1000) { + return 'the value should be between 1 second and 2 minutes'; + } + }, + }), + socketTimeout: schema.number({ + defaultValue: 120000, + }), + ssl: sslSchema, + }, + { + validate: (rawConfig) => { + if ( + rawConfig.ssl.enabled && + rawConfig.ssl.redirectHttpFromPort !== undefined && + rawConfig.ssl.redirectHttpFromPort === rawConfig.port + ) { + return ( + 'The health gateway does not accept http traffic to [port] when ssl is ' + + 'enabled (only https is allowed), so [ssl.redirectHttpFromPort] ' + + `cannot be configured to the same value. Both are [${rawConfig.port}].` + ); + } + }, + } +); + +export type ServerConfigType = TypeOf; + +export const config: ServiceConfigDescriptor = { + path: 'server' as const, + schema: configSchema, +}; + +export class ServerConfig implements IHttpConfig { + host: string; + port: number; + maxPayload: ByteSizeValue; + keepaliveTimeout: number; + shutdownTimeout: Duration; + socketTimeout: number; + ssl: ISslConfig; + cors: ICorsConfig; + + constructor(rawConfig: ServerConfigType) { + this.host = rawConfig.host; + this.port = rawConfig.port; + this.maxPayload = rawConfig.maxPayload; + this.keepaliveTimeout = rawConfig.keepaliveTimeout; + this.shutdownTimeout = rawConfig.shutdownTimeout; + this.socketTimeout = rawConfig.socketTimeout; + this.ssl = new SslConfig(rawConfig.ssl); + this.cors = { + enabled: false, + allowCredentials: false, + allowOrigin: ['*'], + }; + } +} diff --git a/packages/kbn-health-gateway-server/tsconfig.json b/packages/kbn-health-gateway-server/tsconfig.json new file mode 100644 index 0000000000000..98e6b09c1c81a --- /dev/null +++ b/packages/kbn-health-gateway-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "stripInternal": false, + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ] +} diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index 0e61cba7511ac..ae37273c8aefb 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -30,6 +30,7 @@ const ALERT_DURATION = `${ALERT_NAMESPACE}.duration.us` as const; const ALERT_END = `${ALERT_NAMESPACE}.end` as const; const ALERT_EVALUATION_THRESHOLD = `${ALERT_NAMESPACE}.evaluation.threshold` as const; const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const; +const ALERT_FLAPPING = `${ALERT_NAMESPACE}.flapping` as const; const ALERT_INSTANCE_ID = `${ALERT_NAMESPACE}.instance.id` as const; const ALERT_REASON = `${ALERT_NAMESPACE}.reason` as const; const ALERT_RISK_SCORE = `${ALERT_NAMESPACE}.risk_score` as const; @@ -115,6 +116,7 @@ const fields = { ALERT_END, ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, + ALERT_FLAPPING, ALERT_INSTANCE_ID, ALERT_RULE_CONSUMER, ALERT_RULE_PRODUCER, @@ -176,6 +178,7 @@ export { ALERT_END, ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, + ALERT_FLAPPING, ALERT_INSTANCE_ID, ALERT_NAMESPACE, ALERT_RULE_NAMESPACE, diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.test.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.test.tsx index ae9459272e37d..4bd3fca6cb902 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.test.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.test.tsx @@ -42,4 +42,21 @@ describe('AnalyticsNoDataPageComponent', () => { expect(noDataConfig.docsLink).toEqual('http://www.test.com'); expect(noDataConfig.action.elasticAgent).not.toBeNull(); }); + + it('allows ad-hoc data view creation', async () => { + const component = mountWithIntl( + + + + ); + + await act(() => new Promise(setImmediate)); + + expect(component.find(KibanaNoDataPage).length).toBe(1); + expect(component.find(KibanaNoDataPage).props().allowAdHocDataView).toBe(true); + }); }); diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx index 657d823606155..fe607b70120df 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx @@ -17,6 +17,8 @@ export interface Props { kibanaGuideDocLink: string; /** Handler for successfully creating a new data view. */ onDataViewCreated: (dataView: unknown) => void; + /** if set to true allows creation of an ad-hoc dataview from data view editor */ + allowAdHocDataView?: boolean; } const solution = i18n.translate('sharedUXPackages.noDataConfig.analytics', { @@ -41,7 +43,11 @@ const addIntegrationsDescription = i18n.translate( /** * A pure component of an entire page that can be displayed when Kibana "has no data", specifically for Analytics. */ -export const AnalyticsNoDataPage = ({ kibanaGuideDocLink, onDataViewCreated }: Props) => { +export const AnalyticsNoDataPage = ({ + kibanaGuideDocLink, + onDataViewCreated, + allowAdHocDataView, +}: Props) => { const noDataConfig = { solution, pageTitle, @@ -55,6 +61,5 @@ export const AnalyticsNoDataPage = ({ kibanaGuideDocLink, onDataViewCreated }: P }, docsLink: kibanaGuideDocLink, }; - - return ; + return ; }; diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx index 191ea9010ba1e..996b9d062becf 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx @@ -27,7 +27,7 @@ describe('AnalyticsNoDataPage', () => { it('renders correctly', async () => { const component = mountWithIntl( - + ); @@ -36,5 +36,6 @@ describe('AnalyticsNoDataPage', () => { expect(component.find(Component).length).toBe(1); expect(component.find(Component).props().kibanaGuideDocLink).toBe(services.kibanaGuideDocLink); expect(component.find(Component).props().onDataViewCreated).toBe(onDataViewCreated); + expect(component.find(Component).props().allowAdHocDataView).toBe(true); }); }); diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx index 8a67467939a92..df1fc2486c1b3 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx @@ -15,7 +15,10 @@ import { AnalyticsNoDataPage as Component } from './analytics_no_data_page.compo * An entire page that can be displayed when Kibana "has no data", specifically for Analytics. Uses * services from a Provider to supply props to a pure component. */ -export const AnalyticsNoDataPage = ({ onDataViewCreated }: AnalyticsNoDataPageProps) => { +export const AnalyticsNoDataPage = ({ + onDataViewCreated, + allowAdHocDataView, +}: AnalyticsNoDataPageProps) => { const services = useServices(); const { kibanaGuideDocLink } = services; @@ -23,6 +26,7 @@ export const AnalyticsNoDataPage = ({ onDataViewCreated }: AnalyticsNoDataPagePr diff --git a/packages/shared-ux/page/analytics_no_data/types/index.d.ts b/packages/shared-ux/page/analytics_no_data/types/index.d.ts index 1e36aae41df77..d4021360bea23 100644 --- a/packages/shared-ux/page/analytics_no_data/types/index.d.ts +++ b/packages/shared-ux/page/analytics_no_data/types/index.d.ts @@ -47,4 +47,6 @@ export type AnalyticsNoDataPageKibanaDependencies = KibanaDependencies & export interface AnalyticsNoDataPageProps { /** Handler for successfully creating a new data view. */ onDataViewCreated: (dataView: unknown) => void; + /** if set to true allows creation of an ad-hoc data view from data view editor */ + allowAdHocDataView?: boolean; } diff --git a/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx b/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx index 73726d7b82eaa..c3fbccd3a60fb 100644 --- a/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx +++ b/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx @@ -16,7 +16,11 @@ import { useServices } from './services'; /** * A page to display when Kibana has no data, prompting a person to add integrations or create a new data view. */ -export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: KibanaNoDataPageProps) => { +export const KibanaNoDataPage = ({ + onDataViewCreated, + noDataConfig, + allowAdHocDataView, +}: KibanaNoDataPageProps) => { // These hooks are temporary, until this component is moved to a package. const services = useServices(); const { hasESData, hasUserDataView } = services; @@ -43,7 +47,12 @@ export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: KibanaNoDa } if (!hasUserDataViews && dataExists) { - return ; + return ( + + ); } if (!dataExists) { diff --git a/packages/shared-ux/page/kibana_no_data/types/index.d.ts b/packages/shared-ux/page/kibana_no_data/types/index.d.ts index 18fe5499e93c3..1cce51f372021 100644 --- a/packages/shared-ux/page/kibana_no_data/types/index.d.ts +++ b/packages/shared-ux/page/kibana_no_data/types/index.d.ts @@ -22,6 +22,8 @@ export interface Services { hasESData: () => Promise; /** True if Kibana instance contains user-created data view, false otherwise. */ hasUserDataView: () => Promise; + /** if set to true allows creation of an ad-hoc data view from data view editor */ + allowAdHocDataView?: boolean; } /** @@ -53,4 +55,6 @@ export interface KibanaNoDataPageProps { onDataViewCreated: (dataView: unknown) => void; /** `NoDataPage` configuration; see `NoDataPageProps`. */ noDataConfig: NoDataPageProps; + /** if set to true allows creation of an ad-hoc dataview from data view editor */ + allowAdHocDataView?: boolean; } diff --git a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx index 2248f1f6cc1c0..4f668a1017b28 100644 --- a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx +++ b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx @@ -24,7 +24,10 @@ type CloseDataViewEditorFn = ReturnType { +export const NoDataViewsPrompt = ({ + onDataViewCreated, + allowAdHocDataView = false, +}: NoDataViewsPromptProps) => { const { canCreateNewDataView, openDataViewEditor, dataViewsDocLink } = useServices(); const closeDataViewEditor = useRef(); @@ -54,12 +57,19 @@ export const NoDataViewsPrompt = ({ onDataViewCreated }: NoDataViewsPromptProps) onSave: (dataView) => { onDataViewCreated(dataView); }, + allowAdHocDataView, }); if (setDataViewEditorRef) { setDataViewEditorRef(ref); } - }, [canCreateNewDataView, openDataViewEditor, setDataViewEditorRef, onDataViewCreated]); + }, [ + canCreateNewDataView, + openDataViewEditor, + allowAdHocDataView, + setDataViewEditorRef, + onDataViewCreated, + ]); return ( diff --git a/packages/shared-ux/prompt/no_data_views/types/index.d.ts b/packages/shared-ux/prompt/no_data_views/types/index.d.ts index 4b428cc64ad3d..eff6ad60e2aa4 100644 --- a/packages/shared-ux/prompt/no_data_views/types/index.d.ts +++ b/packages/shared-ux/prompt/no_data_views/types/index.d.ts @@ -26,6 +26,8 @@ type DataView = unknown; interface DataViewEditorOptions { /** Handler to be invoked when the Data View Editor completes a save operation. */ onSave: (dataView: DataView) => void; + /** if set to true allows creation of an ad-hoc data view from data view editor */ + allowAdHocDataView?: boolean; } /** @@ -75,4 +77,6 @@ export interface NoDataViewsPromptComponentProps { export interface NoDataViewsPromptProps { /** Handler for successfully creating a new data view. */ onDataViewCreated: (dataView: unknown) => void; + /** if set to true allows creation of an ad-hoc data view from data view editor */ + allowAdHocDataView?: boolean; } diff --git a/src/cli_health_gateway/cli_health_gateway.ts b/src/cli_health_gateway/cli_health_gateway.ts new file mode 100644 index 0000000000000..018a47aed2a39 --- /dev/null +++ b/src/cli_health_gateway/cli_health_gateway.ts @@ -0,0 +1,26 @@ +/* + * 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 { Command } from 'commander'; +import { kibanaPackageJson } from '@kbn/utils'; +import { bootstrap } from '@kbn/health-gateway-server'; + +const program = new Command('bin/kibana-health-gateway'); + +program + .version(kibanaPackageJson.version) + .description( + 'This command starts up a health gateway server that can be ' + + 'configured to send requests to multiple Kibana instances' + ) + .option('-c, --config', 'Path to a gateway.yml configuration file') + .action(async () => { + return await bootstrap(); + }); + +program.parse(process.argv); diff --git a/src/cli_health_gateway/dev.js b/src/cli_health_gateway/dev.js new file mode 100644 index 0000000000000..ba5dbba0bbe71 --- /dev/null +++ b/src/cli_health_gateway/dev.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +require('../setup_node_env'); +require('./cli_health_gateway'); diff --git a/src/cli_health_gateway/dist.js b/src/cli_health_gateway/dist.js new file mode 100644 index 0000000000000..d1a5cd6a82944 --- /dev/null +++ b/src/cli_health_gateway/dist.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +require('../setup_node_env/dist'); +require('./cli_health_gateway'); diff --git a/src/cli_health_gateway/tsconfig.json b/src/cli_health_gateway/tsconfig.json new file mode 100644 index 0000000000000..c8ad5deb6f6d5 --- /dev/null +++ b/src/cli_health_gateway/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + }, + "include": [ + "*.js", + "*.ts", + ], + "kbn_references": [ + { "path": "../cli/tsconfig.json" }, + ] +} diff --git a/src/dev/build/tasks/bin/scripts/kibana-health-gateway b/src/dev/build/tasks/bin/scripts/kibana-health-gateway new file mode 100755 index 0000000000000..6b190ee2d82c4 --- /dev/null +++ b/src/dev/build/tasks/bin/scripts/kibana-health-gateway @@ -0,0 +1,29 @@ +#!/bin/sh +SCRIPT=$0 + +# SCRIPT may be an arbitrarily deep series of symlinks. Loop until we have the concrete path. +while [ -h "$SCRIPT" ] ; do + ls=$(ls -ld "$SCRIPT") + # Drop everything prior to -> + link=$(expr "$ls" : '.*-> \(.*\)$') + if expr "$link" : '/.*' > /dev/null; then + SCRIPT="$link" + else + SCRIPT=$(dirname "$SCRIPT")/"$link" + fi +done + +DIR="$(dirname "${SCRIPT}")/.." +CONFIG_DIR=${KBN_PATH_CONF:-"$DIR/config"} +NODE="${DIR}/node/bin/node" +test -x "$NODE" +if [ ! -x "$NODE" ]; then + echo "unable to find usable node.js executable." + exit 1 +fi + +if [ -f "${CONFIG_DIR}/node.options" ]; then + KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)" +fi + +NODE_OPTIONS="$KBN_NODE_OPTS $NODE_OPTIONS" NODE_ENV=production exec "${NODE}" "${DIR}/src/cli_health_gateway/dist" "$@" diff --git a/src/dev/build/tasks/bin/scripts/kibana-health-gateway.bat b/src/dev/build/tasks/bin/scripts/kibana-health-gateway.bat new file mode 100755 index 0000000000000..fec208990ebb0 --- /dev/null +++ b/src/dev/build/tasks/bin/scripts/kibana-health-gateway.bat @@ -0,0 +1,36 @@ +@echo off + +SETLOCAL ENABLEDELAYEDEXPANSION + +set SCRIPT_DIR=%~dp0 +for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI + +set NODE=%DIR%\node\node.exe +set NODE_ENV=production + +If Not Exist "%NODE%" ( + Echo unable to find usable node.js executable. + Exit /B 1 +) + +set CONFIG_DIR=%KBN_PATH_CONF% +If ["%KBN_PATH_CONF%"] == [] ( + set "CONFIG_DIR=%DIR%\config" +) + +IF EXIST "%CONFIG_DIR%\node.options" ( + for /F "usebackq eol=# tokens=*" %%i in ("%CONFIG_DIR%\node.options") do ( + If [!NODE_OPTIONS!] == [] ( + set "NODE_OPTIONS=%%i" + ) Else ( + set "NODE_OPTIONS=!NODE_OPTIONS! %%i" + ) + ) +) + +TITLE Health Gateway +"%NODE%" "%DIR%\src\cli_health_gateway\dist" %* + +:finally + +ENDLOCAL diff --git a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx index ea36ede0e1c9d..2292555316b82 100644 --- a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx +++ b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx @@ -56,9 +56,10 @@ export class OptionsListEmbeddableFactory public isFieldCompatible = (dataControlField: DataControlField) => { if ( - (dataControlField.field.aggregatable && dataControlField.field.type === 'string') || - dataControlField.field.type === 'boolean' || - dataControlField.field.type === 'ip' + !dataControlField.field.spec.scripted && + ((dataControlField.field.aggregatable && dataControlField.field.type === 'string') || + dataControlField.field.type === 'boolean' || + dataControlField.field.type === 'ip') ) { dataControlField.compatibleControlTypes.push(this.type); } diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index b5cabc654a3f7..4e6192d24e8eb 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -903,6 +903,13 @@ describe('SearchSource', () => { expect(Object.keys(JSON.parse(searchSourceJSON))).toEqual(['highlightAll', 'from', 'sort']); }); + test('should add pit', () => { + const pit = { id: 'flimflam', keep_alive: '1m' }; + searchSource.setField('pit', pit); + const { searchSourceJSON } = searchSource.serialize(); + expect(searchSourceJSON).toBe(JSON.stringify({ pit })); + }); + test('should serialize filters', () => { const filter = [ { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 497a247668694..fad799c7915b1 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -667,6 +667,8 @@ export class SearchSource { getConfig(UI_SETTINGS.SORT_OPTIONS) ); return addToBody(key, sort); + case 'pit': + return addToRoot(key, val); case 'aggs': if ((val as unknown) instanceof AggConfigs) { return addToBody('aggs', val.toDsl()); @@ -768,7 +770,7 @@ export class SearchSource { const { getConfig } = this.dependencies; const searchRequest = this.mergeProps(); searchRequest.body = searchRequest.body || {}; - const { body, index, query, filters, highlightAll } = searchRequest; + const { body, index, query, filters, highlightAll, pit } = searchRequest; searchRequest.indexType = this.getIndexType(index); const metaFields = getConfig(UI_SETTINGS.META_FIELDS) ?? []; @@ -911,6 +913,10 @@ export class SearchSource { delete searchRequest.highlightAll; } + if (pit) { + body.pit = pit; + } + return searchRequest; } diff --git a/src/plugins/data/common/search/search_source/types.ts b/src/plugins/data/common/search/search_source/types.ts index a583a1d1112cc..140c2dd59a59d 100644 --- a/src/plugins/data/common/search/search_source/types.ts +++ b/src/plugins/data/common/search/search_source/types.ts @@ -39,6 +39,9 @@ export interface ISearchStartSearchSource createEmpty: () => ISearchSource; } +/** + * @deprecated use {@link estypes.SortResults} instead. + */ export type EsQuerySearchAfter = [string | number, string | number]; export enum SortDirection { @@ -112,9 +115,13 @@ export interface SearchSourceFields { * {@link IndexPatternService} */ index?: DataView; - searchAfter?: EsQuerySearchAfter; timeout?: string; terminate_after?: number; + searchAfter?: estypes.SortResults; + /** + * Allow querying to use a point-in-time ID for paging results + */ + pit?: estypes.SearchPointInTimeReference; parent?: SearchSourceFields; } @@ -160,7 +167,7 @@ export type SerializedSearchSourceFields = { * {@link IndexPatternService} */ index?: string | DataViewSpec; - searchAfter?: EsQuerySearchAfter; + searchAfter?: estypes.SortResults; timeout?: string; terminate_after?: number; diff --git a/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts b/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts index 73a3b58704877..b2aed5804f248 100644 --- a/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts @@ -35,13 +35,17 @@ export const esSearchStrategyProvider = ( throw new KbnServerError(`Unsupported index pattern type ${request.indexType}`, 400); } + const isPit = request.params?.body?.pit != null; + const search = async () => { try { const config = await firstValueFrom(config$); // @ts-expect-error params fall back to any, but should be valid SearchRequest params const { terminateAfter, ...requestParams } = request.params ?? {}; + const defaults = await getDefaultSearchParams(uiSettingsClient, { isPit }); + const params = { - ...(await getDefaultSearchParams(uiSettingsClient)), + ...defaults, ...getShardTimeout(config), ...(terminateAfter ? { terminate_after: terminateAfter } : {}), ...requestParams, diff --git a/src/plugins/data/server/search/strategies/es_search/request_utils.ts b/src/plugins/data/server/search/strategies/es_search/request_utils.ts index 2418ccfb49a0c..11fd271902e1f 100644 --- a/src/plugins/data/server/search/strategies/es_search/request_utils.ts +++ b/src/plugins/data/server/search/strategies/es_search/request_utils.ts @@ -18,19 +18,29 @@ export function getShardTimeout( } export async function getDefaultSearchParams( - uiSettingsClient: Pick + uiSettingsClient: Pick, + options = { isPit: false } ): Promise<{ max_concurrent_shard_requests?: number; - ignore_unavailable: boolean; + ignore_unavailable?: boolean; track_total_hits: boolean; }> { const maxConcurrentShardRequests = await uiSettingsClient.get( UI_SETTINGS.COURIER_MAX_CONCURRENT_SHARD_REQUESTS ); - return { + + const defaults: Awaited> = { max_concurrent_shard_requests: maxConcurrentShardRequests > 0 ? maxConcurrentShardRequests : undefined, - ignore_unavailable: true, // Don't fail if the index/indices don't exist track_total_hits: true, }; + + // If the request has a point-in-time ID attached, it can not include ignore_unavailable from {@link estypes.IndicesOptions}. + // ES will reject the request as that option was set when the point-in-time was created. + // Otherwise, this option allows search to not fail when the index/indices don't exist + if (!options.isPit) { + defaults.ignore_unavailable = true; + } + + return defaults; } diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 6cfe2c31fa0c1..2a16d33775397 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -195,6 +195,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboard.clearUnsavedChanges(); }); + it('cannot create options list for scripted field', async () => { + expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( + 'animals-*' + ); + await dashboardControls.openCreateControlFlyout(); + await testSubjects.missingOrFail('field-picker-select-isDog'); + await dashboardControls.controlEditorCancel(true); + }); + after(async () => { await dashboardControls.clearAllControls(); }); diff --git a/test/package/deb.yml b/test/package/deb.yml index 030fbad39b167..b6674e996a7e9 100644 --- a/test/package/deb.yml +++ b/test/package/deb.yml @@ -7,6 +7,7 @@ - assert_encryption_keys_cli - assert_plugin_cli - assert_setup_cli + - assert_health_gateway_cli - assert_verification_code_cli - assert_kibana_yml - assert_kibana_listening diff --git a/test/package/roles/assert_health_gateway_cli/tasks/main.yml b/test/package/roles/assert_health_gateway_cli/tasks/main.yml new file mode 100644 index 0000000000000..0f873e4e9868f --- /dev/null +++ b/test/package/roles/assert_health_gateway_cli/tasks/main.yml @@ -0,0 +1,13 @@ +- name: "--help" + become: true + command: + cmd: /usr/share/kibana/bin/kibana-health-gateway --help + register: health_gateway_help + +- debug: + msg: "{{ health_gateway_help }}" + +- name: assert health-gateway provides help + assert: + that: + - health_gateway_help.failed == false diff --git a/test/package/rpm.yml b/test/package/rpm.yml index f717b38797123..6c5bb5b845adf 100644 --- a/test/package/rpm.yml +++ b/test/package/rpm.yml @@ -7,6 +7,7 @@ - assert_encryption_keys_cli - assert_plugin_cli - assert_setup_cli + - assert_health_gateway_cli - assert_verification_code_cli - assert_kibana_yml - assert_kibana_listening diff --git a/tsconfig.base.json b/tsconfig.base.json index b5372e27d631c..fa549137e0609 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -476,6 +476,8 @@ "@kbn/handlebars/*": ["packages/kbn-handlebars/*"], "@kbn/hapi-mocks": ["packages/kbn-hapi-mocks"], "@kbn/hapi-mocks/*": ["packages/kbn-hapi-mocks/*"], + "@kbn/health-gateway-server": ["packages/kbn-health-gateway-server"], + "@kbn/health-gateway-server/*": ["packages/kbn-health-gateway-server/*"], "@kbn/i18n": ["packages/kbn-i18n"], "@kbn/i18n/*": ["packages/kbn-i18n/*"], "@kbn/i18n-react": ["packages/kbn-i18n-react"], diff --git a/tsconfig.json b/tsconfig.json index a03576565d124..0e1d602ee945a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,4 +10,4 @@ "kbn_references": [ { "path": "./src/core/tsconfig.json" }, ] -} +} \ No newline at end of file diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts index 01b7876d69afd..5b8a9fdcbf1c4 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -146,9 +146,9 @@ export abstract class SubActionConnector { `Request to external service failed. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type}. Method: ${error.config.method}. URL: ${error.config.url}` ); - const errorMessage = `Status code: ${error.status}. Message: ${this.getResponseErrorMessage( - error - )}`; + const errorMessage = `Status code: ${ + error.status ?? error.response?.status + }. Message: ${this.getResponseErrorMessage(error)}`; throw new Error(errorMessage); } diff --git a/x-pack/plugins/alerting/common/alert_summary.ts b/x-pack/plugins/alerting/common/alert_summary.ts index fc35e3403fe92..f9675e64a7f95 100644 --- a/x-pack/plugins/alerting/common/alert_summary.ts +++ b/x-pack/plugins/alerting/common/alert_summary.ts @@ -36,4 +36,5 @@ export interface AlertStatus { muted: boolean; actionGroupId?: string; activeStartDate?: string; + flapping: boolean; } diff --git a/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.test.ts b/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.test.ts index 56a862f2ad6ca..3bf01caaead1a 100644 --- a/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.test.ts +++ b/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.test.ts @@ -122,12 +122,14 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": true, "status": "OK", }, "alert-2": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": true, "status": "OK", }, @@ -232,6 +234,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, @@ -272,6 +275,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, @@ -311,6 +315,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, @@ -351,6 +356,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group A", "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": false, "status": "Active", }, @@ -391,6 +397,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": false, "status": "Active", }, @@ -431,6 +438,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group B", "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": false, "status": "Active", }, @@ -469,6 +477,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group A", "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "Active", }, @@ -511,12 +520,14 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group A", "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": true, "status": "Active", }, "alert-2": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": true, "status": "OK", }, @@ -566,12 +577,14 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group B", "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": false, "status": "Active", }, "alert-2": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, @@ -584,6 +597,43 @@ describe('alertSummaryFromEventLog', () => { testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); + test('rule with currently active alert, flapping', async () => { + const rule = createRule({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addActiveAlert('alert-1', 'action group A', true) + .getEvents(); + + const executionEvents = eventsFactory.getEvents(); + + const summary: AlertSummary = alertSummaryFromEventLog({ + rule, + events, + executionEvents, + dateStart, + dateEnd, + }); + const { lastRun, status, alerts, executionDuration } = summary; + expect({ lastRun, status, alerts }).toMatchInlineSnapshot(` + Object { + "alerts": Object { + "alert-1": Object { + "actionGroupId": "action group A", + "activeStartDate": undefined, + "flapping": true, + "muted": false, + "status": "Active", + }, + }, + "lastRun": "2020-06-18T00:00:00.000Z", + "status": "Active", + } + `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); + }); + const testExecutionDurations = ( actualDurations: Record, executionDuration?: { @@ -642,7 +692,11 @@ export class EventsFactory { return this; } - addActiveAlert(alertId: string, actionGroupId: string | undefined): EventsFactory { + addActiveAlert( + alertId: string, + actionGroupId: string | undefined, + flapping = false + ): EventsFactory { const kibanaAlerting = actionGroupId ? { instance_id: alertId, action_group_id: actionGroupId } : { instance_id: alertId }; @@ -652,7 +706,7 @@ export class EventsFactory { provider: EVENT_LOG_PROVIDER, action: EVENT_LOG_ACTIONS.activeInstance, }, - kibana: { alerting: kibanaAlerting }, + kibana: { alerting: kibanaAlerting, alert: { flapping } }, }); return this; } diff --git a/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.ts b/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.ts index d8e5f4dea9b41..f1aedf078800f 100644 --- a/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.ts +++ b/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.ts @@ -80,6 +80,11 @@ export function alertSummaryFromEventLog(params: AlertSummaryFromEventLogParams) if (alertId === undefined) continue; const status = getAlertStatus(alerts, alertId); + + if (event?.kibana?.alert?.flapping) { + status.flapping = true; + } + switch (action) { case EVENT_LOG_ACTIONS.newInstance: status.activeStartDate = timeStamp; @@ -152,6 +157,7 @@ function getAlertStatus(alerts: Map, alertId: string): Aler muted: false, actionGroupId: undefined, activeStartDate: undefined, + flapping: false, }; alerts.set(alertId, status); return status; 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 3dea32f4f45a4..7af3c963814f6 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 @@ -66,6 +66,7 @@ const alert = { end: '2020-01-01T03:00:00.000Z', duration: '2343252346', }, + flapping: false, }; const action = { diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts index 4fd3afafba5c2..3422fb21bb1f9 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts @@ -48,6 +48,7 @@ interface AlertOpts { message: string; group?: string; state?: AlertInstanceState; + flapping: boolean; } interface ActionOpts { @@ -247,6 +248,7 @@ export function createAlertRecord(context: RuleContextOpts, alert: AlertOpts) { }, ], ruleName: context.ruleName, + flapping: alert.flapping, }); } diff --git a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts index a0f229c0b46d9..ea74cab5d11cf 100644 --- a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts +++ b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts @@ -35,6 +35,7 @@ interface CreateAlertEventLogRecordParams { typeId: string; relation?: string; }>; + flapping?: boolean; } export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecordParams): Event { @@ -50,6 +51,7 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor namespace, consumer, spaceId, + flapping, } = params; const alerting = params.instanceId || group @@ -72,6 +74,7 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor }, kibana: { alert: { + ...(flapping !== undefined ? { flapping } : {}), rule: { rule_type_id: ruleType.id, ...(consumer ? { consumer } : {}), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts index 4aa7ae40f8782..f0f634538d6f7 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts @@ -128,7 +128,7 @@ describe('getAlertSummary()', () => { .advanceTime(10000) .addExecute() .addRecoveredAlert('alert-previously-active') - .addActiveAlert('alert-currently-active', 'action group A') + .addActiveAlert('alert-currently-active', 'action group A', true) .getEvents(); const eventsResult = { ...AlertSummaryFindEventsResult, @@ -157,18 +157,21 @@ describe('getAlertSummary()', () => { "alert-currently-active": Object { "actionGroupId": "action group A", "activeStartDate": "2019-02-12T21:01:22.479Z", + "flapping": true, "muted": false, "status": "Active", }, "alert-muted-no-activity": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": true, "status": "OK", }, "alert-previously-active": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts deleted file mode 100644 index 716f0fefef3e4..0000000000000 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ /dev/null @@ -1,595 +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 { createExecutionHandler } from './create_execution_handler'; -import { CreateExecutionHandlerOptions } from './types'; -import { loggingSystemMock } from '@kbn/core/server/mocks'; -import { - actionsClientMock, - actionsMock, - renderActionParameterTemplatesDefault, -} from '@kbn/actions-plugin/server/mocks'; -import { KibanaRequest } from '@kbn/core/server'; -import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; -import { InjectActionParamsOpts } from './inject_action_params'; -import { NormalizedRuleType } from '../rule_type_registry'; -import { - ActionsCompletion, - AlertInstanceContext, - AlertInstanceState, - RuleTypeParams, - RuleTypeState, -} from '../types'; -import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; -import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; - -jest.mock('./inject_action_params', () => ({ - injectActionParams: jest.fn(), -})); - -const alertingEventLogger = alertingEventLoggerMock.create(); - -const ruleType: NormalizedRuleType< - RuleTypeParams, - RuleTypeParams, - RuleTypeState, - AlertInstanceState, - AlertInstanceContext, - 'default' | 'other-group', - 'recovered' -> = { - id: 'test', - name: 'Test', - actionGroups: [ - { id: 'default', name: 'Default' }, - { id: 'other-group', name: 'Other Group' }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - recoveryActionGroup: { - id: 'recovered', - name: 'Recovered', - }, - executor: jest.fn(), - producer: 'alerts', -}; - -const actionsClient = actionsClientMock.create(); - -const mockActionsPlugin = actionsMock.createStart(); -const createExecutionHandlerParams: jest.Mocked< - CreateExecutionHandlerOptions< - RuleTypeParams, - RuleTypeParams, - RuleTypeState, - AlertInstanceState, - AlertInstanceContext, - 'default' | 'other-group', - 'recovered' - > -> = { - actionsPlugin: mockActionsPlugin, - spaceId: 'test1', - ruleId: '1', - ruleName: 'name-of-alert', - ruleConsumer: 'rule-consumer', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - tags: ['tag-A', 'tag-B'], - apiKey: 'MTIzOmFiYw==', - kibanaBaseUrl: 'http://localhost:5601', - ruleType, - logger: loggingSystemMock.create().get(), - alertingEventLogger, - actions: [ - { - id: '1', - group: 'default', - actionTypeId: 'test', - params: { - foo: true, - contextVal: 'My {{context.value}} goes here', - stateVal: 'My {{state.value}} goes here', - alertVal: 'My {{alertId}} {{alertName}} {{spaceId}} {{tags}} {{alertInstanceId}} goes here', - }, - }, - ], - request: {} as KibanaRequest, - ruleParams: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - supportsEphemeralTasks: false, - maxEphemeralActionsPerRule: 10, - actionsConfigMap: { - default: { - max: 1000, - }, - }, -}; -let ruleRunMetricsStore: RuleRunMetricsStore; - -describe('Create Execution Handler', () => { - beforeEach(() => { - jest.resetAllMocks(); - jest - .requireMock('./inject_action_params') - .injectActionParams.mockImplementation( - ({ actionParams }: InjectActionParamsOpts) => actionParams - ); - mockActionsPlugin.isActionTypeEnabled.mockReturnValue(true); - mockActionsPlugin.isActionExecutable.mockReturnValue(true); - mockActionsPlugin.getActionsClientWithRequest.mockResolvedValue(actionsClient); - mockActionsPlugin.renderActionParameterTemplates.mockImplementation( - renderActionParameterTemplatesDefault - ); - ruleRunMetricsStore = new RuleRunMetricsStore(); - }); - - test('enqueues execution per selected action', async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - actionGroup: 'default', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); - expect(mockActionsPlugin.getActionsClientWithRequest).toHaveBeenCalledWith( - createExecutionHandlerParams.request - ); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - 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 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", - }, - ], - ] - `); - - expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); - expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { - id: '1', - typeId: 'test', - alertId: '2', - alertGroup: 'default', - }); - - expect(jest.requireMock('./inject_action_params').injectActionParams).toHaveBeenCalledWith({ - ruleId: '1', - spaceId: 'test1', - actionTypeId: 'test', - actionParams: { - alertVal: 'My 1 name-of-alert test1 tag-A,tag-B 2 goes here', - contextVal: 'My goes here', - foo: true, - stateVal: 'My goes here', - }, - }); - - expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); - }); - - test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => { - // Mock two calls, one for check against actions[0] and the second for actions[1] - mockActionsPlugin.isActionExecutable.mockReturnValueOnce(false); - mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(false); - mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(true); - const executionHandler = createExecutionHandler({ - ...createExecutionHandlerParams, - actions: [ - { - id: '2', - group: 'default', - actionTypeId: 'test2', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - { - id: '2', - group: 'default', - actionTypeId: 'test2', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - ], - }); - await executionHandler({ - actionGroup: 'default', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith([ - { - consumer: 'rule-consumer', - id: '2', - params: { - foo: true, - contextVal: 'My other goes here', - stateVal: 'My other goes here', - }, - source: asSavedObjectExecutionSource({ - id: '1', - type: 'alert', - }), - relatedSavedObjects: [ - { - id: '1', - namespace: 'test1', - type: 'alert', - typeId: 'test', - }, - ], - spaceId: 'test1', - apiKey: createExecutionHandlerParams.apiKey, - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - ]); - }); - - test('trow error error message when action type is disabled', async () => { - mockActionsPlugin.preconfiguredActions = []; - mockActionsPlugin.isActionExecutable.mockReturnValue(false); - mockActionsPlugin.isActionTypeEnabled.mockReturnValue(false); - const executionHandler = createExecutionHandler({ - ...createExecutionHandlerParams, - actions: [ - { - id: '1', - group: 'default', - actionTypeId: '.slack', - params: { - foo: true, - }, - }, - { - id: '2', - group: 'default', - actionTypeId: '.slack', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - ], - }); - - await executionHandler({ - actionGroup: 'default', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); - - mockActionsPlugin.isActionExecutable.mockImplementation(() => true); - const executionHandlerForPreconfiguredAction = createExecutionHandler({ - ...createExecutionHandlerParams, - actions: [...createExecutionHandlerParams.actions], - }); - await executionHandlerForPreconfiguredAction({ - actionGroup: 'default', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - }); - - test('limits actionsPlugin.execute per action group', async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - actionGroup: 'other-group', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); - expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); - }); - - test('context attribute gets parameterized', async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - actionGroup: 'default', - context: { value: 'context-val' }, - state: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - 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", - }, - ], - ] - `); - }); - - test('state attribute gets parameterized', async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - actionGroup: 'default', - context: {}, - state: { value: 'state-val' }, - alertId: '2', - ruleRunMetricsStore, - }); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - 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", - }, - ], - ] - `); - }); - - test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - // we have to trick the compiler as this is an invalid type and this test checks whether we - // enforce this at runtime as well as compile time - actionGroup: 'invalid-group' as 'default' | 'other-group', - context: {}, - state: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(createExecutionHandlerParams.logger.error).toHaveBeenCalledWith( - 'Invalid action group "invalid-group" for rule "test".' - ); - - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); - expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); - }); - - test('Stops triggering actions when the number of total triggered actions is reached the number of max executable actions', async () => { - const executionHandler = createExecutionHandler({ - ...createExecutionHandlerParams, - actionsConfigMap: { - default: { - max: 2, - }, - }, - actions: [ - { - id: '1', - group: 'default', - actionTypeId: 'test2', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - { - id: '2', - group: 'default', - actionTypeId: 'test2', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - { - id: '3', - group: 'default', - actionTypeId: 'test3', - params: { - foo: true, - contextVal: '{{context.value}} goes here', - stateVal: '{{state.value}} goes here', - }, - }, - ], - }); - - ruleRunMetricsStore = new RuleRunMetricsStore(); - - await executionHandler({ - actionGroup: 'default', - context: {}, - state: { value: 'state-val' }, - alertId: '2', - ruleRunMetricsStore, - }); - - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(2); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(3); - expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); - expect(createExecutionHandlerParams.logger.debug).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - }); - - test('Skips triggering actions for a specific action type when it reaches the limit for that specific action type', async () => { - const executionHandler = createExecutionHandler({ - ...createExecutionHandlerParams, - actionsConfigMap: { - default: { - max: 4, - }, - 'test-action-type-id': { - max: 1, - }, - }, - actions: [ - ...createExecutionHandlerParams.actions, - { - id: '2', - group: 'default', - actionTypeId: 'test-action-type-id', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - { - id: '3', - group: 'default', - actionTypeId: 'test-action-type-id', - params: { - foo: true, - contextVal: '{{context.value}} goes here', - stateVal: '{{state.value}} goes here', - }, - }, - { - id: '4', - group: 'default', - actionTypeId: 'another-action-type-id', - params: { - foo: true, - contextVal: '{{context.value}} goes here', - stateVal: '{{state.value}} goes here', - }, - }, - { - id: '5', - group: 'default', - actionTypeId: 'another-action-type-id', - params: { - foo: true, - contextVal: '{{context.value}} goes here', - stateVal: '{{state.value}} goes here', - }, - }, - ], - }); - - ruleRunMetricsStore = new RuleRunMetricsStore(); - - await executionHandler({ - actionGroup: 'default', - context: {}, - state: { value: 'state-val' }, - alertId: '2', - ruleRunMetricsStore, - }); - - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(4); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(5); - expect(ruleRunMetricsStore.getStatusByConnectorType('test').numberOfTriggeredActions).toBe(1); - expect( - ruleRunMetricsStore.getStatusByConnectorType('test-action-type-id').numberOfTriggeredActions - ).toBe(1); - expect( - ruleRunMetricsStore.getStatusByConnectorType('another-action-type-id') - .numberOfTriggeredActions - ).toBe(2); - expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts deleted file mode 100644 index 51ba1404b2a4f..0000000000000 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ /dev/null @@ -1,215 +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 { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; -import { isEphemeralTaskRejectedDueToCapacityError } from '@kbn/task-manager-plugin/server'; -import { chunk } from 'lodash'; -import { transformActionParams } from './transform_action_params'; -import { injectActionParams } from './inject_action_params'; -import { - ActionsCompletion, - AlertInstanceContext, - AlertInstanceState, - RuleTypeParams, - RuleTypeState, -} from '../types'; -import { CreateExecutionHandlerOptions, ExecutionHandlerOptions } from './types'; - -export type ExecutionHandler = ( - options: ExecutionHandlerOptions -) => Promise; - -export function createExecutionHandler< - Params extends RuleTypeParams, - ExtractedParams extends RuleTypeParams, - State extends RuleTypeState, - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string ->({ - logger, - ruleId, - ruleName, - ruleConsumer, - executionId, - tags, - actionsPlugin, - actions: ruleActions, - spaceId, - apiKey, - ruleType, - kibanaBaseUrl, - alertingEventLogger, - request, - ruleParams, - supportsEphemeralTasks, - maxEphemeralActionsPerRule, - actionsConfigMap, -}: CreateExecutionHandlerOptions< - Params, - ExtractedParams, - State, - InstanceState, - InstanceContext, - ActionGroupIds, - RecoveryActionGroupId ->): ExecutionHandler { - const ruleTypeActionGroups = new Map( - ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) - ); - const CHUNK_SIZE = 1000; - - return async ({ - actionGroup, - context, - state, - ruleRunMetricsStore, - alertId, - }: ExecutionHandlerOptions) => { - if (!ruleTypeActionGroups.has(actionGroup)) { - logger.error(`Invalid action group "${actionGroup}" for rule "${ruleType.id}".`); - return; - } - - const actions = ruleActions - .filter(({ group }) => group === actionGroup) - .map((action) => { - return { - ...action, - params: transformActionParams({ - actionsPlugin, - alertId: ruleId, - alertType: ruleType.id, - actionTypeId: action.actionTypeId, - alertName: ruleName, - spaceId, - tags, - alertInstanceId: alertId, - alertActionGroup: actionGroup, - alertActionGroupName: ruleTypeActionGroups.get(actionGroup)!, - context, - actionParams: action.params, - actionId: action.id, - state, - kibanaBaseUrl, - alertParams: ruleParams, - }), - }; - }) - .map((action) => ({ - ...action, - params: injectActionParams({ - ruleId, - spaceId, - actionParams: action.params, - actionTypeId: action.actionTypeId, - }), - })); - - ruleRunMetricsStore.incrementNumberOfGeneratedActions(actions.length); - - const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); - let ephemeralActionsToSchedule = maxEphemeralActionsPerRule; - - const bulkActions = []; - const logActions = []; - for (const action of actions) { - const { actionTypeId } = action; - - ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); - - if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - logger.debug( - `Rule "${ruleId}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` - ); - break; - } - - if ( - ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ - actionTypeId, - actionsConfigMap, - }) - ) { - if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { - logger.debug( - `Rule "${ruleId}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` - ); - } - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - continue; - } - - if (!actionsPlugin.isActionExecutable(action.id, actionTypeId, { notifyUsage: true })) { - logger.warn( - `Rule "${ruleId}" skipped scheduling action "${action.id}" because it is disabled` - ); - continue; - } - - ruleRunMetricsStore.incrementNumberOfTriggeredActions(); - ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); - - const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; - - const enqueueOptions = { - id: action.id, - params: action.params, - spaceId, - apiKey: apiKey ?? null, - consumer: ruleConsumer, - source: asSavedObjectExecutionSource({ - id: ruleId, - type: 'alert', - }), - executionId, - relatedSavedObjects: [ - { - id: ruleId, - type: 'alert', - namespace: namespace.namespace, - typeId: ruleType.id, - }, - ], - }; - - if (supportsEphemeralTasks && ephemeralActionsToSchedule > 0) { - ephemeralActionsToSchedule--; - try { - await actionsClient.ephemeralEnqueuedExecution(enqueueOptions); - } catch (err) { - if (isEphemeralTaskRejectedDueToCapacityError(err)) { - bulkActions.push(enqueueOptions); - } - } - } else { - bulkActions.push(enqueueOptions); - } - logActions.push({ - id: action.id, - typeId: actionTypeId, - alertId, - alertGroup: actionGroup, - }); - } - - for (const c of chunk(bulkActions, CHUNK_SIZE)) { - await actionsClient.bulkEnqueueExecution(c); - } - - for (const action of logActions) { - alertingEventLogger.logAction(action); - } - }; -} 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 new file mode 100644 index 0000000000000..c75107c52cb81 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts @@ -0,0 +1,732 @@ +/* + * 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 { ExecutionHandler } from './execution_handler'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { + actionsClientMock, + actionsMock, + renderActionParameterTemplatesDefault, +} from '@kbn/actions-plugin/server/mocks'; +import { KibanaRequest } from '@kbn/core/server'; +import { InjectActionParamsOpts } from './inject_action_params'; +import { NormalizedRuleType } from '../rule_type_registry'; +import { ActionsCompletion, RuleTypeParams, RuleTypeState, SanitizedRule } from '../types'; +import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; +import { TaskRunnerContext } from './task_runner_factory'; +import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; +import { Alert } from '../alert'; +import { AlertInstanceState, AlertInstanceContext } from '../../common'; +import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; +import sinon from 'sinon'; + +jest.mock('./inject_action_params', () => ({ + injectActionParams: jest.fn(), +})); + +const alertingEventLogger = alertingEventLoggerMock.create(); +const actionsClient = actionsClientMock.create(); +const mockActionsPlugin = actionsMock.createStart(); +const apiKey = Buffer.from('123:abc').toString('base64'); +const ruleType: NormalizedRuleType< + RuleTypeParams, + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + 'default' | 'other-group', + 'recovered' +> = { + id: 'test', + name: 'Test', + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'recovered', name: 'Recovered' }, + { id: 'other-group', name: 'Other Group' }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: { + id: 'recovered', + name: 'Recovered', + }, + executor: jest.fn(), + producer: 'alerts', +}; +const rule = { + id: '1', + name: 'name-of-alert', + tags: ['tag-A', 'tag-B'], + mutedInstanceIds: [], + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + actions: [ + { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: 'My {{alertId}} {{alertName}} {{spaceId}} {{tags}} {{alertInstanceId}} goes here', + }, + }, + ], +} as unknown as SanitizedRule; + +const defaultExecutionParams = { + rule, + ruleType, + logger: loggingSystemMock.create().get(), + taskRunnerContext: { + actionsConfigMap: { + default: { + max: 1000, + }, + }, + actionsPlugin: mockActionsPlugin, + } as unknown as TaskRunnerContext, + apiKey, + ruleConsumer: 'rule-consumer', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleLabel: 'rule-label', + request: {} as KibanaRequest, + alertingEventLogger, + taskInstance: { + params: { spaceId: 'test1', alertId: '1' }, + } as unknown as ConcreteTaskInstance, + actionsClient, +}; + +let ruleRunMetricsStore: RuleRunMetricsStore; +let clock: sinon.SinonFakeTimers; +type ActionGroup = 'default' | 'other-group' | 'recovered'; +const generateAlert = ({ + id, + group = 'default', + context, + state, + scheduleActions = true, +}: { + id: number; + group?: ActionGroup; + context?: AlertInstanceContext; + state?: AlertInstanceState; + scheduleActions?: boolean; +}) => { + const alert = new Alert< + AlertInstanceState, + AlertInstanceContext, + 'default' | 'other-group' | 'recovered' + >(String(id), { + state: state || { test: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group, + }, + }, + }); + if (scheduleActions) { + alert.scheduleActions(group); + } + if (context) { + alert.setContext(context); + } + return { [id]: alert }; +}; + +// @ts-ignore +const generateExecutionParams = (params = {}) => { + return { + ...defaultExecutionParams, + ...params, + ruleRunMetricsStore, + }; +}; + +describe('Execution Handler', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest + .requireMock('./inject_action_params') + .injectActionParams.mockImplementation( + ({ actionParams }: InjectActionParamsOpts) => actionParams + ); + mockActionsPlugin.isActionTypeEnabled.mockReturnValue(true); + mockActionsPlugin.isActionExecutable.mockReturnValue(true); + mockActionsPlugin.getActionsClientWithRequest.mockResolvedValue(actionsClient); + mockActionsPlugin.renderActionParameterTemplates.mockImplementation( + renderActionParameterTemplatesDefault + ); + ruleRunMetricsStore = new RuleRunMetricsStore(); + }); + beforeAll(() => { + clock = sinon.useFakeTimers(); + }); + afterAll(() => clock.restore()); + + test('enqueues execution per selected action', async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run(generateAlert({ id: 1 })); + + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + 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", + }, + ], + ] + `); + + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { + id: '1', + typeId: 'test', + alertId: '1', + alertGroup: 'default', + }); + + expect(jest.requireMock('./inject_action_params').injectActionParams).toHaveBeenCalledWith({ + ruleId: '1', + spaceId: 'test1', + actionTypeId: 'test', + actionParams: { + alertVal: 'My 1 name-of-alert test1 tag-A,tag-B 1 goes here', + contextVal: 'My goes here', + foo: true, + stateVal: 'My goes here', + }, + }); + + expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); + }); + + test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => { + // Mock two calls, one for check against actions[0] and the second for actions[1] + mockActionsPlugin.isActionExecutable.mockReturnValueOnce(false); + mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(false); + mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(true); + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + rule: { + ...defaultExecutionParams.rule, + actions: [ + { + id: '2', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '2', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + ], + }, + }) + ); + + await executionHandler.run(generateAlert({ id: 1 })); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith([ + { + consumer: 'rule-consumer', + id: '2', + params: { + foo: true, + contextVal: 'My other goes here', + stateVal: 'My other goes here', + }, + source: asSavedObjectExecutionSource({ + id: '1', + type: 'alert', + }), + relatedSavedObjects: [ + { + id: '1', + namespace: 'test1', + type: 'alert', + typeId: 'test', + }, + ], + spaceId: 'test1', + apiKey, + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + ]); + }); + + test('trow error error message when action type is disabled', async () => { + mockActionsPlugin.preconfiguredActions = []; + mockActionsPlugin.isActionExecutable.mockReturnValue(false); + mockActionsPlugin.isActionTypeEnabled.mockReturnValue(false); + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + rule: { + ...defaultExecutionParams.rule, + actions: [ + { + id: '1', + group: 'default', + actionTypeId: '.slack', + params: { + foo: true, + }, + }, + { + id: '2', + group: 'default', + actionTypeId: '.slack', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + ], + }, + }) + ); + + await executionHandler.run(generateAlert({ id: 2 })); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); + + mockActionsPlugin.isActionExecutable.mockImplementation(() => true); + const executionHandlerForPreconfiguredAction = new ExecutionHandler({ + ...defaultExecutionParams, + ruleRunMetricsStore, + }); + + await executionHandlerForPreconfiguredAction.run(generateAlert({ id: 2 })); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + }); + + test('limits actionsPlugin.execute per action group', async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run(generateAlert({ id: 2, group: 'other-group' })); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); + expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); + }); + + test('context attribute gets parameterized', async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run(generateAlert({ id: 2, context: { value: 'context-val' } })); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + 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", + }, + ], + ] + `); + }); + + test('state attribute gets parameterized', async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + 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", + }, + ], + ] + `); + }); + + test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run( + generateAlert({ id: 2, group: 'invalid-group' as 'default' | 'other-group' }) + ); + expect(defaultExecutionParams.logger.error).toHaveBeenCalledWith( + 'Invalid action group "invalid-group" for rule "test".' + ); + + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); + expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); + }); + + test('Stops triggering actions when the number of total triggered actions is reached the number of max executable actions', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + taskRunnerContext: { + ...defaultExecutionParams.taskRunnerContext, + actionsConfigMap: { + default: { + max: 2, + }, + }, + }, + rule: { + ...defaultExecutionParams.rule, + actions: [ + { + id: '1', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '2', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '3', + group: 'default', + actionTypeId: 'test3', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + ], + }, + }) + ); + 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('Skips triggering actions for a specific action type when it reaches the limit for that specific action type', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + taskRunnerContext: { + ...defaultExecutionParams.taskRunnerContext, + actionsConfigMap: { + default: { + max: 4, + }, + 'test-action-type-id': { + max: 1, + }, + }, + }, + rule: { + ...defaultExecutionParams.rule, + actions: [ + ...defaultExecutionParams.rule.actions, + { + id: '2', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '3', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + { + id: '4', + group: 'default', + actionTypeId: 'another-action-type-id', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + { + id: '5', + group: 'default', + actionTypeId: 'another-action-type-id', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + ], + }, + }) + ); + await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(4); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(5); + expect(ruleRunMetricsStore.getStatusByConnectorType('test').numberOfTriggeredActions).toBe(1); + expect( + ruleRunMetricsStore.getStatusByConnectorType('test-action-type-id').numberOfTriggeredActions + ).toBe(1); + expect( + ruleRunMetricsStore.getStatusByConnectorType('another-action-type-id') + .numberOfTriggeredActions + ).toBe(2); + expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + }); + + test('schedules alerts with recovered actions', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + actions: [ + { + id: '1', + group: 'recovered', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{alertId}} {{alertName}} {{spaceId}} {{tags}} {{alertInstanceId}} goes here', + }, + }, + ], + }, + }) + ); + await executionHandler.run(generateAlert({ id: 1, scheduleActions: false }), true); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + 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", + }, + ], + ] + `); + }); + + test('does not schedule alerts with recovered actions that are muted', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + mutedInstanceIds: ['1'], + actions: [ + { + id: '1', + group: 'recovered', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{alertId}} {{alertName}} {{spaceId}} {{tags}} {{alertInstanceId}} goes here', + }, + }, + ], + }, + }) + ); + await executionHandler.run(generateAlert({ id: 1, scheduleActions: false }), true); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); + expect(defaultExecutionParams.logger.debug).nthCalledWith( + 1, + `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is muted` + ); + }); + + test('does not schedule active alerts that are throttled', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + throttle: '1m', + }, + }) + ); + await executionHandler.run(generateAlert({ id: 1 })); + + clock.tick(30000); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); + expect(defaultExecutionParams.logger.debug).nthCalledWith( + 1, + `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is throttled` + ); + }); + + test('does not schedule active alerts that are muted', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + mutedInstanceIds: ['1'], + }, + }) + ); + await executionHandler.run(generateAlert({ id: 1 })); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); + expect(defaultExecutionParams.logger.debug).nthCalledWith( + 1, + `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is muted` + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts new file mode 100644 index 0000000000000..2d72bcd284a66 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -0,0 +1,410 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import { Logger } from '@kbn/core/server'; +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 { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; +import { chunk } from 'lodash'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; +import { RawRule } from '../types'; +import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { injectActionParams } from './inject_action_params'; +import { ExecutionHandlerOptions, RuleTaskInstance } from './types'; +import { TaskRunnerContext } from './task_runner_factory'; +import { transformActionParams } from './transform_action_params'; +import { Alert } from '../alert'; +import { NormalizedRuleType } from '../rule_type_registry'; +import { + ActionsCompletion, + AlertInstanceContext, + AlertInstanceState, + RuleAction, + RuleTypeParams, + RuleTypeState, + SanitizedRule, +} from '../../common'; + +enum Reasons { + MUTED = 'muted', + THROTTLED = 'throttled', + ACTION_GROUP_NOT_CHANGED = 'actionGroupHasNotChanged', +} + +export class ExecutionHandler< + Params extends RuleTypeParams, + ExtractedParams extends RuleTypeParams, + RuleState extends RuleTypeState, + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + private logger: Logger; + private alertingEventLogger: PublicMethodsOf; + private rule: SanitizedRule; + private ruleType: NormalizedRuleType< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId + >; + private taskRunnerContext: TaskRunnerContext; + private taskInstance: RuleTaskInstance; + private ruleRunMetricsStore: RuleRunMetricsStore; + private apiKey: RawRule['apiKey']; + private ruleConsumer: string; + private executionId: string; + private ruleLabel: string; + private ephemeralActionsToSchedule: number; + private CHUNK_SIZE = 1000; + private skippedAlerts: { [key: string]: { reason: string } } = {}; + private actionsClient: PublicMethodsOf; + private ruleTypeActionGroups?: Map; + private mutedAlertIdsSet?: Set; + + constructor({ + rule, + ruleType, + logger, + alertingEventLogger, + taskRunnerContext, + taskInstance, + ruleRunMetricsStore, + apiKey, + ruleConsumer, + executionId, + ruleLabel, + actionsClient, + }: ExecutionHandlerOptions< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId + >) { + this.logger = logger; + this.alertingEventLogger = alertingEventLogger; + this.rule = rule; + this.ruleType = ruleType; + this.taskRunnerContext = taskRunnerContext; + this.taskInstance = taskInstance; + this.ruleRunMetricsStore = ruleRunMetricsStore; + this.apiKey = apiKey; + this.ruleConsumer = ruleConsumer; + this.executionId = executionId; + this.ruleLabel = ruleLabel; + this.actionsClient = actionsClient; + this.ephemeralActionsToSchedule = taskRunnerContext.maxEphemeralActionsPerRule; + this.ruleTypeActionGroups = new Map( + ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) + ); + this.mutedAlertIdsSet = new Set(rule.mutedInstanceIds); + } + + public async run( + alerts: Record>, + recovered: boolean = false + ) { + const { + CHUNK_SIZE, + logger, + alertingEventLogger, + ruleRunMetricsStore, + taskRunnerContext: { actionsConfigMap, actionsPlugin }, + taskInstance: { + params: { spaceId, alertId: ruleId }, + }, + } = this; + + const executables = this.generateExecutables({ alerts, recovered }); + + if (!!executables.length) { + const logActions = []; + const bulkActions: EnqueueExecutionOptions[] = []; + + this.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); + + for (const { action, alert, alertId, actionGroup, state } of executables) { + const { actionTypeId } = action; + + if (!recovered) { + alert.updateLastScheduledActions(action.group as ActionGroupIds); + alert.unscheduleActions(); + } + + ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); + + if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + logger.debug( + `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` + ); + break; + } + + if ( + ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ + actionTypeId, + actionsConfigMap, + }) + ) { + if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { + logger.debug( + `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` + ); + } + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + continue; + } + + if (!this.isActionExecutable(action)) { + this.logger.warn( + `Rule "${this.taskInstance.params.alertId}" skipped scheduling action "${action.id}" because it is disabled` + ); + continue; + } + + ruleRunMetricsStore.incrementNumberOfTriggeredActions(); + ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); + + const actionToRun = { + ...action, + params: injectActionParams({ + ruleId, + spaceId, + actionTypeId, + actionParams: transformActionParams({ + actionsPlugin, + alertId: ruleId, + alertType: this.ruleType.id, + actionTypeId, + alertName: this.rule.name, + spaceId, + tags: this.rule.tags, + alertInstanceId: alertId, + alertActionGroup: actionGroup, + alertActionGroupName: this.ruleTypeActionGroups!.get(actionGroup)!, + context: alert.getContext(), + actionId: action.id, + state, + kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, + alertParams: this.rule.params, + actionParams: action.params, + }), + }), + }; + + await this.actionRunOrAddToBulk({ + enqueueOptions: this.getEnqueueOptions(actionToRun), + bulkActions, + }); + + logActions.push({ + id: action.id, + typeId: action.actionTypeId, + alertId, + alertGroup: action.group, + }); + + if (recovered) { + alert.scheduleActions(action.group as ActionGroupIds); + } + } + + if (!!bulkActions.length) { + for (const c of chunk(bulkActions, CHUNK_SIZE)) { + await this.actionsClient!.bulkEnqueueExecution(c); + } + } + + if (!!logActions.length) { + for (const action of logActions) { + alertingEventLogger.logAction(action); + } + } + } + } + + private generateExecutables({ + alerts, + recovered, + }: { + alerts: Record>; + recovered: boolean; + }) { + const executables = []; + + for (const action of this.rule.actions) { + for (const [alertId, alert] of Object.entries(alerts)) { + const actionGroup = recovered + ? this.ruleType.recoveryActionGroup.id + : alert.getScheduledActionOptions()?.actionGroup!; + + if (!this.ruleTypeActionGroups!.has(actionGroup)) { + this.logger.error( + `Invalid action group "${actionGroup}" for rule "${this.ruleType.id}".` + ); + continue; + } + + if (action.group === actionGroup && this.isAlertExecutable({ alertId, alert, recovered })) { + const state = recovered ? {} : alert.getScheduledActionOptions()?.state!; + + executables.push({ + action, + alert, + alertId, + actionGroup, + state, + }); + } + } + } + + return executables; + } + + private async actionRunOrAddToBulk({ + enqueueOptions, + bulkActions, + }: { + enqueueOptions: EnqueueExecutionOptions; + bulkActions: EnqueueExecutionOptions[]; + }) { + if (this.taskRunnerContext.supportsEphemeralTasks && this.ephemeralActionsToSchedule > 0) { + this.ephemeralActionsToSchedule--; + try { + await this.actionsClient!.ephemeralEnqueuedExecution(enqueueOptions); + } catch (err) { + if (isEphemeralTaskRejectedDueToCapacityError(err)) { + bulkActions.push(enqueueOptions); + } + } + } else { + bulkActions.push(enqueueOptions); + } + } + + private getEnqueueOptions(action: RuleAction): EnqueueExecutionOptions { + const { + apiKey, + ruleConsumer, + executionId, + taskInstance: { + params: { spaceId, alertId: ruleId }, + }, + } = this; + + const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; + return { + id: action.id, + params: action.params, + spaceId, + apiKey: apiKey ?? null, + consumer: ruleConsumer, + source: asSavedObjectExecutionSource({ + id: ruleId, + type: 'alert', + }), + executionId, + relatedSavedObjects: [ + { + id: ruleId, + type: 'alert', + namespace: namespace.namespace, + typeId: this.ruleType.id, + }, + ], + }; + } + + private isActionExecutable(action: RuleAction) { + return this.taskRunnerContext.actionsPlugin.isActionExecutable(action.id, action.actionTypeId, { + notifyUsage: true, + }); + } + + private isAlertExecutable({ + alertId, + alert, + recovered, + }: { + alertId: string; + alert: Alert; + recovered: boolean; + }) { + const { + rule: { throttle, notifyWhen }, + ruleLabel, + logger, + mutedAlertIdsSet, + } = this; + + const muted = mutedAlertIdsSet!.has(alertId); + const throttled = alert.isThrottled(throttle); + + if (muted) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.MUTED) + ) { + logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: rule is muted` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.MUTED }; + return false; + } + + if (!recovered) { + if (throttled) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.THROTTLED) + ) { + logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: rule is throttled` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.THROTTLED }; + return false; + } + + if (notifyWhen === 'onActionGroupChange' && !alert.scheduledActionGroupHasChanged()) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && + this.skippedAlerts[alertId].reason !== Reasons.ACTION_GROUP_NOT_CHANGED) + ) { + logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: alert is active but action group has not changed` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.ACTION_GROUP_NOT_CHANGED }; + return false; + } + + return alert.hasScheduledActions(); + } else { + return true; + } + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 0a90456dbdad6..ca9ddd0c48db6 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -198,6 +198,7 @@ export const generateAlertOpts = ({ action, group, state, id }: GeneratorParams message, state, ...(group ? { group } : {}), + flapping: false, }; }; diff --git a/x-pack/plugins/alerting/server/task_runner/log_alerts.test.ts b/x-pack/plugins/alerting/server/task_runner/log_alerts.test.ts index d6dfd42cb0dcf..43f191fc0a3aa 100644 --- a/x-pack/plugins/alerting/server/task_runner/log_alerts.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/log_alerts.test.ts @@ -158,48 +158,56 @@ describe('logAlerts', () => { id: '7', message: "test-rule-type-id:123: 'test rule' alert '7' has recovered", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(2, { action: 'recovered-instance', id: '8', message: "test-rule-type-id:123: 'test rule' alert '8' has recovered", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(3, { action: 'recovered-instance', id: '9', message: "test-rule-type-id:123: 'test rule' alert '9' has recovered", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(4, { action: 'recovered-instance', id: '10', message: "test-rule-type-id:123: 'test rule' alert '10' has recovered", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(5, { action: 'new-instance', id: '4', message: "test-rule-type-id:123: 'test rule' created new alert: '4'", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(6, { action: 'active-instance', id: '1', message: "test-rule-type-id:123: 'test rule' active alert: '1' in actionGroup: 'undefined'", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(7, { action: 'active-instance', id: '2', message: "test-rule-type-id:123: 'test rule' active alert: '2' in actionGroup: 'undefined'", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(8, { action: 'active-instance', id: '4', message: "test-rule-type-id:123: 'test rule' active alert: '4' in actionGroup: 'undefined'", state: {}, + flapping: false, }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/log_alerts.ts b/x-pack/plugins/alerting/server/task_runner/log_alerts.ts index 2abe72ed06cb5..b7abaf4c236be 100644 --- a/x-pack/plugins/alerting/server/task_runner/log_alerts.ts +++ b/x-pack/plugins/alerting/server/task_runner/log_alerts.ts @@ -102,6 +102,7 @@ export function logAlerts< group: actionGroup, message, state, + flapping: false, }); } @@ -115,6 +116,7 @@ export function logAlerts< group: actionGroup, message, state, + flapping: false, }); } @@ -128,6 +130,7 @@ export function logAlerts< group: actionGroup, message, state, + flapping: false, }); } } diff --git a/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.test.ts b/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.test.ts deleted file mode 100644 index cdd16289c7a1a..0000000000000 --- a/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.test.ts +++ /dev/null @@ -1,207 +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 { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; -import { RecoveredActionGroup } from '../types'; -import { RULE_NAME } from './fixtures'; -import { loggingSystemMock } from '@kbn/core/server/mocks'; -import { scheduleActionsForAlerts } from './schedule_actions_for_alerts'; -import { Alert } from '../alert'; -import { AlertInstanceState, AlertInstanceContext, DefaultActionGroupId } from '../../common'; -import sinon from 'sinon'; - -describe('Schedule Actions For Alerts', () => { - const ruleRunMetricsStore = new RuleRunMetricsStore(); - const executionHandler = jest.fn(); - const recoveryActionGroup = RecoveredActionGroup; - const mutedAlertIdsSet = new Set('2'); - const logger: ReturnType = - loggingSystemMock.createLogger(); - const notifyWhen = 'onActiveAlert'; - const throttle = null; - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - jest.resetAllMocks(); - clock.reset(); - }); - beforeAll(() => { - clock = sinon.useFakeTimers(); - }); - afterAll(() => clock.restore()); - - test('schedules alerts with executable actions', async () => { - const alert = new Alert('1', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - alert.scheduleActions('default'); - const alerts = { '1': alert }; - const recoveredAlerts = {}; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); - - expect(executionHandler).toBeCalledWith({ - actionGroup: 'default', - context: {}, - state: { test: true }, - alertId: '1', - ruleRunMetricsStore, - }); - }); - - test('schedules alerts with recovered actions', async () => { - const alert = new Alert('1', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - const alerts = {}; - const recoveredAlerts = { '1': alert }; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); - - expect(executionHandler).toHaveBeenNthCalledWith(1, { - actionGroup: 'recovered', - context: {}, - state: {}, - alertId: '1', - ruleRunMetricsStore, - }); - }); - - test('does not schedule alerts with recovered actions that are muted', async () => { - const alert = new Alert('2', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - const alerts = {}; - const recoveredAlerts = { '2': alert }; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); - - expect(executionHandler).not.toBeCalled(); - expect(logger.debug).nthCalledWith( - 1, - `skipping scheduling of actions for '2' in rule ${RULE_NAME}: instance is muted` - ); - }); - - test('does not schedule active alerts that are throttled', async () => { - const alert = new Alert('1', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - clock.tick(30000); - alert.scheduleActions('default'); - const alerts = { '1': alert }; - const recoveredAlerts = {}; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle: '1m', - notifyWhen, - }); - expect(executionHandler).not.toBeCalled(); - expect(logger.debug).nthCalledWith( - 1, - `skipping scheduling of actions for '1' in rule ${RULE_NAME}: rule is throttled` - ); - }); - - test('does not schedule active alerts that are muted', async () => { - const alert = new Alert('2', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - const alerts = { '2': alert }; - const recoveredAlerts = {}; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); - - expect(executionHandler).not.toBeCalled(); - expect(logger.debug).nthCalledWith( - 1, - `skipping scheduling of actions for '2' in rule ${RULE_NAME}: rule is muted` - ); - }); -}); diff --git a/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.ts b/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.ts deleted file mode 100644 index 8c1d62ecf4e32..0000000000000 --- a/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.ts +++ /dev/null @@ -1,134 +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 { Logger } from '@kbn/core/server'; -import { ExecutionHandler } from './create_execution_handler'; -import { ScheduleActionsForAlertsParams } from './types'; -import { AlertInstanceState, AlertInstanceContext } from '../types'; -import { Alert } from '../alert'; -import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; - -export async function scheduleActionsForAlerts< - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string ->( - params: ScheduleActionsForAlertsParams< - InstanceState, - InstanceContext, - ActionGroupIds, - RecoveryActionGroupId - > -): Promise { - const { - logger, - activeAlerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - ruleLabel, - ruleRunMetricsStore, - throttle, - notifyWhen, - } = params; - // execute alerts with executable actions - for (const [alertId, alert] of Object.entries(activeAlerts)) { - const executeAction: boolean = shouldExecuteAction( - alertId, - alert, - mutedAlertIdsSet, - ruleLabel, - logger, - throttle, - notifyWhen - ); - if (executeAction && alert.hasScheduledActions()) { - const { actionGroup, state } = alert.getScheduledActionOptions()!; - await executeAlert(alertId, alert, executionHandler, ruleRunMetricsStore, actionGroup, state); - } - } - - // execute recovered alerts - for (const alertId of Object.keys(recoveredAlerts)) { - if (mutedAlertIdsSet.has(alertId)) { - logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: instance is muted` - ); - } else { - const alert = recoveredAlerts[alertId]; - await executeAlert( - alertId, - alert, - executionHandler, - ruleRunMetricsStore, - recoveryActionGroup.id, - {} as InstanceState - ); - alert.scheduleActions(recoveryActionGroup.id); - } - } -} - -async function executeAlert< - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string ->( - alertId: string, - alert: Alert, - executionHandler: ExecutionHandler, - ruleRunMetricsStore: RuleRunMetricsStore, - actionGroup: ActionGroupIds | RecoveryActionGroupId, - state: InstanceState -) { - alert.updateLastScheduledActions(actionGroup); - alert.unscheduleActions(); - return executionHandler({ - actionGroup, - context: alert.getContext(), - state, - alertId, - ruleRunMetricsStore, - }); -} - -function shouldExecuteAction< - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string ->( - alertId: string, - alert: Alert, - mutedAlertIdsSet: Set, - ruleLabel: string, - logger: Logger, - throttle: string | null, - notifyWhen: string | null -) { - const throttled = alert.isThrottled(throttle); - const muted = mutedAlertIdsSet.has(alertId); - let executeAction = true; - - if (throttled || muted) { - executeAction = false; - logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: rule is ${ - muted ? 'muted' : 'throttled' - }` - ); - } else if (notifyWhen === 'onActionGroupChange' && !alert.scheduledActionGroupHasChanged()) { - executeAction = false; - logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: alert is active but action group has not changed` - ); - } - - return executeAction; -} 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 3d3c82d91fe28..54573f1b7e2b2 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 @@ -2424,8 +2424,7 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); - // 1x(.server-log) and 1x(any-action) per alert - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(2); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update @@ -2468,7 +2467,7 @@ describe('Task Runner', () => { expect(logger.debug).nthCalledWith( 3, - 'Rule "1" skipped scheduling action "2" because the maximum number of allowed actions for connector type .server-log has been reached.' + 'Rule "1" skipped scheduling action "1" because the maximum number of allowed actions for connector type .server-log has been reached.' ); testAlertingEventLogCalls({ diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index fe8fcbb73152a..354b18d3d38d4 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -9,11 +9,11 @@ import apm from 'elastic-apm-node'; import { cloneDeep, omit } from 'lodash'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import uuid from 'uuid'; -import { KibanaRequest, Logger } from '@kbn/core/server'; +import { Logger } from '@kbn/core/server'; import { ConcreteTaskInstance, throwUnrecoverableError } from '@kbn/task-manager-plugin/server'; import { nanosToMillis } from '@kbn/event-log-plugin/server'; +import { ExecutionHandler } from './execution_handler'; import { TaskRunnerContext } from './task_runner_factory'; -import { createExecutionHandler } from './create_execution_handler'; import { Alert, createAlertFactory } from '../alert'; import { ElasticsearchError, @@ -25,12 +25,10 @@ import { processAlerts, } from '../lib'; import { - Rule, RuleExecutionStatus, RuleExecutionStatusErrorReasons, IntervalSchedule, RawAlertInstance, - RawRule, RawRuleExecutionStatus, RuleMonitoring, RuleMonitoringHistory, @@ -66,7 +64,6 @@ import { wrapSearchSourceClient } from '../lib/wrap_search_source_client'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; import { loadRule } from './rule_loader'; import { logAlerts } from './log_alerts'; -import { scheduleActionsForAlerts } from './schedule_actions_for_alerts'; import { getPublicAlertFactory } from '../alert/create_alert_factory'; import { TaskRunnerTimer, TaskRunnerTimerSpan } from './task_runner_timer'; @@ -154,47 +151,6 @@ export class TaskRunner< this.stackTraceLog = null; } - private getExecutionHandler( - ruleId: string, - ruleName: string, - tags: string[] | undefined, - spaceId: string, - apiKey: RawRule['apiKey'], - kibanaBaseUrl: string | undefined, - actions: Rule['actions'], - ruleParams: Params, - request: KibanaRequest - ) { - return createExecutionHandler< - Params, - ExtractedParams, - RuleState, - State, - Context, - ActionGroupIds, - RecoveryActionGroupId - >({ - ruleId, - ruleName, - ruleConsumer: this.ruleConsumer!, - tags, - executionId: this.executionId, - logger: this.logger, - actionsPlugin: this.context.actionsPlugin, - apiKey, - actions, - spaceId, - ruleType: this.ruleType, - kibanaBaseUrl, - alertingEventLogger: this.alertingEventLogger, - request, - ruleParams, - supportsEphemeralTasks: this.context.supportsEphemeralTasks, - maxEphemeralActionsPerRule: this.context.maxEphemeralActionsPerRule, - actionsConfigMap: this.context.actionsConfigMap, - }); - } - private async updateRuleSavedObject( ruleId: string, namespace: string | undefined, @@ -223,6 +179,11 @@ export class TaskRunner< return !this.context.cancelAlertsOnRuleTimeout || !this.ruleType.cancelAlertsOnRuleTimeout; } + // Usage counter for telemetry + // This keeps track of how many times action executions were skipped after rule + // execution completed successfully after the execution timeout + // This can occur when rule executors do not short circuit execution in response + // to timeout private countUsageOfActionExecutionAfterRuleCancellation() { if (this.cancelled && this.usageCounter) { if (this.context.cancelAlertsOnRuleTimeout && this.ruleType.cancelAlertsOnRuleTimeout) { @@ -259,7 +220,6 @@ export class TaskRunner< schedule, throttle, notifyWhen, - mutedInstanceIds, name, tags, createdBy, @@ -464,52 +424,34 @@ export class TaskRunner< } ); - await this.timer.runWithTimer(TaskRunnerTimerSpan.TriggerActions, async () => { - const executionHandler = this.getExecutionHandler( - ruleId, - rule.name, - rule.tags, - spaceId, - apiKey, - this.context.kibanaBaseUrl, - rule.actions, - rule.params, - fakeRequest - ); + const executionHandler = new ExecutionHandler({ + rule, + ruleType: this.ruleType, + logger: this.logger, + taskRunnerContext: this.context, + taskInstance: this.taskInstance, + ruleRunMetricsStore, + apiKey, + ruleConsumer: this.ruleConsumer!, + executionId: this.executionId, + ruleLabel, + alertingEventLogger: this.alertingEventLogger, + actionsClient: await this.context.actionsPlugin.getActionsClientWithRequest(fakeRequest), + }); + await this.timer.runWithTimer(TaskRunnerTimerSpan.TriggerActions, async () => { await rulesClient.clearExpiredSnoozes({ id: rule.id }); - const ruleIsSnoozed = isRuleSnoozed(rule); - if (!ruleIsSnoozed && this.shouldLogAndScheduleActionsForAlerts()) { - const mutedAlertIdsSet = new Set(mutedInstanceIds); - - await scheduleActionsForAlerts({ - activeAlerts, - recoveryActionGroup: this.ruleType.recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger: this.logger, - ruleLabel, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); + if (isRuleSnoozed(rule)) { + this.logger.debug(`no scheduling of actions for rule ${ruleLabel}: rule is snoozed.`); + } else if (!this.shouldLogAndScheduleActionsForAlerts()) { + this.logger.debug( + `no scheduling of actions for rule ${ruleLabel}: rule execution has been cancelled.` + ); + this.countUsageOfActionExecutionAfterRuleCancellation(); } else { - if (ruleIsSnoozed) { - this.logger.debug(`no scheduling of actions for rule ${ruleLabel}: rule is snoozed.`); - } - if (!this.shouldLogAndScheduleActionsForAlerts()) { - this.logger.debug( - `no scheduling of actions for rule ${ruleLabel}: rule execution has been cancelled.` - ); - // Usage counter for telemetry - // This keeps track of how many times action executions were skipped after rule - // execution completed successfully after the execution timeout - // This can occur when rule executors do not short circuit execution in response - // to timeout - this.countUsageOfActionExecutionAfterRuleCancellation(); - } + await executionHandler.run(activeAlerts); + await executionHandler.run(recoveredAlerts, true); } }); diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index ce439fa3b3b0a..bffa25577b3a6 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -7,25 +7,21 @@ import { KibanaRequest, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; -import { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; import { PublicMethodsOf } from '@kbn/utility-types'; +import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; +import { TaskRunnerContext } from './task_runner_factory'; import { - ActionGroup, - RuleAction, AlertInstanceContext, AlertInstanceState, RuleTypeParams, - RuleTypeState, IntervalSchedule, RuleMonitoring, RuleTaskState, SanitizedRule, + RuleTypeState, } from '../../common'; -import { Alert } from '../alert'; import { NormalizedRuleType } from '../rule_type_registry'; -import { ExecutionHandler } from './create_execution_handler'; import { RawRule, RulesClientApi } from '../types'; -import { ActionsConfigMap } from '../lib/get_actions_config_map'; import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; @@ -57,67 +53,35 @@ export interface RuleTaskInstance extends ConcreteTaskInstance { state: RuleTaskState; } -export interface ScheduleActionsForAlertsParams< - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string -> { - logger: Logger; - recoveryActionGroup: ActionGroup; - recoveredAlerts: Record>; - executionHandler: ExecutionHandler; - mutedAlertIdsSet: Set; - ruleLabel: string; - ruleRunMetricsStore: RuleRunMetricsStore; - activeAlerts: Record>; - throttle: string | null; - notifyWhen: string | null; -} - // / ExecutionHandler -export interface CreateExecutionHandlerOptions< +export interface ExecutionHandlerOptions< Params extends RuleTypeParams, ExtractedParams extends RuleTypeParams, - State extends RuleTypeState, - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, + RuleState extends RuleTypeState, + State extends AlertInstanceState, + Context extends AlertInstanceContext, ActionGroupIds extends string, RecoveryActionGroupId extends string > { - ruleId: string; - ruleName: string; - ruleConsumer: string; - executionId: string; - tags?: string[]; - actionsPlugin: ActionsPluginStartContract; - actions: RuleAction[]; - spaceId: string; - apiKey: RawRule['apiKey']; - kibanaBaseUrl: string | undefined; ruleType: NormalizedRuleType< Params, ExtractedParams, + RuleState, State, - InstanceState, - InstanceContext, + Context, ActionGroupIds, RecoveryActionGroupId >; logger: Logger; alertingEventLogger: PublicMethodsOf; - request: KibanaRequest; - ruleParams: RuleTypeParams; - supportsEphemeralTasks: boolean; - maxEphemeralActionsPerRule: number; - actionsConfigMap: ActionsConfigMap; -} - -export interface ExecutionHandlerOptions { - actionGroup: ActionGroupIds; - alertId: string; - context: AlertInstanceContext; - state: AlertInstanceState; + rule: SanitizedRule; + taskRunnerContext: TaskRunnerContext; + taskInstance: RuleTaskInstance; ruleRunMetricsStore: RuleRunMetricsStore; + apiKey: RawRule['apiKey']; + ruleConsumer: string; + executionId: string; + ruleLabel: string; + actionsClient: PublicMethodsOf; } diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index 1db28528efd57..67b60230c33aa 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -276,6 +276,9 @@ }, "alert": { "properties": { + "flapping": { + "type": "boolean" + }, "rule": { "properties": { "consumer": { diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index e40be778755af..acad4d86a6aa5 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -120,6 +120,7 @@ export const EventSchema = schema.maybe( ), alert: schema.maybe( schema.object({ + flapping: ecsBoolean(), rule: schema.maybe( schema.object({ consumer: ecsString(), @@ -199,6 +200,10 @@ function ecsDate() { return schema.maybe(schema.string({ validate: validateDate })); } +function ecsBoolean() { + return schema.maybe(schema.boolean()); +} + const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; function validateDate(isoDate: string) { diff --git a/x-pack/plugins/event_log/scripts/create_schemas.js b/x-pack/plugins/event_log/scripts/create_schemas.js index dc4eeb01c8957..6ba8df5e0d46d 100755 --- a/x-pack/plugins/event_log/scripts/create_schemas.js +++ b/x-pack/plugins/event_log/scripts/create_schemas.js @@ -161,6 +161,11 @@ function generateSchemaLines(lineWriter, prop, mappings) { return; } + if (mappings.type === 'boolean') { + lineWriter.addLine(`${propKey}: ecsBoolean(),`); + return; + } + // only handling objects for the rest of this function if (mappings.properties == null) { logError(`unknown properties to map: ${prop}: ${JSON.stringify(mappings)}`); @@ -324,6 +329,10 @@ function ecsDate() { return schema.maybe(schema.string({ validate: validateDate })); } +function ecsBoolean() { + return schema.maybe(schema.boolean()); +} + const ISO_DATE_PATTERN = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/; function validateDate(isoDate: string) { diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index d47ef4be6cac2..ff69ef9160352 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -58,6 +58,9 @@ exports.EcsCustomPropertyMappings = { }, alert: { properties: { + flapping: { + type: 'boolean', + }, rule: { properties: { consumer: { diff --git a/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx b/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx index 9065cb723d8b5..ba4afddf9cc99 100644 --- a/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx +++ b/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx @@ -97,7 +97,7 @@ export const WithGuidedOnboardingTour: FunctionComponent<{ footerAction={ setIsGuidedOnboardingTourOpen(false)} size="s" color="success"> {i18n.translate('xpack.fleet.guidedOnboardingTour.nextButtonLabel', { - defaultMessage: 'Next', + defaultMessage: 'Got it', })} } diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index a475e6beea011..e8c884e2cd21d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -22,6 +22,7 @@ import { RuleExecutorServices, RuleTypeState, } from '@kbn/alerting-plugin/server'; +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { RuleParams, @@ -47,7 +48,7 @@ import { decodeOrThrow } from '../../../../common/runtime_types'; import { getLogsAppAlertUrl } from '../../../../common/formatters/alert_link'; import { getIntervalInSeconds } from '../../../../common/utils/get_interval_in_seconds'; import { InfraBackendLibs } from '../../infra_types'; -import { UNGROUPED_FACTORY_KEY } from '../common/utils'; +import { getAlertDetailsUrl, UNGROUPED_FACTORY_KEY } from '../common/utils'; import { getReasonMessageForGroupedCountAlert, getReasonMessageForGroupedRatioAlert, @@ -101,13 +102,14 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => LogThresholdAlertState, LogThresholdAlertContext, LogThresholdActionGroups - >(async ({ services, params, startedAt }) => { + >(async ({ services, params, spaceId, startedAt }) => { const { alertFactory: { alertLimit }, alertWithLifecycle, savedObjectsClient, scopedClusterClient, getAlertStartedDate, + getAlertUuid, } = services; const { basePath } = libs; @@ -124,17 +126,30 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => if (actions && actions.length > 0) { const indexedStartedAt = getAlertStartedDate(id) ?? startedAt.toISOString(); const relativeViewInAppUrl = getLogsAppAlertUrl(new Date(indexedStartedAt).getTime()); - const viewInAppUrl = basePath.publicBaseUrl - ? new URL(basePath.prepend(relativeViewInAppUrl), basePath.publicBaseUrl).toString() - : relativeViewInAppUrl; + + const viewInAppUrl = addSpaceIdToPath( + basePath.publicBaseUrl, + spaceId, + relativeViewInAppUrl + ); const sharedContext = { timestamp: startedAt.toISOString(), viewInAppUrl, }; + actions.forEach((actionSet) => { const { actionGroup, context } = actionSet; - alert.scheduleActions(actionGroup, { ...sharedContext, ...context }); + + const alertInstanceId = (context.group || id) as string; + + const alertUuid = getAlertUuid(alertInstanceId); + + alert.scheduleActions(actionGroup, { + ...sharedContext, + ...context, + alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid), + }); }); } @@ -179,13 +194,15 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => const { getRecoveredAlerts } = services.alertFactory.done(); const recoveredAlerts = getRecoveredAlerts(); - processRecoveredAlerts( + processRecoveredAlerts({ + basePath, + getAlertStartedDate, + getAlertUuid, recoveredAlerts, + spaceId, startedAt, - getAlertStartedDate, - basePath, - validatedParams - ); + validatedParams, + }); } catch (e) { throw new Error(e); } @@ -876,22 +893,33 @@ type LogThresholdRecoveredAlert = { getId: () => string; } & LogThresholdAlert; -const processRecoveredAlerts = ( - recoveredAlerts: LogThresholdRecoveredAlert[], - startedAt: Date, - getAlertStartedDate: (alertId: string) => string | null, - basePath: IBasePath, - validatedParams: RuleParams -) => { +const processRecoveredAlerts = ({ + basePath, + getAlertStartedDate, + getAlertUuid, + recoveredAlerts, + spaceId, + startedAt, + validatedParams, +}: { + basePath: IBasePath; + getAlertStartedDate: (alertId: string) => string | null; + getAlertUuid: (alertId: string) => string | null; + recoveredAlerts: LogThresholdRecoveredAlert[]; + spaceId: string; + startedAt: Date; + validatedParams: RuleParams; +}) => { for (const alert of recoveredAlerts) { const recoveredAlertId = alert.getId(); const indexedStartedAt = getAlertStartedDate(recoveredAlertId) ?? startedAt.toISOString(); const relativeViewInAppUrl = getLogsAppAlertUrl(new Date(indexedStartedAt).getTime()); - const viewInAppUrl = basePath.publicBaseUrl - ? new URL(basePath.prepend(relativeViewInAppUrl), basePath.publicBaseUrl).toString() - : relativeViewInAppUrl; + const alertUuid = getAlertUuid(recoveredAlertId); + + const viewInAppUrl = addSpaceIdToPath(basePath.publicBaseUrl, spaceId, relativeViewInAppUrl); const baseContext = { + alertDetailsUrl: getAlertDetailsUrl(basePath, spaceId, alertUuid), group: hasGroupBy(validatedParams) ? recoveredAlertId : null, timestamp: startedAt.toISOString(), viewInAppUrl, @@ -899,21 +927,21 @@ const processRecoveredAlerts = ( if (isRatioRuleParams(validatedParams)) { const { criteria } = validatedParams; - const context = { + + alert.setContext({ ...baseContext, numeratorConditions: createConditionsMessageForCriteria(getNumerator(criteria)), denominatorConditions: createConditionsMessageForCriteria(getDenominator(criteria)), isRatio: true, - }; - alert.setContext(context); + }); } else { const { criteria } = validatedParams; - const context = { + + alert.setContext({ ...baseContext, conditions: createConditionsMessageForCriteria(criteria), isRatio: false, - }; - alert.setContext(context); + }); } } }; diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts index 3e611bbefeef2..169d674bfd475 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts @@ -14,6 +14,8 @@ import { } from '../../../../common/alerting/logs/log_threshold'; import { InfraBackendLibs } from '../../infra_types'; import { decodeOrThrow } from '../../../../common/runtime_types'; +import { getAlertDetailsPageEnabledForApp } from '../common/utils'; +import { alertDetailUrlActionVariableDescription } from '../common/messages'; const timestampActionVariableDescription = i18n.translate( 'xpack.infra.logs.alerting.threshold.timestampActionVariableDescription', @@ -96,6 +98,8 @@ export async function registerLogThresholdRuleType( ); } + const config = libs.getAlertDetailsConfig(); + alertingPlugin.registerType({ id: LOG_DOCUMENT_COUNT_RULE_TYPE_ID, name: i18n.translate('xpack.infra.logs.alertName', { @@ -127,6 +131,9 @@ export async function registerLogThresholdRuleType( name: 'denominatorConditions', description: denominatorConditionsActionVariableDescription, }, + ...(getAlertDetailsPageEnabledForApp(config, 'logs') + ? [{ name: 'alertDetailsUrl', description: alertDetailUrlActionVariableDescription }] + : []), { name: 'viewInAppUrl', description: viewInAppUrlActionVariableDescription, diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap index 855b447d85ced..c10911d7687d3 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap @@ -73,7 +73,7 @@ exports[`keeps order of the columns during the scroll 1`] = ` " `; -exports[`uses the scrollId to page all the data 1`] = ` +exports[`uses the pit ID to page all the data 1`] = ` "date,ip,message \\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" \\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts index ee00ea28cc05e..804fa4bcdd4a6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { errors as esErrors } from '@elastic/elasticsearch'; +import { errors as esErrors, estypes } from '@elastic/elasticsearch'; import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { IScopedClusterClient, IUiSettingsClient, Logger } from '@kbn/core/server'; import { @@ -50,6 +50,7 @@ const searchSourceMock = { ...searchSourceInstanceMock, getSearchRequestBody: jest.fn(() => ({})), }; + const mockSearchSourceService: jest.Mocked = { create: jest.fn().mockReturnValue(searchSourceMock), createEmpty: jest.fn().mockReturnValue(searchSourceMock), @@ -58,19 +59,21 @@ const mockSearchSourceService: jest.Mocked = { extract: jest.fn(), getAllMigrations: jest.fn(), }; + +const mockPitId = 'oju9fs3698s3902f02-8qg3-u9w36oiewiuyew6'; + +const getMockRawResponse = (hits: Array> = [], total = hits.length) => ({ + took: 1, + timed_out: false, + pit_id: mockPitId, + _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, + hits: { hits, total, max_score: 0 }, +}); + const mockDataClientSearchDefault = jest.fn().mockImplementation( (): Rx.Observable<{ rawResponse: SearchResponse }> => Rx.of({ - rawResponse: { - took: 1, - timed_out: false, - _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, - hits: { - hits: [], - total: 0, - max_score: 0, - }, - }, + rawResponse: getMockRawResponse(), }) ); @@ -92,6 +95,8 @@ beforeEach(async () => { mockDataClient = dataPluginMock.createStartContract().search.asScoped({} as any); mockDataClient.search = mockDataClientSearchDefault; + mockEsClient.asCurrentUser.openPointInTime = jest.fn().mockResolvedValueOnce({ id: mockPitId }); + uiSettingsClient = uiSettingsServiceMock .createStartContract() .asScopedToClient(savedObjectsClientMock.create()); @@ -117,6 +122,8 @@ beforeEach(async () => { searchSourceMock.getField = jest.fn((key: string) => { switch (key) { + case 'pit': + return { id: mockPitId }; case 'index': return { fields: { @@ -125,6 +132,7 @@ beforeEach(async () => { }, metaFields: ['_id', '_index', '_type', '_score'], getFormatterForField: jest.fn(), + getIndexPattern: () => 'logstash-*', }; } }); @@ -157,20 +165,15 @@ it('formats an empty search result to CSV content', async () => { it('formats a search result to CSV content', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - fields: { - date: `["2020-12-31T00:14:28.000Z"]`, - ip: `["110.135.176.89"]`, - message: `["This is a great message!"]`, - }, - }, - ], - total: 1, - }, - }, + rawResponse: getMockRawResponse([ + { + fields: { + date: `["2020-12-31T00:14:28.000Z"]`, + ip: `["110.135.176.89"]`, + message: `["This is a great message!"]`, + }, + } as unknown as estypes.SearchHit, + ]), }) ); const generateCsv = new CsvGenerator( @@ -199,16 +202,16 @@ const HITS_TOTAL = 100; it('calculates the bytes of the content', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: range(0, HITS_TOTAL).map(() => ({ - fields: { - message: ['this is a great message'], - }, - })), - total: HITS_TOTAL, - }, - }, + rawResponse: getMockRawResponse( + range(0, HITS_TOTAL).map( + () => + ({ + fields: { + message: ['this is a great message'], + }, + } as unknown as estypes.SearchHit) + ) + ), }) ); @@ -246,18 +249,18 @@ it('warns if max size was reached', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: range(0, HITS_TOTAL).map(() => ({ - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: ['super cali fragile istic XPLA docious'], - }, - })), - total: HITS_TOTAL, - }, - }, + rawResponse: getMockRawResponse( + range(0, HITS_TOTAL).map( + () => + ({ + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: ['super cali fragile istic XPLA docious'], + }, + } as unknown as estypes.SearchHit) + ) + ), }) ); @@ -283,36 +286,42 @@ it('warns if max size was reached', async () => { expect(content).toMatchSnapshot(); }); -it('uses the scrollId to page all the data', async () => { - mockDataClient.search = jest.fn().mockImplementation(() => - Rx.of({ - rawResponse: { - _scroll_id: 'awesome-scroll-hero', - hits: { - hits: range(0, HITS_TOTAL / 10).map(() => ({ - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: ['hit from the initial search'], - }, - })), - total: HITS_TOTAL, - }, - }, - }) - ); - - mockEsClient.asCurrentUser.scroll = jest.fn().mockResolvedValue({ - hits: { - hits: range(0, HITS_TOTAL / 10).map(() => ({ - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: ['hit from a subsequent scroll'], - }, - })), - }, - }); +it('uses the pit ID to page all the data', async () => { + mockDataClient.search = jest + .fn() + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + range(0, HITS_TOTAL / 10).map( + () => + ({ + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: ['hit from the initial search'], + }, + } as unknown as estypes.SearchHit) + ), + HITS_TOTAL + ), + }) + ) + .mockImplementation(() => + Rx.of({ + rawResponse: getMockRawResponse( + range(0, HITS_TOTAL / 10).map( + () => + ({ + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: ['hit from a subsequent scroll'], + }, + } as unknown as estypes.SearchHit) + ) + ), + }) + ); const generateCsv = new CsvGenerator( createMockJob({ columns: ['date', 'ip', 'message'] }), @@ -334,70 +343,55 @@ it('uses the scrollId to page all the data', async () => { expect(csvResult.warnings).toEqual([]); expect(content).toMatchSnapshot(); - expect(mockDataClient.search).toHaveBeenCalledTimes(1); + expect(mockDataClient.search).toHaveBeenCalledTimes(10); expect(mockDataClient.search).toBeCalledWith( - { params: { body: {}, ignore_throttled: undefined, scroll: '30s', size: 500 } }, + { params: { body: {}, ignore_throttled: undefined } }, { strategy: 'es', transport: { maxRetries: 0, requestTimeout: '30s' } } ); - // `scroll` and `clearScroll` must be called with scroll ID in the post body! - expect(mockEsClient.asCurrentUser.scroll).toHaveBeenCalledTimes(9); - expect(mockEsClient.asCurrentUser.scroll).toHaveBeenCalledWith({ - scroll: '30s', - scroll_id: 'awesome-scroll-hero', - }); + expect(mockEsClient.asCurrentUser.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockEsClient.asCurrentUser.openPointInTime).toHaveBeenCalledWith( + { + ignore_unavailable: true, + index: 'logstash-*', + keep_alive: '30s', + }, + { maxRetries: 0, requestTimeout: '30s' } + ); - expect(mockEsClient.asCurrentUser.clearScroll).toHaveBeenCalledTimes(1); - expect(mockEsClient.asCurrentUser.clearScroll).toHaveBeenCalledWith({ - scroll_id: ['awesome-scroll-hero'], + expect(mockEsClient.asCurrentUser.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockEsClient.asCurrentUser.closePointInTime).toHaveBeenCalledWith({ + body: { id: mockPitId }, }); }); it('keeps order of the columns during the scroll', async () => { - mockDataClient.search = jest.fn().mockImplementation(() => - Rx.of({ - rawResponse: { - _scroll_id: 'awesome-scroll-hero', - hits: { - hits: [ - { - fields: { - a: ['a1'], - b: ['b1'], - }, - }, - ], - total: 3, - }, - }, - }) - ); - - mockEsClient.asCurrentUser.scroll = jest + mockDataClient.search = jest .fn() - .mockResolvedValueOnce({ - hits: { - hits: [ - { - fields: { - b: ['b2'], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - hits: { - hits: [ - { - fields: { - a: ['a3'], - c: ['c3'], - }, - }, - ], - }, - }); + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + [{ fields: { a: ['a1'], b: ['b1'] } } as unknown as estypes.SearchHit], + 3 + ), + }) + ) + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + [{ fields: { b: ['b2'] } } as unknown as estypes.SearchHit], + 3 + ), + }) + ) + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + [{ fields: { a: ['a3'], c: ['c3'] } } as unknown as estypes.SearchHit], + 3 + ), + }) + ); const generateCsv = new CsvGenerator( createMockJob({ searchSource: {}, columns: [] }), @@ -424,21 +418,16 @@ describe('fields from job.searchSource.getFields() (7.12 generated)', () => { it('cells can be multi-value', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - sku: [`This is a cool SKU.`, `This is also a cool SKU.`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + sku: [`This is a cool SKU.`, `This is also a cool SKU.`], + }, }, - }, + ]), }) ); @@ -466,22 +455,17 @@ describe('fields from job.searchSource.getFields() (7.12 generated)', () => { it('provides top-level underscored fields as columns', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - date: ['2020-12-31T00:14:28.000Z'], - message: [`it's nice to see you`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + date: ['2020-12-31T00:14:28.000Z'], + message: [`it's nice to see you`], + }, }, - }, + ]), }) ); @@ -520,28 +504,23 @@ describe('fields from job.searchSource.getFields() (7.12 generated)', () => { it('sorts the fields when they are to be used as table column names', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - date: ['2020-12-31T00:14:28.000Z'], - message_z: [`test field Z`], - message_y: [`test field Y`], - message_x: [`test field X`], - message_w: [`test field W`], - message_v: [`test field V`], - message_u: [`test field U`], - message_t: [`test field T`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + date: ['2020-12-31T00:14:28.000Z'], + message_z: [`test field Z`], + message_y: [`test field Y`], + message_x: [`test field X`], + message_w: [`test field W`], + message_v: [`test field V`], + message_u: [`test field U`], + message_t: [`test field T`], + }, }, - }, + ]), }) ); @@ -581,22 +560,17 @@ describe('fields from job.columns (7.13+ generated)', () => { it('cells can be multi-value', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - product: 'coconut', - category: [`cool`, `rad`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + product: 'coconut', + category: [`cool`, `rad`], + }, }, - }, + ]), }) ); @@ -624,22 +598,17 @@ describe('fields from job.columns (7.13+ generated)', () => { it('columns can be top-level fields such as _id and _index', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - product: 'coconut', - category: [`cool`, `rad`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + product: 'coconut', + category: [`cool`, `rad`], + }, }, - }, + ]), }) ); @@ -667,22 +636,17 @@ describe('fields from job.columns (7.13+ generated)', () => { it('default column names come from tabify', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - product: 'coconut', - category: [`cool`, `rad`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + product: 'coconut', + category: [`cool`, `rad`], + }, }, - }, + ]), }) ); @@ -714,20 +678,15 @@ describe('formulas', () => { it(`escapes formula values in a cell, doesn't warn the csv contains formulas`, async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: [TEST_FORMULA], - }, - }, - ], - total: 1, - }, - }, + rawResponse: getMockRawResponse([ + { + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: [TEST_FORMULA], + }, + } as unknown as estypes.SearchHit, + ]), }) ); @@ -757,20 +716,15 @@ describe('formulas', () => { it(`escapes formula values in a header, doesn't warn the csv contains formulas`, async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - [TEST_FORMULA]: 'This is great data', - }, - }, - ], - total: 1, - }, - }, + rawResponse: getMockRawResponse([ + { + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + [TEST_FORMULA]: 'This is great data', + }, + } as unknown as estypes.SearchHit, + ]), }) ); @@ -808,20 +762,15 @@ describe('formulas', () => { }); mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: [TEST_FORMULA], - }, - }, - ], - total: 1, - }, - }, + rawResponse: getMockRawResponse([ + { + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: [TEST_FORMULA], + }, + } as unknown as estypes.SearchHit, + ]), }) ); @@ -875,8 +824,6 @@ it('can override ignoring frozen indices', async () => { params: { body: {}, ignore_throttled: false, - scroll: '30s', - size: 500, }, }, { strategy: 'es', transport: { maxRetries: 0, requestTimeout: '30s' } } @@ -928,7 +875,7 @@ it('will return partial data if the scroll or search fails', async () => { expect(mockLogger.error.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "CSV export scan error: ResponseError: my error", + "CSV export search error: ResponseError: my error", ], Array [ [ResponseError: my error], @@ -978,27 +925,27 @@ it('handles unknown errors', async () => { describe('error codes', () => { it('returns the expected error code when authentication expires', async () => { - mockDataClient.search = jest.fn().mockImplementation(() => - Rx.of({ - rawResponse: { - _scroll_id: 'test', - hits: { - hits: range(0, 5).map(() => ({ + mockDataClient.search = jest + .fn() + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + range(0, 5).map(() => ({ + _index: 'lasdf', + _id: 'lasdf123', fields: { date: ['2020-12-31T00:14:28.000Z'], ip: ['110.135.176.89'], message: ['super cali fragile istic XPLA docious'], }, })), - total: 10, - }, - }, - }) - ); - - mockEsClient.asCurrentUser.scroll = jest.fn().mockImplementation(() => { - throw new esErrors.ResponseError({ statusCode: 403, meta: {} as any, warnings: [] }); - }); + 10 + ), + }) + ) + .mockImplementationOnce(() => { + throw new esErrors.ResponseError({ statusCode: 403, meta: {} as any, warnings: [] }); + }); const generateCsv = new CsvGenerator( createMockJob({ columns: ['date', 'ip', 'message'] }), @@ -1029,7 +976,7 @@ describe('error codes', () => { expect(mockLogger.error.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "CSV export scroll error: ResponseError: Response Error", + "CSV export search error: ResponseError: Response Error", ], Array [ [ResponseError: Response Error], diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index d287ec58530b9..f527956d5c7fa 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -5,15 +5,9 @@ * 2.0. */ -import { errors as esErrors } from '@elastic/elasticsearch'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { errors as esErrors, estypes } from '@elastic/elasticsearch'; import type { IScopedClusterClient, IUiSettingsClient, Logger } from '@kbn/core/server'; -import type { - DataView, - ISearchSource, - ISearchStartSearchSource, - SearchRequest, -} from '@kbn/data-plugin/common'; +import type { ISearchSource, ISearchStartSearchSource } from '@kbn/data-plugin/common'; import { cellHasFormulas, ES_SEARCH_STRATEGY, tabifyDocs } from '@kbn/data-plugin/common'; import type { IScopedSearchClient } from '@kbn/data-plugin/server'; import type { Datatable } from '@kbn/expressions-plugin/server'; @@ -61,21 +55,63 @@ export class CsvGenerator { private stream: Writable ) {} - private async scan(index: DataView, searchSource: ISearchSource, settings: CsvExportSettings) { + private async openPointInTime(indexPatternTitle: string, settings: CsvExportSettings) { + const { duration } = settings.scroll; + let pitId: string | undefined; + this.logger.debug(`Requesting point-in-time for: [${indexPatternTitle}]...`); + try { + // NOTE: if ES is overloaded, this request could time out + const response = await this.clients.es.asCurrentUser.openPointInTime( + { + index: indexPatternTitle, + keep_alive: duration, + ignore_unavailable: true, + }, + { + requestTimeout: duration, + maxRetries: 0, + } + ); + pitId = response.id; + } catch (err) { + this.logger.error(err); + } + + if (!pitId) { + throw new Error(`Could not receive a point-in-time ID!`); + } + + this.logger.debug(`Opened PIT ID: ${this.truncatePitId(pitId)}`); + + return pitId; + } + + private async doSearch( + searchSource: ISearchSource, + settings: CsvExportSettings, + searchAfter?: estypes.SortResults + ) { const { scroll: scrollSettings, includeFrozen } = settings; - const searchBody: SearchRequest | undefined = searchSource.getSearchRequestBody(); + searchSource.setField('size', scrollSettings.size); + + if (searchAfter) { + searchSource.setField('searchAfter', searchAfter); + } + + const pitId = searchSource.getField('pit')?.id; + this.logger.debug( + `Executing search request with PIT ID: [${this.truncatePitId(pitId)}]` + + (searchAfter ? ` search_after: [${searchAfter}]` : '') + ); + + const searchBody: estypes.SearchRequest = searchSource.getSearchRequestBody(); if (searchBody == null) { throw new Error('Could not retrieve the search body!'); } - this.logger.debug(`Tracking total hits with: track_total_hits=${searchBody.track_total_hits}`); - this.logger.info(`Executing search request...`); const searchParams = { params: { body: searchBody, - index: index.title, - scroll: scrollSettings.duration, - size: scrollSettings.size, ignore_throttled: includeFrozen ? false : undefined, // "true" will cause deprecation warnings logged in ES }, }; @@ -88,35 +124,19 @@ export class CsvGenerator { strategy: ES_SEARCH_STRATEGY, transport: { maxRetries: 0, // retrying reporting jobs is handled in the task manager scheduling logic - requestTimeout: this.config.scroll.duration, + requestTimeout: scrollSettings.duration, }, }) ) - ).rawResponse as estypes.SearchResponse; + ).rawResponse; } catch (err) { - this.logger.error(`CSV export scan error: ${err}`); + this.logger.error(`CSV export search error: ${err}`); throw err; } return results; } - private async scroll(scrollId: string, scrollSettings: CsvExportSettings['scroll']) { - this.logger.info(`Executing scroll request...`); - - let results: estypes.SearchResponse | undefined; - try { - results = await this.clients.es.asCurrentUser.scroll({ - scroll: scrollSettings.duration, - scroll_id: scrollId, - }); - } catch (err) { - this.logger.error(`CSV export scroll error: ${err}`); - throw err; - } - return results; - } - /* * Load field formats for each field in the list */ @@ -202,7 +222,7 @@ export class CsvGenerator { builder: MaxSizeStringBuilder, settings: CsvExportSettings ) { - this.logger.debug(`Building CSV header row...`); + this.logger.debug(`Building CSV header row`); const header = Array.from(columns).map(this.escapeValues(settings)).join(settings.separator) + '\n'; @@ -225,7 +245,7 @@ export class CsvGenerator { formatters: Record, settings: CsvExportSettings ) { - this.logger.debug(`Building ${table.rows.length} CSV data rows...`); + this.logger.debug(`Building ${table.rows.length} CSV data rows`); for (const dataTableRow of table.rows) { if (this.cancellationToken.isCancelled()) { break; @@ -293,26 +313,28 @@ export class CsvGenerator { throw new Error(`The search must have a reference to an index pattern!`); } - const { maxSizeBytes, bom, escapeFormulaValues, scroll: scrollSettings } = settings; - + const { maxSizeBytes, bom, escapeFormulaValues, timezone } = settings; + const indexPatternTitle = index.getIndexPattern(); const builder = new MaxSizeStringBuilder(this.stream, byteSizeValueToNumber(maxSizeBytes), bom); const warnings: string[] = []; let first = true; let currentRecord = -1; let totalRecords: number | undefined; let totalRelation = 'eq'; - let scrollId: string | undefined; + let searchAfter: estypes.SortResults | undefined; + + let pitId = await this.openPointInTime(indexPatternTitle, settings); // apply timezone from the job to all date field formatters try { index.fields.getByType('date').forEach(({ name }) => { - this.logger.debug(`setting timezone on ${name}`); + this.logger.debug(`Setting timezone on ${name}`); const format: FieldFormatConfig = { ...index.fieldFormatMap[name], id: index.fieldFormatMap[name]?.id || 'date', // allow id: date_nanos params: { ...index.fieldFormatMap[name]?.params, - timezone: settings.timezone, + timezone, }, }; index.setFieldFormat(name, format); @@ -327,24 +349,20 @@ export class CsvGenerator { if (this.cancellationToken.isCancelled()) { break; } - let results: estypes.SearchResponse | undefined; - if (scrollId == null) { - // open a scroll cursor in Elasticsearch - results = await this.scan(index, searchSource, settings); - scrollId = results?._scroll_id; - if (results?.hits?.total != null) { - const { hits } = results; - if (typeof hits.total === 'number') { - totalRecords = hits.total; - } else { - totalRecords = hits.total?.value; - totalRelation = hits.total?.relation ?? 'unknown'; - } - this.logger.info(`Total hits: [${totalRecords}].` + `Accuracy: ${totalRelation}`); + // set the latest pit, which could be different from the last request + searchSource.setField('pit', { id: pitId, keep_alive: settings.scroll.duration }); + + const results = await this.doSearch(searchSource, settings, searchAfter); + + const { hits } = results; + if (first && hits.total != null) { + if (typeof hits.total === 'number') { + totalRecords = hits.total; + } else { + totalRecords = hits.total?.value; + totalRelation = hits.total?.relation ?? 'unknown'; } - } else { - // use the scroll cursor in Elasticsearch - results = await this.scroll(scrollId, scrollSettings); + this.logger.info(`Total hits ${totalRelation} ${totalRecords}.`); } if (!results) { @@ -352,13 +370,35 @@ export class CsvGenerator { break; } - // TODO check for shard failures, log them and add a warning if found - { - const { - hits: { hits, ...hitsMeta }, - ...header - } = results; - this.logger.debug('Results metadata: ' + JSON.stringify({ header, hitsMeta })); + const { + hits: { hits: _hits, ...hitsMeta }, + ...headerWithPit + } = results; + + const { pit_id: newPitId, ...header } = headerWithPit; + + const logInfo = { + header: { pit_id: `${this.truncatePitId(newPitId)}`, ...header }, + hitsMeta, + }; + this.logger.debug(`Results metadata: ${JSON.stringify(logInfo)}`); + + // use the most recently received id for the next search request + this.logger.debug(`Received PIT ID: [${this.truncatePitId(results.pit_id)}]`); + pitId = results.pit_id ?? pitId; + + // Update last sort results for next query. PIT is used, so the sort results + // automatically include _shard_doc as a tiebreaker + searchAfter = hits.hits[hits.hits.length - 1]?.sort as estypes.SortResults | undefined; + this.logger.debug(`Received search_after: [${searchAfter}]`); + + // check for shard failures, log them and add a warning if found + const { _shards: shards } = header; + if (shards.failures) { + shards.failures.forEach(({ reason }) => { + warnings.push(`Shard failure: ${JSON.stringify(reason)}`); + this.logger.warn(JSON.stringify(reason)); + }); } let table: Datatable | undefined; @@ -411,16 +451,12 @@ export class CsvGenerator { warnings.push(i18nTexts.unknownError(err?.message ?? err)); } } finally { - // clear scrollID - if (scrollId) { - this.logger.debug(`Executing clearScroll request`); - try { - await this.clients.es.asCurrentUser.clearScroll({ scroll_id: [scrollId] }); - } catch (err) { - this.logger.error(err); - } + // + if (pitId) { + this.logger.debug(`Closing point-in-time`); + await this.clients.es.asCurrentUser.closePointInTime({ body: { id: pitId } }); } else { - this.logger.warn(`No scrollId to clear!`); + this.logger.warn(`No PIT ID to clear!`); } } @@ -429,7 +465,7 @@ export class CsvGenerator { if (!this.maxSizeReached && this.csvRowCount !== totalRecords) { this.logger.warn( `ES scroll returned fewer total hits than expected! ` + - `Search result total hits: ${totalRecords}. Row count: ${this.csvRowCount}.` + `Search result total hits: ${totalRecords}. Row count: ${this.csvRowCount}` ); warnings.push( i18nTexts.csvRowCountError({ expected: totalRecords ?? NaN, received: this.csvRowCount }) @@ -447,4 +483,8 @@ export class CsvGenerator { error_code: reportingError?.code, }; } + + private truncatePitId(pitId: string | undefined) { + return pitId?.substring(0, 12) + '...'; + } } diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts index 32406f7a87fca..e01ab0105a5d5 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts @@ -43,6 +43,9 @@ it('matches snapshot', () => { "kibana.alert.end": Object { "type": "date", }, + "kibana.alert.flapping": Object { + "type": "boolean", + }, "kibana.alert.instance.id": Object { "required": true, "type": "keyword", diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts index 2233f2d977010..82994950dfd04 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts @@ -33,6 +33,7 @@ export const technicalRuleFieldMap = { [Fields.ALERT_DURATION]: { type: 'long' }, [Fields.ALERT_SEVERITY]: { type: 'keyword' }, [Fields.ALERT_STATUS]: { type: 'keyword', required: true }, + [Fields.ALERT_FLAPPING]: { type: 'boolean' }, [Fields.VERSION]: { type: 'version', array: false, diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts index abf5ec53b537c..6f7dafd3e495f 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts @@ -37,6 +37,7 @@ import { TAGS, TIMESTAMP, VERSION, + // ALERT_FLAPPING, } from '../../common/technical_rule_data_field_names'; import { CommonAlertFieldNameLatest, CommonAlertIdFieldNameLatest } from '../../common/schemas'; import { IRuleDataClient } from '../rule_data_client'; diff --git a/x-pack/plugins/stack_connectors/common/opsgenie.ts b/x-pack/plugins/stack_connectors/common/opsgenie/index.ts similarity index 100% rename from x-pack/plugins/stack_connectors/common/opsgenie.ts rename to x-pack/plugins/stack_connectors/common/opsgenie/index.ts diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.test.tsx new file mode 100644 index 0000000000000..b876e19354e7a --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, within, fireEvent, waitFor } from '@testing-library/react'; +import { CloseAlert } from './close_alert'; +import userEvent from '@testing-library/user-event'; + +describe('CloseAlert', () => { + const editSubAction = jest.fn(); + const editOptionalSubAction = jest.fn(); + + const options = { + showSaveError: false, + errors: { + 'subActionParams.message': [], + 'subActionParams.alias': [], + }, + index: 0, + editSubAction, + editOptionalSubAction, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('does not render the additional options by default', () => { + render(); + + expect(screen.queryByTestId('opsgenie-source-row')).not.toBeInTheDocument(); + }); + + it('renders the form fields by default', () => { + render(); + + expect(screen.getByTestId('opsgenie-alias-row')).toBeInTheDocument(); + expect(screen.getByText('Note')).toBeInTheDocument(); + }); + + it('renders the form fields with the subActionParam values', () => { + render( + + ); + + expect( + within(screen.getByTestId('opsgenie-alias-row')).getByDisplayValue('an alias') + ).toBeInTheDocument(); + expect(within(screen.getByTestId('noteTextArea')).getByText('a note')).toBeInTheDocument(); + }); + + it('renders the additional form fields with the subActionParam values', () => { + render( + + ); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + expect( + within(screen.getByTestId('opsgenie-source-row')).getByDisplayValue('a source') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-user-row')).getByDisplayValue('a user') + ).toBeInTheDocument(); + }); + + it.each([ + ['alias', 'aliasInput', 'an alias', editSubAction], + ['note', 'noteTextArea', 'a note', editOptionalSubAction], + ['source', 'sourceInput', 'a source', editOptionalSubAction], + ['user', 'userInput', 'a user', editOptionalSubAction], + ])( + 'calls the callback for field %s data-test-subj %s with input %s', + (field, dataTestSubj, input, callback) => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + fireEvent.change(screen.getByTestId(dataTestSubj), { target: { value: input } }); + + expect(callback.mock.calls[0]).toEqual([field, input, 0]); + } + ); + + it('shows the additional options when clicking the more options button', () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + expect(screen.getByTestId('opsgenie-source-row')).toBeInTheDocument(); + }); + + it('shows the message required error when showSaveError is true', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('MessageError')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx new file mode 100644 index 0000000000000..82b0b5c061f77 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + ActionParamsProps, + TextAreaWithMessageVariables, + TextFieldWithMessageVariables, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer, RecursivePartial } from '@elastic/eui'; +import type { + OpsgenieActionParams, + OpsgenieCloseAlertParams, +} from '../../../../server/connector_types/stack'; +import * as i18n from './translations'; +import { EditActionCallback } from './types'; +import { DisplayMoreOptions } from './display_more_options'; + +type AdditionalOptionsProps = Pick< + CloseAlertProps, + 'subActionParams' | 'editOptionalSubAction' | 'index' | 'messageVariables' +>; + +const AdditionalOptions: React.FC = ({ + subActionParams, + editOptionalSubAction, + index, + messageVariables, +}) => { + return ( + <> + + + + + + + + + + + + + + + ); +}; + +AdditionalOptions.displayName = 'AdditionalOptions'; + +type CloseAlertProps = Pick< + ActionParamsProps, + 'errors' | 'index' | 'messageVariables' +> & { + subActionParams?: RecursivePartial; + editSubAction: EditActionCallback; + editOptionalSubAction: EditActionCallback; + showSaveError: boolean; +}; + +const CloseAlertComponent: React.FC = ({ + editSubAction, + editOptionalSubAction, + errors, + index, + messageVariables, + subActionParams, + showSaveError, +}) => { + const isAliasInvalid = + (errors['subActionParams.alias'] !== undefined && + errors['subActionParams.alias'].length > 0 && + subActionParams?.alias !== undefined) || + showSaveError; + + const [showingMoreOptions, setShowingMoreOptions] = useState(false); + const toggleShowingMoreOptions = useCallback( + () => setShowingMoreOptions((previousState) => !previousState), + [] + ); + + return ( + <> + + + + + + {showingMoreOptions ? ( + + ) : null} + + + + ); +}; + +CloseAlertComponent.displayName = 'CloseAlert'; + +export const CloseAlert = React.memo(CloseAlertComponent); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.test.tsx new file mode 100644 index 0000000000000..89fe3678372ad --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, within, fireEvent } from '@testing-library/react'; +import { AdditionalOptions } from './additional_options'; + +describe('AdditionalOptions', () => { + const editOptionalSubAction = jest.fn(); + + const options = { + index: 0, + editOptionalSubAction, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders the component with empty states', () => { + render(); + + expect(screen.getByTestId('opsgenie-entity-row')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-source-row')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-user-row')).toBeInTheDocument(); + expect(screen.getByText('Note')).toBeInTheDocument(); + }); + + it('renders with the subActionParams displayed in the fields', async () => { + render( + + ); + + expect( + within(screen.getByTestId('opsgenie-entity-row')).getByDisplayValue('entity') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-entity-row')).getByDisplayValue('entity') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-source-row')).getByDisplayValue('source') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-user-row')).getByDisplayValue('user') + ).toBeInTheDocument(); + expect(within(screen.getByTestId('noteTextArea')).getByText('note')).toBeInTheDocument(); + }); + + it.each([ + ['entity', 'entityInput', 'an entity'], + ['source', 'sourceInput', 'a source'], + ['user', 'userInput', 'a user'], + ['note', 'noteTextArea', 'a note'], + ])( + 'calls the callback for field %s data-test-subj %s with input %s', + (field, dataTestSubj, input) => { + render(); + + fireEvent.change(screen.getByTestId(dataTestSubj), { target: { value: input } }); + + expect(editOptionalSubAction.mock.calls[0]).toEqual([field, input, 0]); + } + ); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.tsx new file mode 100644 index 0000000000000..7c931d8834639 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + TextAreaWithMessageVariables, + TextFieldWithMessageVariables, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer } from '@elastic/eui'; + +import * as i18n from './translations'; +import { CreateAlertProps } from '.'; + +type AdditionalOptionsProps = Pick< + CreateAlertProps, + 'subActionParams' | 'editOptionalSubAction' | 'messageVariables' | 'index' +>; + +const AdditionalOptionsComponent: React.FC = ({ + subActionParams, + editOptionalSubAction, + messageVariables, + index, +}) => { + return ( + <> + + + + + + + + + + + + + + + + + + + + ); +}; + +AdditionalOptionsComponent.displayName = 'AdditionalOptions'; + +export const AdditionalOptions = React.memo(AdditionalOptionsComponent); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.test.tsx new file mode 100644 index 0000000000000..4bdd32fbb1386 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.test.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, within, fireEvent, waitFor } from '@testing-library/react'; +import { CreateAlert } from '.'; +import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock'; +import userEvent from '@testing-library/user-event'; + +const kibanaReactPath = '../../../../../../../../src/plugins/kibana_react/public'; + +jest.mock(kibanaReactPath, () => { + const original = jest.requireActual(kibanaReactPath); + return { + ...original, + CodeEditor: (props: any) => { + return ; + }, + }; +}); + +describe('CreateAlert', () => { + const editSubAction = jest.fn(); + const editAction = jest.fn(); + const editOptionalSubAction = jest.fn(); + + const options = { + showSaveError: false, + errors: { + 'subActionParams.message': [], + 'subActionParams.alias': [], + }, + index: 0, + editAction, + editSubAction, + editOptionalSubAction, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('does not render the json editor by default', () => { + render(); + + expect(screen.queryByTestId('actionJsonEditor')).not.toBeInTheDocument(); + }); + + it('does not render the additional options by default', () => { + render(); + + expect(screen.queryByTestId('opsgenie-entity-row')).not.toBeInTheDocument(); + }); + + it('renders the form fields by default', () => { + render(); + + expect(screen.getByTestId('opsgenie-message-row')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-alias-row')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-tags')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-prioritySelect')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + }); + + it('renders the form fields with the subActionParam values', () => { + render( + + ); + + expect(within(screen.getByTestId('opsgenie-tags')).getByText('super tag')).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-message-row')).getByDisplayValue('a message') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-alias-row')).getByDisplayValue('an alias') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('descriptionTextArea')).getByText('a description') + ).toBeInTheDocument(); + }); + + it.each([ + ['message', 'messageInput', 'a message', editSubAction], + ['alias', 'aliasInput', 'an alias', editOptionalSubAction], + ['description', 'descriptionTextArea', 'a description', editOptionalSubAction], + ])( + 'calls the callback for field %s data-test-subj %s with input %s', + (field, dataTestSubj, input, callback) => { + render(); + + fireEvent.change(screen.getByTestId(dataTestSubj), { target: { value: input } }); + + expect(callback.mock.calls[0]).toEqual([field, input, 0]); + } + ); + + it('shows the json editor when clicking the editor toggle', async () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-show-json-editor-toggle')); + + await waitFor(() => { + expect(screen.getByTestId('actionJsonEditor')).toBeInTheDocument(); + expect(screen.queryByTestId('opsgenie-message-row')).not.toBeInTheDocument(); + expect(screen.queryByTestId('opsgenie-alias-row')).not.toBeInTheDocument(); + expect(screen.queryByText('Description')).not.toBeInTheDocument(); + }); + }); + + it('shows the additional options when clicking the more options button', () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + expect(screen.getByTestId('opsgenie-entity-row')).toBeInTheDocument(); + }); + + it('sets the json editor error to undefined when the toggle is switched off', async () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-show-json-editor-toggle')); + + await waitFor(() => { + expect(screen.getByTestId('actionJsonEditor')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('opsgenie-show-json-editor-toggle')); + + await waitFor(() => { + expect(screen.queryByTestId('actionJsonEditor')).not.toBeInTheDocument(); + // first call to edit actions is because the editor was rendered and validation failed + expect(editAction.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "jsonEditorError", + true, + 0, + ], + Array [ + "jsonEditorError", + undefined, + 0, + ], + ] + `); + }); + }); + + it('shows the message required error when showSaveError is true', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('MessageError')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx new file mode 100644 index 0000000000000..e5ca5c3741f5d --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense, useCallback, useState } from 'react'; +import { + ActionParamsProps, + TextAreaWithMessageVariables, + TextFieldWithMessageVariables, + SectionLoading, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { + EuiErrorBoundary, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; +import type { + OpsgenieActionParams, + OpsgenieCreateAlertParams, +} from '../../../../../server/connector_types/stack'; +import * as i18n from './translations'; +import { EditActionCallback } from '../types'; +import { DisplayMoreOptions } from '../display_more_options'; +import { AdditionalOptions } from './additional_options'; +import { Tags } from './tags'; +import { Priority } from './priority'; +import type { JsonEditorProps } from './json_editor'; + +const JsonEditorLazy: React.FC = lazy(() => import('./json_editor')); + +type FormViewProps = Omit; + +const FormView: React.FC = ({ + editSubAction, + editOptionalSubAction, + errors, + index, + messageVariables, + subActionParams, + showSaveError, +}) => { + const isMessageInvalid = + (errors['subActionParams.message'] !== undefined && + errors['subActionParams.message'].length > 0 && + subActionParams?.message !== undefined) || + showSaveError; + + return ( + <> + + + + + + + + + + + + + + + + + + + ); +}; + +FormView.displayName = 'FormView'; + +export type CreateAlertProps = Pick< + ActionParamsProps, + 'errors' | 'index' | 'messageVariables' | 'editAction' +> & { + subActionParams?: Partial; + editSubAction: EditActionCallback; + editOptionalSubAction: EditActionCallback; + showSaveError: boolean; +}; + +const CreateAlertComponent: React.FC = ({ + editSubAction, + editAction, + editOptionalSubAction, + errors, + index, + messageVariables, + subActionParams, + showSaveError, +}) => { + const [showingMoreOptions, setShowingMoreOptions] = useState(false); + const [showJsonEditor, setShowJsonEditor] = useState(false); + + const toggleShowJsonEditor = useCallback( + (event) => { + if (!event.target.checked) { + // when the user switches back remove the json editor error if there was one + // must mark as undefined to remove the field so it is not sent to the server side + editAction('jsonEditorError', undefined, index); + } + setShowJsonEditor(event.target.checked); + }, + [editAction, index] + ); + + const toggleShowingMoreOptions = useCallback( + () => setShowingMoreOptions((previousState) => !previousState), + [] + ); + + return ( + <> + + + + {showJsonEditor ? ( + + {i18n.LOADING_JSON_EDITOR}}> + + + + ) : ( + <> + + {showingMoreOptions ? ( + + ) : null} + + + + )} + + ); +}; + +CreateAlertComponent.displayName = 'CreateAlert'; + +export const CreateAlert = React.memo(CreateAlertComponent); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.test.tsx new file mode 100644 index 0000000000000..9de476b33d7bc --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.test.tsx @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, within, fireEvent, waitFor } from '@testing-library/react'; +import JsonEditor from './json_editor'; +import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock'; + +const kibanaReactPath = '../../../../../../../../src/plugins/kibana_react/public'; + +jest.mock(kibanaReactPath, () => { + const original = jest.requireActual(kibanaReactPath); + return { + ...original, + CodeEditor: (props: any) => { + return ; + }, + }; +}); + +describe('JsonEditor', () => { + const editAction = jest.fn(); + + const options = { + index: 0, + editAction, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('sets the default value for the json editor to {}', () => { + render(); + + expect( + within(screen.getByTestId('actionJsonEditor')).getByDisplayValue('{}') + ).toBeInTheDocument(); + }); + + it('displays an error for the message field initially', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('[message]: expected value of type [string] but got [undefined]') + ).toBeInTheDocument(); + }); + }); + + it('calls editActions setting the error state to true', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('[message]: expected value of type [string] but got [undefined]') + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith('jsonEditorError', true, 0); + }); + }); + + it('calls editActions setting the error state to true twice', async () => { + render(); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: 'invalid json' }, + }); + + // first time is from the useEffect, second is from the fireEvent + expect(editAction.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "jsonEditorError", + true, + 0, + ], + Array [ + "jsonEditorError", + true, + 0, + ], + ] + `); + }); + + it('calls the callback when the json input is valid', async () => { + render(); + + const validJson = JSON.stringify({ message: 'awesome' }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect(editAction).toHaveBeenCalledWith('subActionParams', { message: 'awesome' }, 0); + }); + + it('does not show an error when the message field is a valid non empty string', async () => { + render(); + + const validJson = JSON.stringify({ message: 'awesome' }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect( + screen.queryByText('[message]: expected value of type [string] but got [undefined]') + ).not.toBeInTheDocument(); + }); + + it('shows an error when the message field is only spaces', async () => { + render(); + + const validJson = JSON.stringify({ message: ' ' }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect( + screen.getByText('[message]: must be populated with a value other than just whitespace') + ).toBeInTheDocument(); + }); + + it('calls editAction setting editor error to true when validation fails', async () => { + render(); + + const validJson = JSON.stringify({ + tags: 'tags should be an array not a string', + message: 'a message', + }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect( + screen.getByText('Invalid value "tags should be an array not a string" supplied to "tags"') + ).toBeInTheDocument(); + expect(editAction.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "jsonEditorError", + true, + 0, + ], + Array [ + "jsonEditorError", + true, + 0, + ], + ] + `); + }); + + it('calls the callback with only the message field after editing the json', async () => { + render( + + ); + + const validJson = JSON.stringify({ + message: 'a new message', + }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect(editAction).toHaveBeenCalledWith('subActionParams', { message: 'a new message' }, 0); + }); + + it('sets the editor error to undefined when validation succeeds', async () => { + render( + + ); + + await waitFor(() => { + expect(editAction.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "jsonEditorError", + undefined, + 0, + ], + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx new file mode 100644 index 0000000000000..91147204e46f4 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx @@ -0,0 +1,119 @@ +/* + * 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, useEffect, useMemo, useState } from 'react'; +import { isEmpty } from 'lodash'; +import { JsonEditorWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; +import type { OpsgenieCreateAlertParams } from '../../../../../server/connector_types/stack'; +import * as i18n from './translations'; +import { CreateAlertProps } from '.'; +import { decodeCreateAlert, isDecodeError } from './schema'; + +export type JsonEditorProps = Pick< + CreateAlertProps, + 'editAction' | 'index' | 'messageVariables' | 'subActionParams' +>; + +const JsonEditorComponent: React.FC = ({ + editAction, + index, + messageVariables, + subActionParams, +}) => { + const [jsonEditorErrors, setJsonEditorErrors] = useState([]); + + const jsonEditorValue = useMemo(() => getJsonEditorValue(subActionParams), [subActionParams]); + + const decodeJsonWithSchema = useCallback((jsonBlob: unknown) => { + try { + const decodedValue = decodeCreateAlert(jsonBlob); + setJsonEditorErrors([]); + return decodedValue; + } catch (error) { + if (isDecodeError(error)) { + setJsonEditorErrors(error.decodeErrors); + } else { + setJsonEditorErrors([error.message]); + } + + return; + } + }, []); + + const onAdvancedEditorChange = useCallback( + (json: string) => { + const parsedJson = parseJson(json); + if (!parsedJson) { + editAction('jsonEditorError', true, index); + + return; + } + + const decodedValue = decodeJsonWithSchema(parsedJson); + if (!decodedValue) { + editAction('jsonEditorError', true, index); + + return; + } + + editAction('subActionParams', decodedValue, index); + }, + [editAction, index, decodeJsonWithSchema] + ); + + useEffect(() => { + // show the initial error messages + const decodedValue = decodeJsonWithSchema(subActionParams ?? {}); + if (!decodedValue) { + editAction('jsonEditorError', true, index); + } else { + // must mark as undefined to remove the field so it is not sent to the server side + editAction('jsonEditorError', undefined, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subActionParams, decodeJsonWithSchema, index]); + + return ( + + ); +}; + +JsonEditorComponent.displayName = 'JsonEditor'; + +const JsonEditor = React.memo(JsonEditorComponent); + +// eslint-disable-next-line import/no-default-export +export { JsonEditor as default }; + +const parseJson = (jsonValue: string): Record | undefined => { + try { + return JSON.parse(jsonValue); + } catch (error) { + return; + } +}; + +const getJsonEditorValue = (subActionParams?: Partial) => { + const defaultValue = '{}'; + try { + const value = JSON.stringify(subActionParams, null, 2); + if (isEmpty(value)) { + return defaultValue; + } + return value; + } catch (error) { + return defaultValue; + } +}; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.test.tsx new file mode 100644 index 0000000000000..dd6478ae7e653 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Priority } from './priority'; + +describe('Priority', () => { + const onChange = jest.fn(); + + const options = { + priority: undefined, + onChange, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders the priority selectable', () => { + render(); + + expect(screen.getByTestId('opsgenie-prioritySelect')).toBeInTheDocument(); + }); + + it('calls onChange when P1 is selected', async () => { + render(); + + userEvent.selectOptions(screen.getByTestId('opsgenie-prioritySelect'), 'P1'); + + await waitFor(() => + expect(onChange.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "priority", + "P1", + ] + `) + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.tsx new file mode 100644 index 0000000000000..2a47e67b86963 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.tsx @@ -0,0 +1,73 @@ +/* + * 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 } from 'react'; + +import { EuiFormRow, EuiSelect } from '@elastic/eui'; + +import type { OpsgenieCreateAlertParams } from '../../../../../server/connector_types/stack'; +import * as i18n from './translations'; +import { EditActionCallback } from '../types'; + +interface PriorityComponentProps { + priority: OpsgenieCreateAlertParams['priority']; + onChange: EditActionCallback; +} + +const PriorityComponent: React.FC = ({ priority, onChange }) => { + const onPriorityChange = useCallback( + (event: React.ChangeEvent) => { + onChange('priority', event.target.value); + }, + [onChange] + ); + + return ( + + + + ); +}; + +PriorityComponent.displayName = 'Priority'; + +export const Priority = React.memo(PriorityComponent); + +const priorityOptions = [ + { + value: i18n.PRIORITY_1, + text: i18n.PRIORITY_1, + ['data-test-subj']: 'opsgenie-priority-p1', + }, + { + value: i18n.PRIORITY_2, + text: i18n.PRIORITY_2, + ['data-test-subj']: 'opsgenie-priority-p2', + }, + { + value: i18n.PRIORITY_3, + text: i18n.PRIORITY_3, + ['data-test-subj']: 'opsgenie-priority-p3', + }, + { + value: i18n.PRIORITY_4, + text: i18n.PRIORITY_4, + ['data-test-subj']: 'opsgenie-priority-p4', + }, + { + value: i18n.PRIORITY_5, + text: i18n.PRIORITY_5, + ['data-test-subj']: 'opsgenie-priority-p5', + }, +]; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts new file mode 100644 index 0000000000000..f2aec179e8676 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { decodeCreateAlert } from './schema'; +import { + OpsgenieCreateAlertExample, + ValidCreateAlertSchema, +} from '../../../../../server/connector_types/stack/opsgenie/test_schema'; + +describe('decodeCreateAlert', () => { + it('throws an error when the message field is not present', () => { + expect(() => decodeCreateAlert({ alias: '123' })).toThrowErrorMatchingInlineSnapshot( + `"[message]: expected value of type [string] but got [undefined]"` + ); + }); + + it('throws an error when the message field is only spaces', () => { + expect(() => decodeCreateAlert({ message: ' ' })).toThrowErrorMatchingInlineSnapshot( + `"[message]: must be populated with a value other than just whitespace"` + ); + }); + + it('throws an error when the message field is an empty string', () => { + expect(() => decodeCreateAlert({ message: '' })).toThrowErrorMatchingInlineSnapshot( + `"[message]: must be populated with a value other than just whitespace"` + ); + }); + + it('throws an error when additional fields are present in the data that are not defined in the schema', () => { + expect(() => + decodeCreateAlert({ invalidField: 'hi', message: 'hi' }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in responders with name field than in the schema', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + responders: [{ name: 'sam', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in responders with id field than in the schema', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + responders: [{ id: 'id', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in visibleTo with name and type=team', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ name: 'sam', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in visibleTo with id and type=team', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ id: 'id', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in visibleTo with id and type=user', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ id: 'id', type: 'user', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in visibleTo with username and type=user', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ username: 'sam', type: 'user', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when details is a record of string to number', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + details: { id: 1 }, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid value \\"1\\" supplied to \\"details.id\\""`); + }); + + it.each([ + ['ValidCreateAlertSchema', ValidCreateAlertSchema], + ['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample], + ])('validates the test object [%s] correctly', (objectName, testObject) => { + expect(() => decodeCreateAlert(testObject)).not.toThrow(); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts new file mode 100644 index 0000000000000..2a76527c6c355 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts @@ -0,0 +1,142 @@ +/* + * 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 { Either, fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; +import { exactCheck } from '@kbn/securitysolution-io-ts-utils'; +import { identity } from 'fp-ts/lib/function'; +import { isEmpty, isObject } from 'lodash'; +import * as i18n from './translations'; + +const MessageNonEmptyString = new rt.Type( + 'MessageNonEmptyString', + rt.string.is, + (input, context): Either => { + if (input === undefined) { + return rt.failure(input, context, i18n.MESSAGE_NOT_DEFINED); + } else if (typeof input !== 'string') { + return rt.failure(input, context); + } else if (isEmpty(input.trim())) { + return rt.failure(input, context, i18n.MESSAGE_NON_WHITESPACE); + } else { + return rt.success(input); + } + }, + rt.identity +); + +const ResponderTypes = rt.union([ + rt.literal('team'), + rt.literal('user'), + rt.literal('escalation'), + rt.literal('schedule'), +]); + +/** + * This schema is duplicated from the server. The only difference is that it is using io-ts vs kbn-schema. + * NOTE: This schema must be the same as defined here: x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts + * + * The reason it is duplicated here is because the server uses kbn-schema which uses Joi under the hood. If we import + * Joi on the frontend it will cause ~500KB of data to be loaded on page loads. To avoid this we'll use io-ts in the frontend. + * Ideally we could use io-ts in the backend as well but the server requires kbn-schema to be used. + * + * Issue: https://github.com/elastic/kibana/issues/143891 + * + * For more information on the Opsgenie create alert schema see: https://docs.opsgenie.com/docs/alert-api#create-alert + */ +const CreateAlertSchema = rt.intersection([ + rt.strict({ message: MessageNonEmptyString }), + rt.exact( + rt.partial({ + alias: rt.string, + description: rt.string, + responders: rt.array( + rt.union([ + rt.strict({ name: rt.string, type: ResponderTypes }), + rt.strict({ id: rt.string, type: ResponderTypes }), + rt.strict({ username: rt.string, type: rt.literal('user') }), + ]) + ), + visibleTo: rt.array( + rt.union([ + rt.strict({ name: rt.string, type: rt.literal('team') }), + rt.strict({ id: rt.string, type: rt.literal('team') }), + rt.strict({ id: rt.string, type: rt.literal('user') }), + rt.strict({ username: rt.string, type: rt.literal('user') }), + ]) + ), + actions: rt.array(rt.string), + tags: rt.array(rt.string), + details: rt.record(rt.string, rt.string), + entity: rt.string, + source: rt.string, + priority: rt.union([ + rt.literal('P1'), + rt.literal('P2'), + rt.literal('P3'), + rt.literal('P4'), + rt.literal('P5'), + ]), + user: rt.string, + note: rt.string, + }) + ), +]); + +export const formatErrors = (errors: rt.Errors): string[] => { + const err = errors.map((error) => { + if (error.message != null) { + return error.message; + } else { + const keyContext = error.context + .filter( + (entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== '' + ) + .map((entry) => entry.key) + .join('.'); + + const nameContext = error.context.find( + (entry) => entry.type != null && entry.type.name != null && entry.type.name.length > 0 + ); + + const suppliedValue = + keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : ''; + const value = isObject(error.value) ? JSON.stringify(error.value) : error.value; + return `Invalid value "${value}" supplied to "${suppliedValue}"`; + } + }); + + return [...new Set(err)]; +}; + +type CreateAlertSchemaType = rt.TypeOf; + +export const decodeCreateAlert = (data: unknown): CreateAlertSchemaType => { + const onLeft = (errors: rt.Errors) => { + throw new DecodeError(formatErrors(errors)); + }; + + const onRight = (a: CreateAlertSchemaType): CreateAlertSchemaType => identity(a); + + return pipe( + CreateAlertSchema.decode(data), + (decoded) => exactCheck(data, decoded), + fold(onLeft, onRight) + ); +}; + +export class DecodeError extends Error { + constructor(public readonly decodeErrors: string[]) { + super(decodeErrors.join()); + this.name = this.constructor.name; + } +} + +export function isDecodeError(error: unknown): error is DecodeError { + return error instanceof DecodeError; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx new file mode 100644 index 0000000000000..21deccf70b3c4 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Tags } from './tags'; + +describe('Tags', () => { + const onChange = jest.fn(); + + const options = { + values: [], + onChange, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders tags initially', () => { + render(); + + expect(screen.getByText('super')).toBeInTheDocument(); + expect(screen.getByText('hello')).toBeInTheDocument(); + }); + + it('clears the tags', async () => { + render(); + + userEvent.click(screen.getByTestId('comboBoxClearButton')); + + await waitFor(() => + expect(onChange.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "tags", + Array [], + ] + `) + ); + }); + + it('calls onChange when removing a tag', async () => { + render(); + + userEvent.click(screen.getByTitle('Remove super from selection in this group')); + + await waitFor(() => + expect(onChange.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "tags", + Array [ + "hello", + ], + ] + `) + ); + }); + + it('calls onChange when adding a tag', async () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-tags')); + userEvent.type(screen.getByTestId('comboBoxSearchInput'), 'awesome{enter}'); + + await waitFor(() => + expect(onChange.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "tags", + Array [ + "awesome", + ], + ] + `) + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx new file mode 100644 index 0000000000000..67fa34bb76877 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; + +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; + +import * as i18n from './translations'; +import { EditActionCallback } from '../types'; + +interface TagsProps { + onChange: EditActionCallback; + values: string[]; +} + +const TagsComponent: React.FC = ({ onChange, values }) => { + const tagOptions = useMemo(() => values.map((value) => getTagAsOption(value)), [values]); + + const onCreateOption = useCallback( + (tagValue: string) => { + const newTags = [...tagOptions, getTagAsOption(tagValue)]; + onChange( + 'tags', + newTags.map((tag) => tag.label) + ); + }, + [onChange, tagOptions] + ); + + const onTagsChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]) => { + onChange( + 'tags', + newOptions.map((option) => option.label) + ); + }, + [onChange] + ); + + return ( + + + + ); +}; + +TagsComponent.displayName = 'Tags'; + +export const Tags = React.memo(TagsComponent); + +const getTagAsOption = (value: string) => ({ label: value, key: value }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts new file mode 100644 index 0000000000000..5f7e9051af95f --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +export * from '../translations'; + +export const MESSAGE_NOT_DEFINED = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.messageNotDefined', + { defaultMessage: '[message]: expected value of type [string] but got [undefined]' } +); + +export const MESSAGE_NON_WHITESPACE = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.messageNotWhitespace', + { defaultMessage: '[message]: must be populated with a value other than just whitespace' } +); + +export const LOADING_JSON_EDITOR = i18n.translate( + 'xpack.stackConnectors.sections.ospgenie.loadingJsonEditor', + { defaultMessage: 'Loading JSON editor' } +); + +export const MESSAGE_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.messageLabel', + { + defaultMessage: 'Message (required)', + } +); + +export const DESCRIPTION_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.descriptionLabel', + { + defaultMessage: 'Description', + } +); + +export const MESSAGE_FIELD_IS_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.messageFieldRequired', + { + defaultMessage: '"message" field must be populated with a value other than just whitespace', + } +); + +export const USE_JSON_EDITOR_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.useJsonEditorLabel', + { + defaultMessage: 'Use JSON editor', + } +); + +export const ALERT_FIELDS_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.alertFieldsLabel', + { + defaultMessage: 'Alert fields', + } +); + +export const JSON_EDITOR_ARIA = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.jsonEditorAriaLabel', + { + defaultMessage: 'JSON editor', + } +); + +export const ENTITY_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.entityLabel', + { + defaultMessage: 'Entity', + } +); + +export const TAGS_HELP = i18n.translate('xpack.stackConnectors.components.opsgenie.tagsHelp', { + defaultMessage: + 'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.', +}); + +export const TAGS_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.tagsLabel', + { defaultMessage: 'Opsgenie tags' } +); + +export const PRIORITY_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.priorityLabel', + { + defaultMessage: 'Priority', + } +); + +export const PRIORITY_1 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority1', { + defaultMessage: 'P1', +}); + +export const PRIORITY_2 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority2', { + defaultMessage: 'P2', +}); + +export const PRIORITY_3 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority3', { + defaultMessage: 'P3', +}); + +export const PRIORITY_4 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority4', { + defaultMessage: 'P4', +}); + +export const PRIORITY_5 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority5', { + defaultMessage: 'P5', +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.test.tsx new file mode 100644 index 0000000000000..f2cfbe3911732 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DisplayMoreOptions } from './display_more_options'; + +describe('DisplayMoreOptions', () => { + const toggleShowingMoreOptions = jest.fn(); + + const options = { + showingMoreOptions: false, + toggleShowingMoreOptions, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders the more options text', () => { + render(); + + expect(screen.getByText('More options')).toBeInTheDocument(); + }); + + it('renders the hide options text', () => { + render(); + + expect(screen.getByText('Hide options')).toBeInTheDocument(); + }); + + it('calls toggleShowingMoreOptions when clicked', () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + expect(toggleShowingMoreOptions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.tsx new file mode 100644 index 0000000000000..fe58d18f7f179 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.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 { EuiButtonEmpty } from '@elastic/eui'; +import React from 'react'; +import * as i18n from './translations'; + +interface DisplayMoreOptionsProps { + showingMoreOptions: boolean; + toggleShowingMoreOptions: () => void; +} + +const DisplayMoreOptionsComponent: React.FC = ({ + showingMoreOptions, + toggleShowingMoreOptions, +}) => { + return ( + + {showingMoreOptions ? i18n.HIDE_OPTIONS : i18n.MORE_OPTIONS} + + ); +}; + +DisplayMoreOptionsComponent.displayName = 'MoreOptions'; + +export const DisplayMoreOptions = React.memo(DisplayMoreOptionsComponent); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/logo.tsx index 7e705a788c0c7..bf03bf0dafb44 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/logo.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/logo.tsx @@ -5,11 +5,56 @@ * 2.0. */ -import { EuiIcon } from '@elastic/eui'; import React from 'react'; import { LogoProps } from '../../types'; -const Logo = (props: LogoProps) => ; +const Logo = (props: LogoProps) => ( + + + + + + + + + + + + + + + +); // eslint-disable-next-line import/no-default-export export { Logo as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.test.tsx index f3b2c70a6e09f..c9bcdd08f111e 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.test.tsx @@ -41,6 +41,7 @@ describe('opsgenie action params validation', () => { errors: { 'subActionParams.message': [], 'subActionParams.alias': [], + jsonEditorError: [], }, }); }); @@ -57,6 +58,7 @@ describe('opsgenie action params validation', () => { errors: { 'subActionParams.message': [], 'subActionParams.alias': [], + jsonEditorError: [], }, }); }); @@ -71,6 +73,7 @@ describe('opsgenie action params validation', () => { errors: { 'subActionParams.message': ['Message is required.'], 'subActionParams.alias': [], + jsonEditorError: [], }, }); }); @@ -85,6 +88,21 @@ describe('opsgenie action params validation', () => { errors: { 'subActionParams.message': [], 'subActionParams.alias': ['Alias is required.'], + jsonEditorError: [], + }, + }); + }); + + it('sets the jsonEditorError when the jsonEditorError field is set to true', async () => { + const actionParams = { + jsonEditorError: true, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.message': [], + 'subActionParams.alias': [], + jsonEditorError: ['JSON editor error exists'], }, }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx index 03290fbeca7ad..0e3c0e311d3a0 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx @@ -11,14 +11,13 @@ import { ActionTypeModel as ConnectorTypeModel, GenericValidationResult, } from '@kbn/triggers-actions-ui-plugin/public'; -import { RecursivePartial } from '@elastic/eui'; import { OpsgenieSubActions } from '../../../../common'; import type { OpsgenieActionConfig, - OpsgenieActionParams, OpsgenieActionSecrets, } from '../../../../server/connector_types/stack'; import { DEFAULT_ALIAS } from './constants'; +import { OpsgenieConnectorTypeParams, ValidationParams } from './types'; const SELECT_MESSAGE = i18n.translate( 'xpack.stackConnectors.components.opsgenie.selectMessageText', @@ -34,42 +33,14 @@ const TITLE = i18n.translate('xpack.stackConnectors.components.opsgenie.connecto export const getConnectorType = (): ConnectorTypeModel< OpsgenieActionConfig, OpsgenieActionSecrets, - OpsgenieActionParams + OpsgenieConnectorTypeParams > => { return { id: '.opsgenie', iconClass: lazy(() => import('./logo')), selectMessage: SELECT_MESSAGE, actionTypeTitle: TITLE, - validateParams: async ( - actionParams: RecursivePartial - ): Promise> => { - const translations = await import('./translations'); - const errors = { - 'subActionParams.message': new Array(), - 'subActionParams.alias': new Array(), - }; - - const validationResult = { - errors, - }; - - if ( - actionParams.subAction === OpsgenieSubActions.CreateAlert && - !actionParams?.subActionParams?.message?.length - ) { - errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED); - } - - if ( - actionParams.subAction === OpsgenieSubActions.CloseAlert && - !actionParams?.subActionParams?.alias?.length - ) { - errors['subActionParams.alias'].push(translations.ALIAS_IS_REQUIRED); - } - - return validationResult; - }, + validateParams, actionConnectorFields: lazy(() => import('./connector')), actionParamsFields: lazy(() => import('./params')), defaultActionParams: { @@ -86,3 +57,39 @@ export const getConnectorType = (): ConnectorTypeModel< }, }; }; + +const validateParams = async ( + actionParams: ValidationParams +): Promise> => { + const translations = await import('./translations'); + const errors = { + 'subActionParams.message': new Array(), + 'subActionParams.alias': new Array(), + jsonEditorError: new Array(), + }; + + const validationResult = { + errors, + }; + + if ( + actionParams.subAction === OpsgenieSubActions.CreateAlert && + !actionParams?.subActionParams?.message?.length + ) { + errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED); + } + + if ( + actionParams.subAction === OpsgenieSubActions.CloseAlert && + !actionParams?.subActionParams?.alias?.length + ) { + errors['subActionParams.alias'].push(translations.ALIAS_IS_REQUIRED); + } + + if (actionParams.jsonEditorError) { + // This error doesn't actually get displayed it is used to cause the run/save button to fail within the action form + errors.jsonEditorError.push(translations.JSON_EDITOR_ERROR); + } + + return validationResult; +}; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx index 4a7650f8c6cff..7f20f15294878 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx @@ -11,6 +11,20 @@ import userEvent from '@testing-library/user-event'; import OpsgenieParamFields from './params'; import { OpsgenieSubActions } from '../../../../common'; import { OpsgenieActionParams } from '../../../../server/connector_types/stack'; +import { ActionConnectorMode } from '@kbn/triggers-actions-ui-plugin/public'; +import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock'; + +const kibanaReactPath = '../../../../../../../src/plugins/kibana_react/public'; + +jest.mock(kibanaReactPath, () => { + const original = jest.requireActual(kibanaReactPath); + return { + ...original, + CodeEditor: (props: any) => { + return ; + }, + }; +}); describe('OpsgenieParamFields', () => { const editAction = jest.fn(); @@ -44,6 +58,7 @@ describe('OpsgenieParamFields', () => { index: 0, messageVariables: [], actionConnector: connector, + executionMode: ActionConnectorMode.Test, }; const defaultCloseAlertProps = { @@ -56,6 +71,7 @@ describe('OpsgenieParamFields', () => { index: 0, messageVariables: [], actionConnector: connector, + executionMode: ActionConnectorMode.Test, }; beforeEach(() => { @@ -65,7 +81,7 @@ describe('OpsgenieParamFields', () => { it('renders the create alert component', async () => { render(); - expect(screen.getByText('Message')).toBeInTheDocument(); + expect(screen.getByText('Message (required)')).toBeInTheDocument(); expect(screen.getByText('Alias')).toBeInTheDocument(); expect(screen.getByTestId('opsgenie-subActionSelect')); @@ -77,7 +93,7 @@ describe('OpsgenieParamFields', () => { render(); expect(screen.queryByText('Message')).not.toBeInTheDocument(); - expect(screen.getByText('Alias')).toBeInTheDocument(); + expect(screen.getByText('Alias (required)')).toBeInTheDocument(); expect(screen.getByTestId('opsgenie-subActionSelect')); expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument(); @@ -85,6 +101,37 @@ describe('OpsgenieParamFields', () => { expect(screen.getByDisplayValue('456')).toBeInTheDocument(); }); + it('does not render the sub action select for creating an alert when execution mode is ActionForm', async () => { + render( + + ); + + expect(screen.getByText('Message (required)')).toBeInTheDocument(); + expect(screen.getByText('Alias')).toBeInTheDocument(); + expect(screen.queryByTestId('opsgenie-subActionSelect')).not.toBeInTheDocument(); + + expect(screen.getByDisplayValue('123')).toBeInTheDocument(); + expect(screen.getByDisplayValue('hello')).toBeInTheDocument(); + }); + + it('does not render the sub action select for closing an alert when execution mode is ActionForm', async () => { + render( + + ); + + expect(screen.queryByTestId('opsgenie-subActionSelect')).not.toBeInTheDocument(); + }); + + it('does not render the sub action select for closing an alert when execution mode is undefined', async () => { + render(); + + expect(screen.queryByTestId('opsgenie-subActionSelect')).not.toBeInTheDocument(); + }); + it('calls editAction when the message field is changed', async () => { render(); @@ -231,7 +278,7 @@ describe('OpsgenieParamFields', () => { act(() => userEvent.selectOptions( screen.getByTestId('opsgenie-subActionSelect'), - screen.getByText('Close Alert') + screen.getByText('Close alert') ) ); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx index 8dd6ab450af75..33dc1740c5ad8 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx @@ -8,131 +8,30 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { ActionParamsProps, - TextAreaWithMessageVariables, - TextFieldWithMessageVariables, + ActionConnectorMode, + IErrorObject, } from '@kbn/triggers-actions-ui-plugin/public'; -import { EuiFormRow, EuiSelect, RecursivePartial } from '@elastic/eui'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { isEmpty, unset, cloneDeep } from 'lodash'; import { OpsgenieSubActions } from '../../../../common'; import type { OpsgenieActionParams, - OpsgenieCloseAlertParams, - OpsgenieCreateAlertParams, + OpsgenieCreateAlertSubActionParams, } from '../../../../server/connector_types/stack'; import * as i18n from './translations'; - -type SubActionProps = Omit< - ActionParamsProps, - 'actionParams' | 'editAction' -> & { - subActionParams?: RecursivePartial; - editSubAction: ActionParamsProps['editAction']; -}; - -const CreateAlertComponent: React.FC> = ({ - editSubAction, - errors, - index, - messageVariables, - subActionParams, -}) => { - const isMessageInvalid = - errors['subActionParams.message'] !== undefined && - errors['subActionParams.message'].length > 0 && - subActionParams?.message !== undefined; - - return ( - <> - - - - - - - - - ); -}; - -CreateAlertComponent.displayName = 'CreateAlertComponent'; - -const CloseAlertComponent: React.FC> = ({ - editSubAction, - errors, - index, - messageVariables, - subActionParams, -}) => { - const isAliasInvalid = - errors['subActionParams.alias'] !== undefined && - errors['subActionParams.alias'].length > 0 && - subActionParams?.alias !== undefined; - - return ( - <> - - - - - - ); -}; - -CloseAlertComponent.displayName = 'CloseAlertComponent'; +import { CreateAlert } from './create_alert'; +import { CloseAlert } from './close_alert'; const actionOptions = [ { value: OpsgenieSubActions.CreateAlert, text: i18n.CREATE_ALERT_ACTION, + 'data-test-subj': 'opsgenie-subActionSelect-create-alert', }, { value: OpsgenieSubActions.CloseAlert, text: i18n.CLOSE_ALERT_ACTION, + 'data-test-subj': 'opsgenie-subActionSelect-close-alert', }, ]; @@ -142,6 +41,7 @@ const OpsgenieParamFields: React.FC> = ( errors, index, messageVariables, + executionMode, }) => { const { subAction, subActionParams } = actionParams; @@ -154,6 +54,20 @@ const OpsgenieParamFields: React.FC> = ( [editAction, index] ); + const editOptionalSubAction = useCallback( + (key, value) => { + if (isEmpty(value)) { + const paramsCopy = cloneDeep(subActionParams); + unset(paramsCopy, key); + editAction('subActionParams', paramsCopy, index); + return; + } + + editAction('subActionParams', { ...subActionParams, [key]: value }, index); + }, + [editAction, index, subActionParams] + ); + const editSubAction = useCallback( (key, value) => { editAction('subActionParams', { ...subActionParams, [key]: value }, index); @@ -175,35 +89,42 @@ const OpsgenieParamFields: React.FC> = ( editAction('subActionParams', params, index); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [subAction, currentSubAction]); + }, [subAction, currentSubAction, subActionParams?.alias, index]); return ( <> - - - + {executionMode === ActionConnectorMode.Test && ( + + + + )} - {subAction != null && subAction === OpsgenieSubActions.CreateAlert && ( - )} - {subAction != null && subAction === OpsgenieSubActions.CloseAlert && ( - > = ( OpsgenieParamFields.displayName = 'OpsgenieParamFields'; +/** + * The show*AlertSaveError functions are used to cause a rerender when fields are set to `null` when a user attempts to + * save the form before providing values for the required fields (message for creating an alert and alias for closing an alert). + * If we only passed in subActionParams the child components would not rerender because the objects field is only updated + * and not the entire object. + */ + +const showCreateAlertSaveError = ( + params: Partial, + errors: IErrorObject +): boolean => { + const errorArray = errors['subActionParams.message'] as string[] | undefined; + const errorsLength = errorArray?.length ?? 0; + + return ( + isCreateAlertParams(params) && params.subActionParams?.message === null && errorsLength > 0 + ); +}; + +const showCloseAlertSaveError = ( + params: Partial, + errors: IErrorObject +): boolean => { + const errorArray = errors['subActionParams.alias'] as string[] | undefined; + const errorsLength = errorArray?.length ?? 0; + + return isCloseAlertParams(params) && params.subActionParams?.alias === null && errorsLength > 0; +}; + +const isCreateAlertParams = ( + params: Partial +): params is Partial => + params.subAction === OpsgenieSubActions.CreateAlert; + +const isCloseAlertParams = ( + params: Partial +): params is OpsgenieCreateAlertSubActionParams => + params.subAction === OpsgenieSubActions.CloseAlert; + // eslint-disable-next-line import/no-default-export export { OpsgenieParamFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts index a5dd4e14c9c13..7c82f09f690be 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts @@ -21,6 +21,13 @@ export const API_KEY_LABEL = i18n.translate( } ); +export const MESSAGE_IS_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.requiredMessageTextField', + { + defaultMessage: 'Message is required.', + } +); + export const ACTION_LABEL = i18n.translate( 'xpack.stackConnectors.components.opsgenie.actionLabel', { @@ -31,55 +38,76 @@ export const ACTION_LABEL = i18n.translate( export const CREATE_ALERT_ACTION = i18n.translate( 'xpack.stackConnectors.components.opsgenie.createAlertAction', { - defaultMessage: 'Create Alert', + defaultMessage: 'Create alert', } ); export const CLOSE_ALERT_ACTION = i18n.translate( 'xpack.stackConnectors.components.opsgenie.closeAlertAction', { - defaultMessage: 'Close Alert', + defaultMessage: 'Close alert', } ); -export const MESSAGE_FIELD_LABEL = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.messageLabel', +export const NOTE_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.noteLabel', { - defaultMessage: 'Message', + defaultMessage: 'Note', } ); -export const NOTE_FIELD_LABEL = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.noteLabel', +export const ALIAS_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.aliasLabel', { - defaultMessage: 'Note (optional)', + defaultMessage: 'Alias', } ); -export const DESCRIPTION_FIELD_LABEL = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.descriptionLabel', +export const ALIAS_REQUIRED_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.aliasRequiredLabel', { - defaultMessage: 'Description (optional)', + defaultMessage: 'Alias (required)', } ); -export const MESSAGE_IS_REQUIRED = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.requiredMessageTextField', +export const ALIAS_IS_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.requiredAliasTextField', { - defaultMessage: 'Message is required.', + defaultMessage: 'Alias is required.', } ); -export const ALIAS_FIELD_LABEL = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.aliasLabel', +export const MORE_OPTIONS = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.moreOptions', { - defaultMessage: 'Alias', + defaultMessage: 'More options', } ); -export const ALIAS_IS_REQUIRED = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.requiredAliasTextField', +export const HIDE_OPTIONS = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.hideOptions', { - defaultMessage: 'Alias is required.', + defaultMessage: 'Hide options', + } +); + +export const USER_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.userLabel', + { + defaultMessage: 'User', + } +); + +export const SOURCE_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.sourceLabel', + { + defaultMessage: 'Source', + } +); + +export const JSON_EDITOR_ERROR = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.jsonEditorError', + { + defaultMessage: 'JSON editor error exists', } ); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/types.ts index e1637e99f2149..f58c10334207b 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/types.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/types.ts @@ -5,13 +5,38 @@ * 2.0. */ -import { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { RecursivePartial } from '@elastic/eui'; +import { + ActionParamsProps, + UserConfiguredActionConnector, +} from '@kbn/triggers-actions-ui-plugin/public/types'; import type { OpsgenieActionConfig, OpsgenieActionSecrets, + OpsgenieActionParams, } from '../../../../server/connector_types/stack'; export type OpsgenieActionConnector = UserConfiguredActionConnector< OpsgenieActionConfig, OpsgenieActionSecrets >; + +/** + * These fields will never be sent to Opsgenie or the sub actions framework. This allows us to pass a value to the + * validation functions so it cause a validation failure if the json editor has an error. That way the user can't save + * test. + */ +interface JsonEditorError { + jsonEditorError: boolean; +} + +export type OpsgenieConnectorTypeParams = OpsgenieActionParams & JsonEditorError; + +export type ValidationParams = RecursivePartial & JsonEditorError; + +type EditActionParameters = Parameters['editAction']>; + +export type EditActionCallback = ( + key: EditActionParameters[0], + value: EditActionParameters[1] +) => ReturnType['editAction']>; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.test.ts index 89dd11a2effcb..63c6f6a8466f3 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.test.ts @@ -147,4 +147,62 @@ describe('OpsgenieConnector', () => { data: { user: 'sam' }, }); }); + + describe('getResponseErrorMessage', () => { + it('returns an unknown error message', () => { + // @ts-expect-error expects an axios error as the parameter + expect(connector.getResponseErrorMessage({})).toMatchInlineSnapshot(`"unknown error"`); + }); + + it('returns the error.message', () => { + // @ts-expect-error expects an axios error as the parameter + expect(connector.getResponseErrorMessage({ message: 'a message' })).toMatchInlineSnapshot( + `"a message"` + ); + }); + + it('returns the error.response.data.message', () => { + expect( + // @ts-expect-error expects an axios error as the parameter + connector.getResponseErrorMessage({ response: { data: { message: 'a message' } } }) + ).toMatchInlineSnapshot(`"a message"`); + }); + + it('returns detailed message', () => { + // @ts-expect-error expects an axios error as the parameter + const error: AxiosError = { + response: { + data: { + errors: { + message: 'message field had a problem', + }, + message: 'toplevel message', + }, + }, + }; + + expect(connector.getResponseErrorMessage(error)).toMatchInlineSnapshot( + `"toplevel message: {\\"message\\":\\"message field had a problem\\"}"` + ); + }); + + it('returns detailed message with multiple entires', () => { + // @ts-expect-error expects an axios error as the parameter + const error: AxiosError = { + response: { + data: { + errors: { + message: 'message field had a problem', + visibleTo: 'visibleTo field had a problem', + }, + message: 'toplevel message', + }, + }, + }; + + expect(connector.getResponseErrorMessage(error)).toMatchInlineSnapshot( + `"toplevel message: {\\"message\\":\\"message field had a problem\\",\\"visibleTo\\":\\"visibleTo field had a problem\\"}"` + ); + }); + }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.ts index cb454d87bf7bd..bdcb21d230df6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.ts @@ -8,18 +8,12 @@ import crypto from 'crypto'; import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; import { AxiosError } from 'axios'; +import { isEmpty } from 'lodash'; import { OpsgenieSubActions } from '../../../../common'; -import { CloseAlertParamsSchema, CreateAlertParamsSchema, Response } from './schema'; -import { CloseAlertParams, Config, CreateAlertParams, Secrets } from './types'; +import { CreateAlertParamsSchema, CloseAlertParamsSchema, Response } from './schema'; +import { CloseAlertParams, Config, CreateAlertParams, FailureResponseType, Secrets } from './types'; import * as i18n from './translations'; -interface ErrorSchema { - message?: string; - errors?: { - message?: string; - }; -} - export class OpsgenieConnector extends SubActionConnector { constructor(params: ServiceParams) { super(params); @@ -37,13 +31,40 @@ export class OpsgenieConnector extends SubActionConnector { }); } - public getResponseErrorMessage(error: AxiosError) { - return `Message: ${ - error.response?.data.errors?.message ?? - error.response?.data.message ?? - error.message ?? - i18n.UNKNOWN_ERROR - }`; + public getResponseErrorMessage(error: AxiosError) { + const mainMessage = error.response?.data.message ?? error.message ?? i18n.UNKNOWN_ERROR; + + if (error.response?.data?.errors != null) { + const message = this.getDetailedErrorMessage(error.response?.data?.errors); + if (!isEmpty(message)) { + return `${mainMessage}: ${message}`; + } + } + + return mainMessage; + } + + /** + * When testing invalid requests with Opsgenie the response seems to take the form: + * { + * ['field that is invalid']: 'message about what the issue is' + * } + * + * e.g. + * + * { + * "message": "Message can not be empty.", + * "username": "must be a well-formed email address" + * } + * + * So we'll just stringify it. + */ + private getDetailedErrorMessage(errorField: unknown) { + try { + return JSON.stringify(errorField); + } catch (error) { + return; + } } public async createAlert(params: CreateAlertParams) { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts new file mode 100644 index 0000000000000..b46ddb61be135 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CreateAlertParamsSchema } from './schema'; +import { OpsgenieCreateAlertExample, ValidCreateAlertSchema } from './test_schema'; + +describe('opsgenie schema', () => { + describe('CreateAlertParamsSchema', () => { + it.each([ + ['ValidCreateAlertSchema', ValidCreateAlertSchema], + ['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample], + ])('validates the test object [%s] correctly', (objectName, testObject) => { + expect(() => CreateAlertParamsSchema.validate(testObject)).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts index 23fbe6be32b97..950b1b7117474 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts @@ -6,6 +6,8 @@ */ import { schema } from '@kbn/config-schema'; +import { isEmpty } from 'lodash'; +import * as i18n from './translations'; export const ConfigSchema = schema.object({ apiUrl: schema.string(), @@ -15,6 +17,48 @@ export const SecretsSchema = schema.object({ apiKey: schema.string(), }); +const SuccessfulResponse = schema.object( + { + took: schema.number(), + requestId: schema.string(), + result: schema.string(), + }, + { unknowns: 'allow' } +); + +export const FailureResponse = schema.object( + { + took: schema.number(), + requestId: schema.string(), + message: schema.maybe(schema.string()), + result: schema.maybe(schema.string()), + /** + * When testing invalid requests with Opsgenie the response seems to take the form: + * { + * ['field that is invalid']: 'message about what the issue is' + * } + * + * e.g. + * + * { + * "message": "Message can not be empty.", + * "username": "must be a well-formed email address" + * } + */ + errors: schema.maybe(schema.any()), + }, + { unknowns: 'allow' } +); + +export const Response = schema.oneOf([SuccessfulResponse, FailureResponse]); + +export const CloseAlertParamsSchema = schema.object({ + alias: schema.string(), + user: schema.maybe(schema.string({ maxLength: 100 })), + source: schema.maybe(schema.string({ maxLength: 100 })), + note: schema.maybe(schema.string({ maxLength: 25000 })), +}); + const responderTypes = schema.oneOf([ schema.literal('team'), schema.literal('user'), @@ -22,8 +66,15 @@ const responderTypes = schema.oneOf([ schema.literal('schedule'), ]); +/** + * For more information on the Opsgenie create alert schema see: https://docs.opsgenie.com/docs/alert-api#create-alert + */ export const CreateAlertParamsSchema = schema.object({ - message: schema.string({ maxLength: 130 }), + message: schema.string({ + maxLength: 130, + minLength: 1, + validate: (message) => (isEmpty(message.trim()) ? i18n.MESSAGE_NON_EMPTY : undefined), + }), /** * The max length here should be 512 according to Opsgenie's docs but we will sha256 hash the alias if it is longer than 512 * so we'll not impose a limit on the schema otherwise it'll get rejected prematurely. @@ -38,6 +89,12 @@ export const CreateAlertParamsSchema = schema.object({ type: responderTypes, }), schema.object({ id: schema.string(), type: responderTypes }), + /** + * This field is not explicitly called out in the description of responders within Opsgenie's API docs but it is + * shown in an example and when I tested it, it seems to work as they throw an error if you try to specify a username + * without a valid email + */ + schema.object({ username: schema.string(), type: schema.literal('user') }), ]), { maxSize: 50 } ) @@ -87,32 +144,3 @@ export const CreateAlertParamsSchema = schema.object({ user: schema.maybe(schema.string({ maxLength: 100 })), note: schema.maybe(schema.string({ maxLength: 25000 })), }); - -const SuccessfulResponse = schema.object( - { - took: schema.number(), - requestId: schema.string(), - result: schema.string(), - }, - { unknowns: 'allow' } -); - -const FailureResponse = schema.object( - { - took: schema.number(), - requestId: schema.string(), - message: schema.maybe(schema.string()), - result: schema.maybe(schema.string()), - errors: schema.maybe(schema.object({ message: schema.string() })), - }, - { unknowns: 'allow' } -); - -export const Response = schema.oneOf([SuccessfulResponse, FailureResponse]); - -export const CloseAlertParamsSchema = schema.object({ - alias: schema.string(), - user: schema.maybe(schema.string({ maxLength: 100 })), - source: schema.maybe(schema.string({ maxLength: 100 })), - note: schema.maybe(schema.string({ maxLength: 25000 })), -}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts new file mode 100644 index 0000000000000..748423ef22381 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts @@ -0,0 +1,96 @@ +/* + * 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 { CreateAlertParams } from './types'; + +export const ValidCreateAlertSchema: CreateAlertParams = { + message: 'a message', + alias: 'an alias', + description: 'a description', + responders: [ + { name: 'name for team', type: 'team' }, + { name: 'name for user', type: 'user' }, + { name: 'name for escalation', type: 'escalation' }, + { name: 'name for schedule', type: 'schedule' }, + { + id: '4513b7ea-3b91-438f-b7e4-e3e54af9147c', + type: 'team', + }, + { + name: 'NOC', + type: 'team', + }, + { + id: 'bb4d9938-c3c2-455d-aaab-727aa701c0d8', + type: 'user', + }, + { + username: 'trinity@opsgenie.com', + type: 'user', + }, + { + id: 'aee8a0de-c80f-4515-a232-501c0bc9d715', + type: 'escalation', + }, + { + name: 'Nightwatch Escalation', + type: 'escalation', + }, + { + id: '80564037-1984-4f38-b98e-8a1f662df552', + type: 'schedule', + }, + { + name: 'First Responders Schedule', + type: 'schedule', + }, + ], + visibleTo: [ + { name: 'name for team', type: 'team' }, + { id: 'id for team', type: 'team' }, + { id: 'id for user', type: 'user' }, + { username: 'username for user', type: 'user' }, + ], + actions: ['action1', 'action2'], + tags: ['tag1', 'tag2'], + details: { keyA: 'valueA', keyB: 'valueB' }, + entity: 'an entity', + source: 'a source', + priority: 'P2', + user: 'a user', + note: 'a note', +}; + +/** + * This example is pulled from the sample curl request here: https://docs.opsgenie.com/docs/alert-api#create-alert + */ +export const OpsgenieCreateAlertExample: CreateAlertParams = { + message: 'An example alert message', + alias: 'Life is too short for no alias', + description: 'Every alert needs a description', + responders: [ + { id: '4513b7ea-3b91-438f-b7e4-e3e54af9147c', type: 'team' }, + { name: 'NOC', type: 'team' }, + { id: 'bb4d9938-c3c2-455d-aaab-727aa701c0d8', type: 'user' }, + { username: 'trinity@opsgenie.com', type: 'user' }, + { id: 'aee8a0de-c80f-4515-a232-501c0bc9d715', type: 'escalation' }, + { name: 'Nightwatch Escalation', type: 'escalation' }, + { id: '80564037-1984-4f38-b98e-8a1f662df552', type: 'schedule' }, + { name: 'First Responders Schedule', type: 'schedule' }, + ], + visibleTo: [ + { id: '4513b7ea-3b91-438f-b7e4-e3e54af9147c', type: 'team' }, + { name: 'rocket_team', type: 'team' }, + { id: 'bb4d9938-c3c2-455d-aaab-727aa701c0d8', type: 'user' }, + { username: 'trinity@opsgenie.com', type: 'user' }, + ], + actions: ['Restart', 'AnExampleAction'], + tags: ['OverwriteQuietHours', 'Critical'], + details: { key1: 'value1', key2: 'value2' }, + entity: 'An example entity', + priority: 'P1', +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/translations.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/translations.ts index d5cd24f10a329..2a11e9482e15c 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/translations.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/translations.ts @@ -14,3 +14,10 @@ export const UNKNOWN_ERROR = i18n.translate('xpack.stackConnectors.opsgenie.unkn export const OPSGENIE_NAME = i18n.translate('xpack.stackConnectors.opsgenie.name', { defaultMessage: 'Opsgenie', }); + +export const MESSAGE_NON_EMPTY = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.nonEmptyMessageField', + { + defaultMessage: 'must be populated with a value other than just whitespace', + } +); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/types.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/types.ts index 572a31b201cd0..a460edee30436 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/types.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/types.ts @@ -6,10 +6,11 @@ */ import { TypeOf } from '@kbn/config-schema'; import { + CreateAlertParamsSchema, CloseAlertParamsSchema, ConfigSchema, - CreateAlertParamsSchema, SecretsSchema, + FailureResponse, } from './schema'; import { OpsgenieSubActions } from '../../../../common'; @@ -30,3 +31,5 @@ export interface CloseAlertSubActionParams { } export type Params = CreateAlertSubActionParams | CloseAlertSubActionParams; + +export type FailureResponseType = TypeOf; diff --git a/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts b/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts index adea7f323e432..b3ed4e26948ab 100644 --- a/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts +++ b/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts @@ -8,5 +8,6 @@ export enum SYNTHETICS_API_URLS { SYNTHETICS_OVERVIEW = '/internal/synthetics/overview', PINGS = '/internal/synthetics/pings', + PING_STATUSES = '/internal/synthetics/ping_statuses', OVERVIEW_STATUS = `/internal/synthetics/overview/status`, } diff --git a/x-pack/plugins/synthetics/common/lib/schedule_to_time.ts b/x-pack/plugins/synthetics/common/lib/schedule_to_time.ts index d944c35b2ec78..4578790f54572 100644 --- a/x-pack/plugins/synthetics/common/lib/schedule_to_time.ts +++ b/x-pack/plugins/synthetics/common/lib/schedule_to_time.ts @@ -12,6 +12,10 @@ export function scheduleToMilli(schedule: SyntheticsMonitorSchedule): number { return timeValue * getMilliFactorForScheduleUnit(schedule.unit); } +export function scheduleToMinutes(schedule: SyntheticsMonitorSchedule): number { + return Math.floor(scheduleToMilli(schedule) / (60 * 1000)); +} + function getMilliFactorForScheduleUnit(scheduleUnit: ScheduleUnit): number { switch (scheduleUnit) { case ScheduleUnit.SECONDS: diff --git a/x-pack/plugins/synthetics/common/runtime_types/ping/ping.ts b/x-pack/plugins/synthetics/common/runtime_types/ping/ping.ts index 598a62265f1c2..7fb0a799605a8 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/ping/ping.ts @@ -244,6 +244,24 @@ export const PingType = t.intersection([ export type Ping = t.TypeOf; +export const PingStatusType = t.intersection([ + t.type({ + timestamp: t.string, + docId: t.string, + config_id: t.string, + locationId: t.string, + summary: t.partial({ + down: t.number, + up: t.number, + }), + }), + t.partial({ + error: PingErrorType, + }), +]); + +export type PingStatus = t.TypeOf; + // Convenience function for tests etc that makes an empty ping // object with the minimum of fields. export const makePing = (f: { @@ -282,6 +300,15 @@ export const PingsResponseType = t.type({ export type PingsResponse = t.TypeOf; +export const PingStatusesResponseType = t.type({ + total: t.number, + pings: t.array(PingStatusType), + from: t.string, + to: t.string, +}); + +export type PingStatusesResponse = t.TypeOf; + export const GetPingsParamsType = t.intersection([ t.type({ dateRange: DateRangeType, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_ping_statuses.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_ping_statuses.tsx new file mode 100644 index 0000000000000..ca107c7af3ae1 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_ping_statuses.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useCallback, useRef } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +import { PingStatus } from '../../../../../../common/runtime_types'; +import { + getMonitorPingStatusesAction, + selectIsMonitorStatusesLoading, + selectPingStatusesForMonitorAndLocationAsc, +} from '../../../state'; + +import { useSelectedMonitor } from './use_selected_monitor'; +import { useSelectedLocation } from './use_selected_location'; + +export const usePingStatuses = ({ + from, + to, + size, + monitorInterval, + lastRefresh, +}: { + from: number; + to: number; + size: number; + monitorInterval: number; + lastRefresh: number; +}) => { + const { monitor } = useSelectedMonitor(); + const location = useSelectedLocation(); + + const pingStatusesSelector = useCallback(() => { + return selectPingStatusesForMonitorAndLocationAsc(monitor?.id ?? '', location?.label ?? ''); + }, [monitor?.id, location?.label]); + const isLoading = useSelector(selectIsMonitorStatusesLoading); + const pingStatuses = useSelector(pingStatusesSelector()) as PingStatus[]; + const dispatch = useDispatch(); + + const lastCall = useRef({ monitorId: '', locationLabel: '', to: 0, from: 0, lastRefresh: 0 }); + const toDiff = Math.abs(lastCall.current.to - to) / (1000 * 60); + const fromDiff = Math.abs(lastCall.current.from - from) / (1000 * 60); + const lastRefreshDiff = Math.abs(lastCall.current.lastRefresh - lastRefresh) / (1000 * 60); + const isDataChangedEnough = + toDiff >= monitorInterval || + fromDiff >= monitorInterval || + lastRefreshDiff >= 3 || // Minimum monitor interval + monitor?.id !== lastCall.current.monitorId || + location?.label !== lastCall.current.locationLabel; + + useEffect(() => { + if (!isLoading && isDataChangedEnough && monitor?.id && location?.label && from && to && size) { + dispatch( + getMonitorPingStatusesAction.get({ + monitorId: monitor.id, + locationId: location.label, + from, + to, + size, + }) + ); + + lastCall.current = { + monitorId: monitor.id, + locationLabel: location?.label, + to, + from, + lastRefresh, + }; + } + // `isLoading` shouldn't be included in deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, monitor?.id, location?.label, from, to, size, isDataChangedEnough, lastRefresh]); + + return pingStatuses.filter(({ timestamp }) => { + const timestampN = Number(new Date(timestamp)); + return timestampN >= from && timestampN <= to; + }); +}; + +export const usePingStatusesIsLoading = () => { + return useSelector(selectIsMonitorStatusesLoading) as boolean; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx index a1522f58047da..e0ed2162e6f1b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx @@ -4,9 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiText } from '@elastic/eui'; -import React from 'react'; + +import React, { useCallback } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useUrlParams } from '../../../hooks'; +import { SyntheticsDatePicker } from '../../common/date_picker/synthetics_date_picker'; +import { MonitorStatusPanel } from '../monitor_status/monitor_status_panel'; export const MonitorHistory = () => { - return Monitor history tab content; + const [useGetUrlParams, updateUrlParams] = useUrlParams(); + const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); + + const handleStatusChartBrushed = useCallback( + ({ fromUtc, toUtc }) => { + updateUrlParams({ dateRangeStart: fromUtc, dateRangeEnd: toUtc }); + }, + [updateUrlParams] + ); + + return ( + <> + + + + + + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/labels.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/labels.ts new file mode 100644 index 0000000000000..fdd3ddf3a4432 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/labels.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const AVAILABILITY_LABEL = i18n.translate( + 'xpack.synthetics.monitorDetails.availability.label', + { + defaultMessage: 'Availability', + } +); + +export const COMPLETE_LABEL = i18n.translate('xpack.synthetics.monitorDetails.complete.label', { + defaultMessage: 'Complete', +}); + +export const FAILED_LABEL = i18n.translate('xpack.synthetics.monitorDetails.failed.label', { + defaultMessage: 'Failed', +}); + +export const SKIPPED_LABEL = i18n.translate('xpack.synthetics.monitorDetails.skipped.label', { + defaultMessage: 'Skipped', +}); + +export const ERROR_LABEL = i18n.translate('xpack.synthetics.monitorDetails.error.label', { + defaultMessage: 'Error', +}); + +export const STATUS_LABEL = i18n.translate('xpack.synthetics.monitorDetails.status', { + defaultMessage: 'Status', +}); + +export const LAST_24_HOURS_LABEL = i18n.translate('xpack.synthetics.monitorDetails.last24Hours', { + defaultMessage: 'Last 24 hours', +}); + +export const VIEW_HISTORY_LABEL = i18n.translate('xpack.synthetics.monitorDetails.viewHistory', { + defaultMessage: 'View History', +}); + +export const BRUSH_AREA_MESSAGE = i18n.translate( + 'xpack.synthetics.monitorDetails.brushArea.message', + { + defaultMessage: 'Brush an area for higher fidelity', + } +); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_cell_tooltip.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_cell_tooltip.tsx new file mode 100644 index 0000000000000..8aa155dd2df7b --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_cell_tooltip.tsx @@ -0,0 +1,120 @@ +/* + * 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 moment from 'moment'; +import { css } from '@emotion/react'; +import { useEuiTheme, EuiText, EuiProgress } from '@elastic/eui'; + +import { + TooltipTable, + TooltipTableBody, + TooltipHeader, + TooltipDivider, + TooltipTableRow, + TooltipTableCell, +} from '@elastic/charts'; + +import { usePingStatusesIsLoading } from '../hooks/use_ping_statuses'; +import { MonitorStatusTimeBin, SUCCESS_VIZ_COLOR, DANGER_VIZ_COLOR } from './monitor_status_data'; +import * as labels from './labels'; + +export const MonitorStatusCellTooltip = ({ timeBin }: { timeBin?: MonitorStatusTimeBin }) => { + const { euiTheme } = useEuiTheme(); + const isLoading = usePingStatusesIsLoading(); + + if (!timeBin) { + return <>{''}; + } + + const startM = moment(timeBin.start); + const endM = moment(timeBin.end); + const startDateStr = startM.format('LL'); + const timeStartStr = startM.format('HH:mm'); + const timeEndStr = endM.format('HH:mm'); + const isDifferentDays = startM.dayOfYear() !== endM.dayOfYear(); + + // If start and end days are different, show date for both of the days + const endDateSegment = isDifferentDays ? `${endM.format('LL')} @ ` : ''; + const tooltipTitle = `${startDateStr} @ ${timeStartStr} - ${endDateSegment}${timeEndStr}`; + + const availabilityStr = + timeBin.ups + timeBin.downs > 0 + ? `${Math.round((timeBin.ups / (timeBin.ups + timeBin.downs)) * 100)}%` + : '-'; + + return ( + <> + + + {tooltipTitle} + + + + {isLoading ? : } + +
+ + + + + + + + + + + + + +
+ + ); +}; + +const TooltipListRow = ({ + color, + label, + value, +}: { + color?: string; + label: string; + value: string; +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + <> + + {label} + + + + + {value} + + + + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_chart_theme.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_chart_theme.ts new file mode 100644 index 0000000000000..022a928f2916c --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_chart_theme.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HeatmapStyle, RecursivePartial } from '@elastic/charts'; +import { EuiThemeComputed } from '@elastic/eui'; +import { CHART_CELL_WIDTH } from './monitor_status_data'; + +export function getMonitorStatusChartTheme( + euiTheme: EuiThemeComputed, + brushable: boolean +): RecursivePartial { + return { + grid: { + cellHeight: { + min: 20, + }, + stroke: { + width: 0, + color: 'transparent', + }, + }, + maxRowHeight: 30, + maxColumnWidth: CHART_CELL_WIDTH, + cell: { + maxWidth: 'fill', + maxHeight: 3, + label: { + visible: false, + }, + border: { + stroke: 'transparent', + strokeWidth: 0.5, + }, + }, + xAxisLabel: { + visible: true, + fontSize: 10, + fontFamily: euiTheme.font.family, + fontWeight: euiTheme.font.weight.light, + textColor: euiTheme.colors.subduedText, + }, + yAxisLabel: { + visible: false, + }, + brushTool: { + visible: brushable, + fill: euiTheme.colors.darkShade, + }, + }; +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_data.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_data.ts new file mode 100644 index 0000000000000..f588ab242adf9 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_data.ts @@ -0,0 +1,183 @@ +/* + * 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 datemath from '@elastic/datemath'; +import moment from 'moment'; +import { + tint, + transparentize, + VISUALIZATION_COLORS, + EuiThemeComputed, + EuiThemeColorModeStandard, + COLOR_MODES_STANDARD, +} from '@elastic/eui'; +import type { BrushEvent } from '@elastic/charts'; +import { PingStatus } from '../../../../../../common/runtime_types'; + +export const SUCCESS_VIZ_COLOR = VISUALIZATION_COLORS[0]; +export const DANGER_VIZ_COLOR = VISUALIZATION_COLORS[VISUALIZATION_COLORS.length - 1]; +export const CHART_CELL_WIDTH = 17; + +export interface MonitorStatusTimeBucket { + start: number; + end: number; +} + +export interface MonitorStatusTimeBin { + start: number; + end: number; + ups: number; + downs: number; + + /** + * To color code the time bin on chart + */ + value: number; +} + +export interface MonitorStatusPanelProps { + /** + * Either epoch in millis or Kibana date range e.g. 'now-24h' + */ + from: string | number; + + /** + * Either epoch in millis or Kibana date range e.g. 'now' + */ + to: string | number; + + brushable: boolean; // Whether to allow brushing on the chart to allow zooming in on data. + periodCaption?: string; // e.g. Last 24 Hours + showViewHistoryButton?: boolean; + onBrushed?: (timeBounds: { from: number; to: number; fromUtc: string; toUtc: string }) => void; +} + +export function getColorBands(euiTheme: EuiThemeComputed, colorMode: EuiThemeColorModeStandard) { + const colorTransitionFn = colorMode === COLOR_MODES_STANDARD.dark ? transparentize : tint; + + return [ + { color: DANGER_VIZ_COLOR, start: -Infinity, end: -1 }, + { color: DANGER_VIZ_COLOR, start: -1, end: -0.75 }, + { color: colorTransitionFn(DANGER_VIZ_COLOR, 0.25), start: -0.75, end: -0.5 }, + { color: colorTransitionFn(DANGER_VIZ_COLOR, 0.5), start: -0.5, end: -0.25 }, + { color: colorTransitionFn(DANGER_VIZ_COLOR, 0.75), start: -0.25, end: -0.000000001 }, + { + color: getSkippedVizColor(euiTheme), + start: -0.000000001, + end: 0.000000001, + }, + { color: colorTransitionFn(SUCCESS_VIZ_COLOR, 0.5), start: 0.000000001, end: 0.25 }, + { color: colorTransitionFn(SUCCESS_VIZ_COLOR, 0.35), start: 0.25, end: 0.5 }, + { color: colorTransitionFn(SUCCESS_VIZ_COLOR, 0.2), start: 0.5, end: 0.8 }, + { color: SUCCESS_VIZ_COLOR, start: 0.8, end: 1 }, + { color: SUCCESS_VIZ_COLOR, start: 1, end: Infinity }, + ]; +} + +export function getSkippedVizColor(euiTheme: EuiThemeComputed) { + return euiTheme.colors.lightestShade; +} + +export function getErrorVizColor(euiTheme: EuiThemeComputed) { + return euiTheme.colors.dangerText; +} + +export function getXAxisLabelFormatter(interval: number) { + return (value: string | number) => { + const m = moment(value); + const [hours, minutes] = [m.hours(), m.minutes()]; + const isFirstBucketOfADay = hours === 0 && minutes <= 36; + const isIntervalAcrossDays = interval >= 24 * 60; + return moment(value).format(isFirstBucketOfADay || isIntervalAcrossDays ? 'l' : 'HH:mm'); + }; +} + +export function createTimeBuckets(intervalMinutes: number, from: number, to: number) { + const currentMark = getEndTime(intervalMinutes, to); + const buckets: MonitorStatusTimeBucket[] = []; + + let tick = currentMark; + let maxIterations = 5000; + while (tick >= from && maxIterations > 0) { + const start = tick - Math.floor(intervalMinutes * 60 * 1000); + buckets.unshift({ start, end: tick }); + tick = start; + --maxIterations; + } + + return buckets; +} + +export function createStatusTimeBins( + timeBuckets: MonitorStatusTimeBucket[], + pingStatuses: PingStatus[] +): MonitorStatusTimeBin[] { + let iPingStatus = 0; + return (timeBuckets ?? []).map((bucket) => { + const currentBin: MonitorStatusTimeBin = { + start: bucket.start, + end: bucket.end, + ups: 0, + downs: 0, + value: 0, + }; + while ( + iPingStatus < pingStatuses.length && + moment(pingStatuses[iPingStatus].timestamp).valueOf() < bucket.end + ) { + currentBin.ups += pingStatuses[iPingStatus]?.summary.up ?? 0; + currentBin.downs += pingStatuses[iPingStatus]?.summary.down ?? 0; + currentBin.value = getStatusEffectiveValue(currentBin.ups, currentBin.downs); + iPingStatus++; + } + + return currentBin; + }); +} + +export function indexBinsByEndTime(bins: MonitorStatusTimeBin[]) { + return bins.reduce((acc, cur) => { + return acc.set(cur.end, cur); + }, new Map()); +} + +export function dateToMilli(date: string | number | moment.Moment | undefined): number { + if (typeof date === 'number') { + return date; + } + + let d = date; + if (typeof date === 'string') { + d = datemath.parse(date, { momentInstance: moment }); + } + + return moment(d).valueOf(); +} + +export function getBrushData(e: BrushEvent) { + const [from, to] = [Number(e.x?.[0]), Number(e.x?.[1])]; + const [fromUtc, toUtc] = [moment(from).format(), moment(to).format()]; + + return { from, to, fromUtc, toUtc }; +} + +function getStatusEffectiveValue(ups: number, downs: number): number { + if (ups === downs) { + return -0.1; + } + + return (ups - downs) / (ups + downs); +} + +function getEndTime(intervalMinutes: number, to: number) { + const intervalUnderHour = Math.floor(intervalMinutes) % 60; + + const upperBoundMinutes = + Math.ceil(new Date(to).getUTCMinutes() / intervalUnderHour) * intervalUnderHour; + + return moment(to).utc().startOf('hour').add(upperBoundMinutes, 'minute').valueOf(); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_header.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_header.tsx new file mode 100644 index 0000000000000..8de032f1ccd53 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_header.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTitle, + EuiLink, +} from '@elastic/eui'; +import { css } from '@emotion/css'; +import { useHistory } from 'react-router-dom'; + +import { MONITOR_HISTORY_ROUTE } from '../../../../../../common/constants'; +import { stringifyUrlParams } from '../../../utils/url_params'; +import { useGetUrlParams } from '../../../hooks'; + +import { useSelectedMonitor } from '../hooks/use_selected_monitor'; + +import * as labels from './labels'; +import { MonitorStatusPanelProps } from './monitor_status_data'; + +export const MonitorStatusHeader = ({ + from, + to, + periodCaption, + showViewHistoryButton, +}: MonitorStatusPanelProps) => { + const history = useHistory(); + const params = useGetUrlParams(); + const { monitor } = useSelectedMonitor(); + + const isLast24Hours = from === 'now-24h' && to === 'now'; + const periodCaptionText = !!periodCaption + ? periodCaption + : isLast24Hours + ? labels.LAST_24_HOURS_LABEL + : ''; + + return ( + + + +

{labels.STATUS_LABEL}

+
+
+ {periodCaptionText ? ( + + + {periodCaptionText} + + + ) : null} + + + {showViewHistoryButton ? ( + + + + {labels.VIEW_HISTORY_LABEL} + + + + ) : null} +
+ ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_legend.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_legend.tsx new file mode 100644 index 0000000000000..1b672ae6bf0ed --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_legend.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { css } from '@emotion/css'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, useEuiTheme } from '@elastic/eui'; +import * as labels from './labels'; +import { DANGER_VIZ_COLOR, getSkippedVizColor, SUCCESS_VIZ_COLOR } from './monitor_status_data'; + +export const MonitorStatusLegend = ({ brushable }: { brushable: boolean }) => { + const { euiTheme } = useEuiTheme(); + + const LegendItem = useMemo(() => { + return ({ + color, + label, + iconType = 'dot', + }: { + color: string; + label: string; + iconType?: string; + }) => ( + + + {label} + + ); + }, []); + + return ( + + + + + {/* + // Hiding error for now until @elastic/chart's Heatmap chart supports annotations + // `getErrorVizColor` can be imported from './monitor_status_data' + + */} + + {brushable ? ( + <> + + + + {labels.BRUSH_AREA_MESSAGE} + + + + ) : null} + + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_panel.tsx new file mode 100644 index 0000000000000..aa807f860f5dd --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_panel.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; + +import { EuiPanel, useEuiTheme, EuiResizeObserver } from '@elastic/eui'; +import { Chart, Settings, Heatmap, ScaleType } from '@elastic/charts'; + +import { MonitorStatusHeader } from './monitor_status_header'; +import { MonitorStatusCellTooltip } from './monitor_status_cell_tooltip'; +import { MonitorStatusLegend } from './monitor_status_legend'; +import { getMonitorStatusChartTheme } from './monitor_status_chart_theme'; +import { + getXAxisLabelFormatter, + getColorBands, + getBrushData, + MonitorStatusPanelProps, +} from './monitor_status_data'; +import { useMonitorStatusData } from './use_monitor_status_data'; + +export const MonitorStatusPanel = ({ + from = 'now-24h', + to = 'now', + brushable = true, + periodCaption = undefined, + showViewHistoryButton = false, + onBrushed, +}: MonitorStatusPanelProps) => { + const { euiTheme, colorMode } = useEuiTheme(); + const { timeBins, handleResize, getTimeBinByXValue, xDomain, intervalByWidth } = + useMonitorStatusData({ from, to }); + + const heatmap = useMemo(() => { + return getMonitorStatusChartTheme(euiTheme, brushable); + }, [euiTheme, brushable]); + + return ( + + + + + {(resizeRef) => ( +
+ + ( + + ), + }} + theme={{ heatmap }} + onBrushEnd={(brushArea) => { + onBrushed?.(getBrushData(brushArea)); + }} + /> + timeBin.end} + yAccessor={() => 'T'} + valueAccessor={(timeBin) => timeBin.value} + valueFormatter={(d) => d.toFixed(2)} + xAxisLabelFormatter={getXAxisLabelFormatter(intervalByWidth)} + timeZone="UTC" + xScale={{ + type: ScaleType.Time, + interval: { + type: 'calendar', + unit: 'm', + value: intervalByWidth, + }, + }} + /> + +
+ )} +
+ + +
+ ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts new file mode 100644 index 0000000000000..3465c229c65ce --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useMemo, useState } from 'react'; +import { throttle } from 'lodash'; + +import { scheduleToMinutes } from '../../../../../../common/lib/schedule_to_time'; +import { useSyntheticsRefreshContext } from '../../../contexts/synthetics_refresh_context'; + +import { useSelectedMonitor } from '../hooks/use_selected_monitor'; +import { usePingStatuses } from '../hooks/use_ping_statuses'; +import { + dateToMilli, + createTimeBuckets, + createStatusTimeBins, + CHART_CELL_WIDTH, + indexBinsByEndTime, + MonitorStatusPanelProps, +} from './monitor_status_data'; + +export const useMonitorStatusData = ({ + from, + to, +}: Pick) => { + const { lastRefresh } = useSyntheticsRefreshContext(); + const { monitor } = useSelectedMonitor(); + const monitorInterval = Math.max(3, monitor?.schedule ? scheduleToMinutes(monitor?.schedule) : 3); + + const fromMillis = dateToMilli(from); + const toMillis = dateToMilli(to); + const totalMinutes = Math.ceil(toMillis - fromMillis) / (1000 * 60); + const pingStatuses = usePingStatuses({ + from: fromMillis, + to: toMillis, + size: Math.min(10000, Math.ceil((totalMinutes / monitorInterval) * 2)), // Acts as max size between from - to + monitorInterval, + lastRefresh, + }); + + const [binsAvailableByWidth, setBinsAvailableByWidth] = useState(50); + const intervalByWidth = Math.floor( + Math.max(monitorInterval, totalMinutes / binsAvailableByWidth) + ); + + // Disabling deps warning as we wanna throttle the callback + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleResize = useCallback( + throttle((e: { width: number; height: number }) => { + setBinsAvailableByWidth(Math.floor(e.width / CHART_CELL_WIDTH)); + }, 500), + [] + ); + + const { timeBins, timeBinsByEndTime, xDomain } = useMemo(() => { + const timeBuckets = createTimeBuckets(intervalByWidth, fromMillis, toMillis); + const bins = createStatusTimeBins(timeBuckets, pingStatuses); + const indexedBins = indexBinsByEndTime(bins); + + const timeDomain = { + min: bins?.[0]?.end ?? fromMillis, + max: bins?.[bins.length - 1]?.end ?? toMillis, + }; + + return { timeBins: bins, timeBinsByEndTime: indexedBins, xDomain: timeDomain }; + }, [intervalByWidth, pingStatuses, fromMillis, toMillis]); + + return { + intervalByWidth, + timeBins, + xDomain, + handleResize, + getTimeBinByXValue: (xValue: number | undefined) => + xValue === undefined ? undefined : timeBinsByEndTime.get(xValue), + }; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx index f58ff821198ff..cc0153c7513cd 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx @@ -20,6 +20,7 @@ import { LoadWhenInView } from '@kbn/observability-plugin/public'; import { useEarliestStartDate } from '../hooks/use_earliest_start_data'; import { MonitorErrorSparklines } from './monitor_error_sparklines'; +import { MonitorStatusPanel } from '../monitor_status/monitor_status_panel'; import { DurationSparklines } from './duration_sparklines'; import { MonitorDurationTrend } from './duration_trend'; import { StepDurationPanel } from './step_duration_panel'; @@ -110,8 +111,13 @@ export const MonitorSummary = () => { - {/* */} - {/* /!* TODO: Add status panel*!/ */} + + diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts index 727fd0dfcd4c2..c80fc4640ff67 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts @@ -18,3 +18,4 @@ export * from './monitor_list'; export * from './monitor_details'; export * from './overview'; export * from './browser_journey'; +export * from './ping_status'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/actions.ts new file mode 100644 index 0000000000000..f268a5a2c5600 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/actions.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PingStatusesResponse } from '../../../../../common/runtime_types'; +import { createAsyncAction } from '../utils/actions'; + +import { PingStatusActionArgs } from './models'; + +export const getMonitorPingStatusesAction = createAsyncAction< + PingStatusActionArgs, + PingStatusesResponse +>('[PING STATUSES] GET PING STATUSES'); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/api.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/api.ts new file mode 100644 index 0000000000000..38930dfb02cb8 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/api.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SYNTHETICS_API_URLS } from '../../../../../common/constants'; +import { + PingStatusesResponse, + PingStatusesResponseType, +} from '../../../../../common/runtime_types'; +import { apiService } from '../../../../utils/api_service'; + +export const fetchMonitorPingStatuses = async ({ + monitorId, + locationId, + from, + to, + size, +}: { + monitorId: string; + locationId: string; + from: string; + to: string; + size: number; +}): Promise => { + const locations = JSON.stringify([locationId]); + const sort = 'desc'; + + return await apiService.get( + SYNTHETICS_API_URLS.PING_STATUSES, + { monitorId, from, to, locations, sort, size }, + PingStatusesResponseType + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/effects.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/effects.ts new file mode 100644 index 0000000000000..d7756b6b271e7 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/effects.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { takeEvery } from 'redux-saga/effects'; +import { fetchEffectFactory } from '../utils/fetch_effect'; +import { fetchMonitorPingStatuses } from './api'; + +import { getMonitorPingStatusesAction } from './actions'; + +export function* fetchPingStatusesEffect() { + yield takeEvery( + getMonitorPingStatusesAction.get, + fetchEffectFactory( + fetchMonitorPingStatuses, + getMonitorPingStatusesAction.success, + getMonitorPingStatusesAction.fail + ) + ); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/index.ts new file mode 100644 index 0000000000000..350db7cb41177 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/index.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { PingStatus } from '../../../../../common/runtime_types'; + +import { IHttpSerializedFetchError } from '../utils/http_error'; + +import { getMonitorPingStatusesAction } from './actions'; + +export interface PingStatusState { + pingStatuses: { + [monitorId: string]: { + [locationId: string]: { + [timestamp: string]: PingStatus; + }; + }; + }; + loading: boolean; + error: IHttpSerializedFetchError | null; +} + +const initialState: PingStatusState = { + pingStatuses: {}, + loading: false, + error: null, +}; + +export const pingStatusReducer = createReducer(initialState, (builder) => { + builder + .addCase(getMonitorPingStatusesAction.get, (state) => { + state.loading = true; + }) + .addCase(getMonitorPingStatusesAction.success, (state, action) => { + (action.payload.pings ?? []).forEach((ping) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { config_id, locationId, timestamp } = ping; + if (!state.pingStatuses[config_id]) { + state.pingStatuses[config_id] = {}; + } + + if (!state.pingStatuses[config_id][locationId]) { + state.pingStatuses[config_id][locationId] = {}; + } + + state.pingStatuses[config_id][locationId][timestamp] = ping; + }); + + state.loading = false; + }) + .addCase(getMonitorPingStatusesAction.fail, (state, action) => { + state.error = action.payload; + state.loading = false; + }); +}); + +export * from './actions'; +export * from './effects'; +export * from './selectors'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/models.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/models.ts new file mode 100644 index 0000000000000..bae8ae9acb3fa --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/models.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface PingStatusActionArgs { + monitorId: string; + locationId: string; + from: string | number; + to: string | number; + size: number; +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/selectors.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/selectors.ts new file mode 100644 index 0000000000000..cf3061e0fab33 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/selectors.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createSelector } from 'reselect'; + +import { PingStatus } from '../../../../../common/runtime_types'; +import { SyntheticsAppState } from '../root_reducer'; + +import { PingStatusState } from '.'; + +type PingSelectorReturnType = (state: SyntheticsAppState) => PingStatus[]; + +const getState = (appState: SyntheticsAppState) => appState.pingStatus; + +export const selectIsMonitorStatusesLoading = createSelector(getState, (state) => state.loading); + +export const selectPingStatusesForMonitorAndLocationAsc = ( + monitorId: string, + locationId: string +): PingSelectorReturnType => + createSelector([(state: SyntheticsAppState) => state.pingStatus], (state: PingStatusState) => { + return Object.values(state?.pingStatuses?.[monitorId]?.[locationId] ?? {}).sort( + (a, b) => Number(new Date(a.timestamp)) - Number(new Date(b.timestamp)) + ); + }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts index 7a5c55d72fd24..4a2543b8f941c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts @@ -14,6 +14,7 @@ import { fetchMonitorListEffect, upsertMonitorEffect } from './monitor_list'; import { fetchMonitorOverviewEffect, fetchOverviewStatusEffect } from './overview'; import { fetchServiceLocationsEffect } from './service_locations'; import { browserJourneyEffects } from './browser_journey'; +import { fetchPingStatusesEffect } from './ping_status'; export const rootEffect = function* root(): Generator { yield all([ @@ -27,5 +28,6 @@ export const rootEffect = function* root(): Generator { fork(browserJourneyEffects), fork(fetchOverviewStatusEffect), fork(fetchNetworkEventsEffect), + fork(fetchPingStatusesEffect), ]); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts index b9c7b6ec8db51..6cd15c31f5287 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts @@ -17,6 +17,7 @@ import { serviceLocationsReducer, ServiceLocationsState } from './service_locati import { monitorOverviewReducer, MonitorOverviewState } from './overview'; import { BrowserJourneyState } from './browser_journey/models'; import { browserJourneyReducer } from './browser_journey'; +import { PingStatusState, pingStatusReducer } from './ping_status'; export interface SyntheticsAppState { ui: UiState; @@ -28,6 +29,7 @@ export interface SyntheticsAppState { overview: MonitorOverviewState; browserJourney: BrowserJourneyState; networkEvents: NetworkEventsState; + pingStatus: PingStatusState; } export const rootReducer = combineReducers({ @@ -40,4 +42,5 @@ export const rootReducer = combineReducers({ overview: monitorOverviewReducer, browserJourney: browserJourneyReducer, networkEvents: networkEventsReducer, + pingStatus: pingStatusReducer, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index 7bf845deee9a6..cd1223ac6df4b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -105,6 +105,7 @@ export const mockState: SyntheticsAppState = { monitorDetails: getMonitorDetailsMockSlice(), browserJourney: getBrowserJourneyMockSlice(), networkEvents: {}, + pingStatus: getPingStatusesMockSlice(), }; function getBrowserJourneyMockSlice() { @@ -416,3 +417,32 @@ function getMonitorDetailsMockSlice() { selectedLocationId: 'us_central', }; } + +function getPingStatusesMockSlice() { + const monitorDetails = getMonitorDetailsMockSlice(); + + return { + pingStatuses: monitorDetails.pings.data.reduce((acc, cur) => { + if (!acc[cur.monitor.id]) { + acc[cur.monitor.id] = {}; + } + + if (!acc[cur.monitor.id][cur.observer.geo.name]) { + acc[cur.monitor.id][cur.observer.geo.name] = {}; + } + + acc[cur.monitor.id][cur.observer.geo.name][cur.timestamp] = { + timestamp: cur.timestamp, + error: undefined, + locationId: cur.observer.geo.name, + config_id: cur.config_id, + docId: cur.docId, + summary: cur.summary, + }; + + return acc; + }, {} as SyntheticsAppState['pingStatus']['pingStatuses']), + loading: false, + error: null, + } as SyntheticsAppState['pingStatus']; +} diff --git a/x-pack/plugins/synthetics/server/common/pings/query_pings.ts b/x-pack/plugins/synthetics/server/common/pings/query_pings.ts index 872336767aec8..ac0c0b98a5626 100644 --- a/x-pack/plugins/synthetics/server/common/pings/query_pings.ts +++ b/x-pack/plugins/synthetics/server/common/pings/query_pings.ts @@ -5,8 +5,12 @@ * 2.0. */ -import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { UMElasticsearchQueryFn } from '../../legacy_uptime/lib/adapters/framework'; +import { + Field, + QueryDslFieldAndFormat, + QueryDslQueryContainer, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { UMElasticsearchQueryFnParams } from '../../legacy_uptime/lib/adapters/framework'; import { GetPingsParams, HttpResponseBody, @@ -60,18 +64,35 @@ function isStringArray(value: unknown): value is string[] { throw Error('Excluded locations can only be strings'); } -export const queryPings: UMElasticsearchQueryFn = async ({ - uptimeEsClient, - dateRange: { from, to }, - index, - monitorId, - status, - sort, - size: sizeParam, - pageIndex, - locations, - excludedLocations, -}) => { +type QueryFields = Array; +type GetParamsWithFields = UMElasticsearchQueryFnParams< + GetPingsParams & { fields: QueryFields; fieldsExtractorFn: (doc: any) => F } +>; +type GetParamsWithoutFields = UMElasticsearchQueryFnParams; + +export function queryPings( + params: UMElasticsearchQueryFnParams +): Promise; + +export function queryPings( + params: UMElasticsearchQueryFnParams> +): Promise<{ total: number; pings: F[] }>; + +export async function queryPings( + params: GetParamsWithFields | GetParamsWithoutFields +): Promise { + const { + uptimeEsClient, + dateRange: { from, to }, + index, + monitorId, + status, + sort, + size: sizeParam, + pageIndex, + locations, + excludedLocations, + } = params; const size = sizeParam ?? DEFAULT_PAGE_SIZE; const searchBody = { @@ -92,6 +113,8 @@ export const queryPings: UMElasticsearchQueryFn = ...((locations ?? []).length > 0 ? { post_filter: { terms: { 'observer.geo.name': locations as unknown as string[] } } } : {}), + _source: true, + fields: [] as QueryFields, }; // if there are excluded locations, add a clause to the query's filter @@ -110,6 +133,23 @@ export const queryPings: UMElasticsearchQueryFn = }); } + // If fields are queried, only query the subset of asked fields and omit _source + if (isGetParamsWithFields(params)) { + searchBody._source = false; + searchBody.fields = params.fields; + + const { + body: { + hits: { hits, total }, + }, + } = await uptimeEsClient.search({ body: searchBody }); + + return { + total: total.value, + pings: hits.map((doc: any) => params.fieldsExtractorFn(doc)), + }; + } + const { body: { hits: { hits, total }, @@ -133,4 +173,10 @@ export const queryPings: UMElasticsearchQueryFn = total: total.value, pings, }; -}; +} + +function isGetParamsWithFields( + params: GetParamsWithFields | GetParamsWithoutFields +): params is GetParamsWithFields { + return (params as GetParamsWithFields).fields?.length > 0; +} diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/adapters/framework/adapter_types.ts index b5c41eb0c50b4..07c247dc44514 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/adapters/framework/adapter_types.ts @@ -35,11 +35,13 @@ import type { TelemetryEventsSender } from '../../telemetry/sender'; import type { UptimeRouter } from '../../../../types'; import { UptimeConfig } from '../../../../../common/config'; +export type UMElasticsearchQueryFnParams

= { + uptimeEsClient: UptimeEsClient; + esClient?: IScopedClusterClient; +} & P; + export type UMElasticsearchQueryFn = ( - params: { - uptimeEsClient: UptimeEsClient; - esClient?: IScopedClusterClient; - } & P + params: UMElasticsearchQueryFnParams

) => Promise; export type UMSavedObjectsQueryFn = ( diff --git a/x-pack/plugins/synthetics/server/routes/index.ts b/x-pack/plugins/synthetics/server/routes/index.ts index 546dda039ba22..13ba1cdcda4f2 100644 --- a/x-pack/plugins/synthetics/server/routes/index.ts +++ b/x-pack/plugins/synthetics/server/routes/index.ts @@ -28,7 +28,7 @@ import { editSyntheticsMonitorRoute } from './monitor_cruds/edit_monitor'; import { addSyntheticsMonitorRoute } from './monitor_cruds/add_monitor'; import { addSyntheticsProjectMonitorRoute } from './monitor_cruds/add_monitor_project'; import { addSyntheticsProjectMonitorRouteLegacy } from './monitor_cruds/add_monitor_project_legacy'; -import { syntheticsGetPingsRoute } from './pings'; +import { syntheticsGetPingsRoute, syntheticsGetPingStatusesRoute } from './pings'; import { createGetCurrentStatusRoute } from './status/current_status'; import { SyntheticsRestApiRouteFactory, @@ -56,6 +56,7 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ getServiceAllowedRoute, getAPIKeySyntheticsRoute, syntheticsGetPingsRoute, + syntheticsGetPingStatusesRoute, getHasZipUrlMonitorRoute, createGetCurrentStatusRoute, ]; diff --git a/x-pack/plugins/synthetics/server/routes/pings/get_ping_statuses.ts b/x-pack/plugins/synthetics/server/routes/pings/get_ping_statuses.ts new file mode 100644 index 0000000000000..7e232208de39f --- /dev/null +++ b/x-pack/plugins/synthetics/server/routes/pings/get_ping_statuses.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SYNTHETICS_API_URLS } from '../../../common/constants'; +import { PingError, PingStatus } from '../../../common/runtime_types'; +import { UMServerLibs } from '../../legacy_uptime/lib/lib'; +import { UMRestApiRouteFactory } from '../../legacy_uptime/routes/types'; +import { queryPings } from '../../common/pings/query_pings'; + +import { getPingsRouteQuerySchema } from './get_pings'; + +export const syntheticsGetPingStatusesRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: SYNTHETICS_API_URLS.PING_STATUSES, + validate: { + query: getPingsRouteQuerySchema, + }, + handler: async ({ uptimeEsClient, request, response }): Promise => { + const { + from, + to, + index, + monitorId, + status, + sort, + size, + pageIndex, + locations, + excludedLocations, + } = request.query; + + const result = await queryPings({ + uptimeEsClient, + dateRange: { from, to }, + index, + monitorId, + status, + sort, + size, + pageIndex, + locations: locations ? JSON.parse(locations) : [], + excludedLocations, + fields: ['@timestamp', 'config_id', 'summary.*', 'error.*', 'observer.geo.name'], + fieldsExtractorFn: extractPingStatus, + }); + + return { + ...result, + from, + to, + }; + }, +}); + +function grabPingError(doc: any): PingError | undefined { + const docContainsError = Object.keys(doc?.fields ?? {}).some((key) => key.startsWith('error.')); + if (!docContainsError) { + return undefined; + } + + return { + code: doc.fields['error.code']?.[0], + id: doc.fields['error.id']?.[0], + stack_trace: doc.fields['error.stack_trace']?.[0], + type: doc.fields['error.type']?.[0], + message: doc.fields['error.message']?.[0], + }; +} + +function extractPingStatus(doc: any) { + return { + timestamp: doc.fields['@timestamp']?.[0], + docId: doc._id, + config_id: doc.fields.config_id?.[0], + locationId: doc.fields['observer.geo.name']?.[0], + summary: { up: doc.fields['summary.up']?.[0], down: doc.fields['summary.down']?.[0] }, + error: grabPingError(doc), + } as PingStatus; +} diff --git a/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts b/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts index def868e404db5..80424cbe78902 100644 --- a/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts +++ b/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts @@ -11,22 +11,24 @@ import { UMServerLibs } from '../../legacy_uptime/lib/lib'; import { UMRestApiRouteFactory } from '../../legacy_uptime/routes/types'; import { queryPings } from '../../common/pings/query_pings'; +export const getPingsRouteQuerySchema = schema.object({ + from: schema.string(), + to: schema.string(), + locations: schema.maybe(schema.string()), + excludedLocations: schema.maybe(schema.string()), + monitorId: schema.maybe(schema.string()), + index: schema.maybe(schema.number()), + size: schema.maybe(schema.number()), + pageIndex: schema.maybe(schema.number()), + sort: schema.maybe(schema.string()), + status: schema.maybe(schema.string()), +}); + export const syntheticsGetPingsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', path: SYNTHETICS_API_URLS.PINGS, validate: { - query: schema.object({ - from: schema.string(), - to: schema.string(), - locations: schema.maybe(schema.string()), - excludedLocations: schema.maybe(schema.string()), - monitorId: schema.maybe(schema.string()), - index: schema.maybe(schema.number()), - size: schema.maybe(schema.number()), - pageIndex: schema.maybe(schema.number()), - sort: schema.maybe(schema.string()), - status: schema.maybe(schema.string()), - }), + query: getPingsRouteQuerySchema, }, handler: async ({ uptimeEsClient, request, response }): Promise => { const { diff --git a/x-pack/plugins/synthetics/server/routes/pings/index.ts b/x-pack/plugins/synthetics/server/routes/pings/index.ts index 7bc2a27c155bb..89fa3194d4dc2 100644 --- a/x-pack/plugins/synthetics/server/routes/pings/index.ts +++ b/x-pack/plugins/synthetics/server/routes/pings/index.ts @@ -6,3 +6,4 @@ */ export { syntheticsGetPingsRoute } from './get_pings'; +export { syntheticsGetPingStatusesRoute } from './get_ping_statuses'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/index.ts index 3bc994313093b..08f1a600c58a9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/index.ts @@ -15,3 +15,4 @@ export type { ConfigFieldSchema, SecretsFieldSchema } from './simple_connector_f export { ButtonGroupField } from './button_group_field'; export { JsonFieldWrapper } from './json_field_wrapper'; export { MustacheTextFieldWrapper } from './mustache_text_field_wrapper'; +export { SectionLoading } from './section_loading'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_summary.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_summary.test.ts index e75e3d132671c..43beb66b40f9e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_summary.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_summary.test.ts @@ -14,7 +14,13 @@ const http = httpServiceMock.createStartContract(); describe('loadRuleSummary', () => { test('should call rule summary API', async () => { const resolvedValue: RuleSummary = { - alerts: {}, + alerts: { + '1': { + flapping: true, + status: 'OK', + muted: false, + }, + }, consumer: 'alerts', enabled: true, errorMessages: [], @@ -35,7 +41,13 @@ describe('loadRuleSummary', () => { }; http.get.mockResolvedValueOnce({ - alerts: {}, + alerts: { + '1': { + flapping: true, + status: 'OK', + muted: false, + }, + }, consumer: 'alerts', enabled: true, error_messages: [], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx index 8f05cc57c4016..3005c86e17d6f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx @@ -8,9 +8,17 @@ import * as React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { ActionTypeForm } from './action_type_form'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; -import { ActionConnector, ActionType, RuleAction, GenericValidationResult } from '../../../types'; +import { + ActionConnector, + ActionType, + RuleAction, + GenericValidationResult, + ActionConnectorMode, +} from '../../../types'; import { act } from 'react-dom/test-utils'; import { EuiFieldText } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import { render, waitFor, screen } from '@testing-library/react'; jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); @@ -31,6 +39,24 @@ describe('action_type_form', () => { }, })); + const mockedActionParamsFieldsWithExecutionMode = React.lazy(async () => ({ + default({ executionMode }: { executionMode?: ActionConnectorMode }) { + return ( + <> + {executionMode === ActionConnectorMode.Test && ( + + )} + {executionMode === ActionConnectorMode.ActionForm && ( + + )} + {executionMode === undefined && ( + + )} + + ); + }, + })); + it('calls "setActionParamsProperty" to set the default value for the empty dedupKey', async () => { const actionType = actionTypeRegistryMock.createMockActionTypeModel({ id: '.pagerduty', @@ -81,6 +107,52 @@ describe('action_type_form', () => { ); }); + it('renders the actionParamsField with the execution mode set to ActionForm', async () => { + const actionType = actionTypeRegistryMock.createMockActionTypeModel({ + id: '.pagerduty', + iconClass: 'test', + selectMessage: 'test', + validateParams: (): Promise> => { + const validationResult = { errors: {} }; + return Promise.resolve(validationResult); + }, + actionConnectorFields: null, + actionParamsFields: mockedActionParamsFieldsWithExecutionMode, + defaultActionParams: { + dedupKey: 'test', + eventAction: 'resolve', + }, + }); + actionTypeRegistry.get.mockReturnValue(actionType); + + render( + + {getActionTypeForm(1, undefined, { + id: '123', + actionTypeId: '.pagerduty', + group: 'recovered', + params: { + eventAction: 'recovered', + dedupKey: undefined, + summary: '2323', + source: 'source', + severity: '1', + timestamp: new Date().toISOString(), + component: 'test', + group: 'group', + class: 'test class', + }, + })} + + ); + + await waitFor(() => { + expect(screen.getByTestId('executionModeFieldActionForm')).toBeInTheDocument(); + expect(screen.queryByTestId('executionModeFieldTest')).not.toBeInTheDocument(); + expect(screen.queryByTestId('executionModeFieldUndefined')).not.toBeInTheDocument(); + }); + }); + it('does not call "setActionParamsProperty" because dedupKey is not empty', async () => { const actionType = actionTypeRegistryMock.createMockActionTypeModel({ id: '.pagerduty', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index c3ca4b0275610..eff0ff126d3bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -36,6 +36,7 @@ import { ActionConnector, ActionVariables, ActionTypeRegistryContract, + ActionConnectorMode, } from '../../../types'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { hasSaveActionsCapability } from '../../lib/capabilities'; @@ -280,6 +281,7 @@ export const ActionTypeForm = ({ messageVariables={availableActionVariables} defaultMessage={selectedActionGroup?.defaultActionMessage ?? defaultActionMessage} actionConnector={actionConnector} + executionMode={ActionConnectorMode.ActionForm} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx index 17aaf90e0ddbf..e17cdf6e13a17 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx @@ -9,10 +9,16 @@ import React, { lazy } from 'react'; import { I18nProvider } from '@kbn/i18n-react'; import TestConnectorForm from './test_connector_form'; import { none, some } from 'fp-ts/lib/Option'; -import { ActionConnector, GenericValidationResult } from '../../../types'; +import { + ActionConnector, + ActionConnectorMode, + ActionParamsProps, + GenericValidationResult, +} from '../../../types'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { EuiFormRow, EuiFieldText, EuiText, EuiLink, EuiForm, EuiSelect } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { waitFor, screen, render } from '@testing-library/react'; jest.mock('../../../common/lib/kibana'); const mockedActionParamsFields = lazy(async () => ({ @@ -59,6 +65,34 @@ const actionType = { const actionTypeRegistry = actionTypeRegistryMock.create(); actionTypeRegistry.get.mockReturnValue(actionType); +const ExecutionModeComponent: React.FC, 'executionMode'>> = ({ + executionMode, +}) => { + return ( + + + <> + {executionMode === ActionConnectorMode.Test && ( + + )} + {executionMode === ActionConnectorMode.ActionForm && ( + + )} + {executionMode === undefined && ( + + )} + + + + ); +}; + +const mockedActionParamsFieldsExecutionMode = lazy(async () => ({ + default: ({ executionMode }: { executionMode?: ActionConnectorMode }) => { + return ; + }, +})); + describe('test_connector_form', () => { it('renders initially as the action form and execute button and no result', async () => { const connector = { @@ -88,6 +122,49 @@ describe('test_connector_form', () => { expect(result?.exists()).toBeTruthy(); }); + it('renders the execution test field', async () => { + const actionTypeExecutionMode = { + id: 'execution-mode-type', + iconClass: 'test', + selectMessage: 'test', + validateParams: (): Promise> => { + const validationResult = { errors: {} }; + return Promise.resolve(validationResult); + }, + actionConnectorFields: null, + actionParamsFields: mockedActionParamsFieldsExecutionMode, + }; + const actionTypeRegistryExecutionMode = actionTypeRegistryMock.create(); + actionTypeRegistryExecutionMode.get.mockReturnValue(actionTypeExecutionMode); + + const connector = { + actionTypeId: actionTypeExecutionMode.id, + config: {}, + secrets: {}, + } as ActionConnector; + + render( + + {}} + isExecutingAction={false} + onExecutionAction={async () => {}} + executionResult={none} + actionTypeRegistry={actionTypeRegistryExecutionMode} + /> + + ); + + await waitFor(() => { + expect(screen.getByTestId('executionModeFieldTest')).toBeInTheDocument(); + expect(screen.queryByTestId('executionModeFieldActionForm')).not.toBeInTheDocument(); + expect(screen.queryByTestId('executionModeFieldUndefined')).not.toBeInTheDocument(); + }); + }); + it('renders successful results', async () => { const connector = { actionTypeId: actionType.id, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx index 739ee4100d535..e13ca322a7cf2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx @@ -23,7 +23,12 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; -import { ActionConnector, ActionTypeRegistryContract, IErrorObject } from '../../../types'; +import { + ActionConnector, + ActionConnectorMode, + ActionTypeRegistryContract, + IErrorObject, +} from '../../../types'; export interface TestConnectorFormProps { connector: ActionConnector; @@ -90,6 +95,7 @@ export const TestConnectorForm = ({ } messageVariables={[]} actionConnector={connector} + executionMode={ActionConnectorMode.Test} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx index 5eac73c4e87ac..df698c8597a09 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx @@ -72,11 +72,13 @@ describe('rules', () => { status: 'OK', muted: false, actionGroupId: 'default', + flapping: false, }, second_rule: { status: 'Active', muted: false, actionGroupId: 'action group id unknown', + flapping: false, }, }, }); @@ -134,10 +136,12 @@ describe('rules', () => { ['us-central']: { status: 'OK', muted: false, + flapping: false, }, ['us-east']: { status: 'OK', muted: false, + flapping: false, }, }; @@ -169,8 +173,8 @@ describe('rules', () => { mutedInstanceIds: ['us-west', 'us-east'], }); const ruleType = mockRuleType(); - const ruleUsWest: AlertStatus = { status: 'OK', muted: false }; - const ruleUsEast: AlertStatus = { status: 'OK', muted: false }; + const ruleUsWest: AlertStatus = { status: 'OK', muted: false, flapping: false }; + const ruleUsEast: AlertStatus = { status: 'OK', muted: false, flapping: false }; const wrapper = mountWithIntl( { 'us-west': { status: 'OK', muted: false, + flapping: false, }, 'us-east': { status: 'OK', muted: false, + flapping: false, }, }, })} @@ -219,6 +225,7 @@ describe('alertToListItem', () => { muted: false, activeStartDate: fake2MinutesAgo.toISOString(), actionGroupId: 'testing', + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ @@ -238,6 +245,7 @@ describe('alertToListItem', () => { status: 'Active', muted: false, activeStartDate: fake2MinutesAgo.toISOString(), + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ @@ -258,6 +266,7 @@ describe('alertToListItem', () => { muted: true, activeStartDate: fake2MinutesAgo.toISOString(), actionGroupId: 'default', + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ @@ -276,6 +285,7 @@ describe('alertToListItem', () => { status: 'Active', muted: false, actionGroupId: 'default', + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ @@ -294,6 +304,7 @@ describe('alertToListItem', () => { status: 'OK', muted: true, actionGroupId: 'default', + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ alert: 'id', @@ -389,11 +400,13 @@ describe('tabbed content', () => { status: 'OK', muted: false, actionGroupId: 'default', + flapping: false, }, second_rule: { status: 'Active', muted: false, actionGroupId: 'action group id unknown', + flapping: false, }, }, }); @@ -473,6 +486,7 @@ function mockRuleSummary(overloads: Partial = {}): RuleSummary { status: 'OK', muted: false, actionGroupId: 'testActionGroup', + flapping: false, }, }, executionDuration: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.test.tsx index 34af31146eb0d..a29fc2a2ee9a3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.test.tsx @@ -167,6 +167,7 @@ function mockRuleSummary(overloads: Partial = {}): any { foo: { status: 'OK', muted: false, + flapping: false, }, }, executionDuration: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/test_helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/test_helpers.ts index 9e96487b167a4..b7a6876535a64 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/test_helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/test_helpers.ts @@ -101,6 +101,7 @@ export function mockRuleSummary(overloads: Partial = {}): RuleSumma status: 'OK', muted: false, actionGroupId: 'testActionGroup', + flapping: false, }, }, executionDuration: { diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 0a560be762eb5..0228ac4dec11b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -55,6 +55,7 @@ export { ALERT_HISTORY_PREFIX, AlertHistoryDocumentTemplate, AlertHistoryEsIndexConnectorId, + ActionConnectorMode, } from './types'; export { useConnectorContext } from './application/context/use_connector_context'; @@ -79,6 +80,7 @@ export { SimpleConnectorForm, TextAreaWithMessageVariables, TextFieldWithMessageVariables, + SectionLoading, } from './application/components'; export { diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index c001c7b90fa39..53813632af435 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -162,6 +162,11 @@ export interface BulkEditResponse { total: number; } +export enum ActionConnectorMode { + Test = 'test', + ActionForm = 'actionForm', +} + export interface ActionParamsProps { actionParams: Partial; index: number; @@ -173,6 +178,7 @@ export interface ActionParamsProps { isLoading?: boolean; isDisabled?: boolean; showEmailSubjectAndMessage?: boolean; + executionMode?: ActionConnectorMode; } export interface Pagination { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/stack/opsgenie.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/stack/opsgenie.ts index adce611ec3d1d..1c404a4431d81 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/stack/opsgenie.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/stack/opsgenie.ts @@ -253,7 +253,7 @@ export default function opsgenieTest({ getService }: FtrProviderContext) { retry: true, message: 'an error occurred while running the action', service_message: - 'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.type]: types that failed validation:\n - [responders.0.type.0]: expected value to equal [team]\n - [responders.0.type.1]: expected value to equal [user]\n - [responders.0.type.2]: expected value to equal [escalation]\n - [responders.0.type.3]: expected value to equal [schedule]\n- [responders.0.1.id]: expected value of type [string] but got [undefined])', + 'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.type]: types that failed validation:\n - [responders.0.type.0]: expected value to equal [team]\n - [responders.0.type.1]: expected value to equal [user]\n - [responders.0.type.2]: expected value to equal [escalation]\n - [responders.0.type.3]: expected value to equal [schedule]\n- [responders.0.1.id]: expected value of type [string] but got [undefined]\n- [responders.0.2.username]: expected value of type [string] but got [undefined])', }); }); @@ -282,7 +282,7 @@ export default function opsgenieTest({ getService }: FtrProviderContext) { retry: true, message: 'an error occurred while running the action', service_message: - 'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.name]: expected value of type [string] but got [undefined]\n- [responders.0.1.id]: expected value of type [string] but got [undefined])', + 'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.name]: expected value of type [string] but got [undefined]\n- [responders.0.1.id]: expected value of type [string] but got [undefined]\n- [responders.0.2.username]: expected value of type [string] but got [undefined])', }); }); @@ -682,7 +682,8 @@ export default function opsgenieTest({ getService }: FtrProviderContext) { message: 'an error occurred while running the action', retry: true, connector_id: opsgenieActionId, - service_message: 'Status code: undefined. Message: Message: failed', + service_message: + 'Status code: 422. Message: Request failed with status code 422: {"message":"failed"}', }); }); @@ -704,7 +705,8 @@ export default function opsgenieTest({ getService }: FtrProviderContext) { message: 'an error occurred while running the action', retry: true, connector_id: opsgenieActionId, - service_message: 'Status code: undefined. Message: Message: failed', + service_message: + 'Status code: 422. Message: Request failed with status code 422: {"message":"failed"}', }); }); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index e2a3736e57764..b5509096727fb 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -193,6 +193,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { event, `created new alert: 'instance'`, false, + false, currentExecutionId ); break; @@ -202,6 +203,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { event, `alert 'instance' has recovered`, true, + false, currentExecutionId ); break; @@ -211,6 +213,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { event, `active alert: 'instance' in actionGroup: 'default'`, false, + false, currentExecutionId ); break; @@ -259,33 +262,11 @@ export default function eventLogTests({ getService }: FtrProviderContext) { }); }); - for (const event of actionEvents) { - switch (event?.event?.action) { - case 'execute': - expect(event?.kibana?.alert?.rule?.execution?.uuid).not.to.be(undefined); - expect( - executionIds.indexOf(event?.kibana?.alert?.rule?.execution?.uuid) - ).to.be.greaterThan(-1); - validateEvent(event, { - spaceId: space.id, - savedObjects: [ - { type: 'action', id: createdAction.id, rel: 'primary', type_id: 'test.noop' }, - ], - message: `action executed: test.noop:${createdAction.id}: MY action`, - outcome: 'success', - shouldHaveTask: true, - ruleTypeId: response.body.rule_type_id, - rule: undefined, - consumer: 'alertsFixture', - }); - break; - } - } - function validateInstanceEvent( event: IValidatedEvent, subMessage: string, shouldHaveEventEnd: boolean, + flapping: boolean, executionId?: string ) { validateEvent(event, { @@ -307,8 +288,32 @@ export default function eventLogTests({ getService }: FtrProviderContext) { name: response.body.name, }, consumer: 'alertsFixture', + flapping, }); } + + for (const event of actionEvents) { + switch (event?.event?.action) { + case 'execute': + expect(event?.kibana?.alert?.rule?.execution?.uuid).not.to.be(undefined); + expect( + executionIds.indexOf(event?.kibana?.alert?.rule?.execution?.uuid) + ).to.be.greaterThan(-1); + validateEvent(event, { + spaceId: space.id, + savedObjects: [ + { type: 'action', id: createdAction.id, rel: 'primary', type_id: 'test.noop' }, + ], + message: `action executed: test.noop:${createdAction.id}: MY action`, + outcome: 'success', + shouldHaveTask: true, + ruleTypeId: response.body.rule_type_id, + rule: undefined, + consumer: 'alertsFixture', + }); + break; + } + } }); it('should generate expected events for rules with multiple searches', async () => { @@ -567,6 +572,7 @@ interface ValidateEventLogParams { ruleset?: string; namespace?: string; }; + flapping?: boolean; } export function validateEvent(event: IValidatedEvent, params: ValidateEventLogParams): void { @@ -585,6 +591,7 @@ export function validateEvent(event: IValidatedEvent, params: ValidateEventLogPa numRecoveredAlerts, consumer, ruleTypeId, + flapping, } = params; const { status, actionGroupId, instanceId, reason, shouldHaveEventEnd } = params; @@ -634,6 +641,10 @@ export function validateEvent(event: IValidatedEvent, params: ValidateEventLogPa expect(event?.kibana?.alert?.rule?.execution?.metrics?.alert_counts?.new).to.be(numNewAlerts); } + if (flapping !== undefined) { + expect(event?.kibana?.alert?.flapping).to.be(flapping); + } + expect(event?.kibana?.alert?.rule?.rule_type_id).to.be(ruleTypeId); expect(event?.kibana?.space_ids?.[0]).to.equal(spaceId); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log_alerts.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log_alerts.ts index 46da0e597e66a..29046ae028f6a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log_alerts.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log_alerts.ts @@ -93,10 +93,12 @@ export default function eventLogAlertTests({ getService }: FtrProviderContext) { start?: string; durationToDate?: string; } = {}; + for (let i = 0; i < instanceEvents.length; ++i) { switch (instanceEvents[i]?.event?.action) { case 'new-instance': expect(instanceEvents[i]?.kibana?.alerting?.instance_id).to.equal('instance'); + expect(instanceEvents[i]?.kibana?.alert?.flapping).to.equal(false); // a new alert should generate a unique UUID for the duration of its activeness expect(instanceEvents[i]?.event?.end).to.be(undefined); @@ -107,6 +109,7 @@ export default function eventLogAlertTests({ getService }: FtrProviderContext) { case 'active-instance': expect(instanceEvents[i]?.kibana?.alerting?.instance_id).to.equal('instance'); + expect(instanceEvents[i]?.kibana?.alert?.flapping).to.equal(false); expect(instanceEvents[i]?.event?.start).to.equal(currentAlertSpan.start); expect(instanceEvents[i]?.event?.end).to.be(undefined); @@ -121,6 +124,7 @@ export default function eventLogAlertTests({ getService }: FtrProviderContext) { case 'recovered-instance': expect(instanceEvents[i]?.kibana?.alerting?.instance_id).to.equal('instance'); + expect(instanceEvents[i]?.kibana?.alert?.flapping).to.equal(false); expect(instanceEvents[i]?.event?.start).to.equal(currentAlertSpan.start); expect(instanceEvents[i]?.event?.end).not.to.be(undefined); expect( diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_summary.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_summary.ts index d13da4694bbe2..1ec570f6c4f71 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_summary.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_summary.ts @@ -180,6 +180,7 @@ export default function createGetAlertSummaryTests({ getService }: FtrProviderCo '1': { status: 'OK', muted: true, + flapping: false, }, }); }); @@ -239,20 +240,24 @@ export default function createGetAlertSummaryTests({ getService }: FtrProviderCo muted: false, actionGroupId: 'default', activeStartDate: actualAlerts.alertA.activeStartDate, + flapping: false, }, alertB: { status: 'OK', muted: false, + flapping: false, }, alertC: { status: 'Active', muted: true, actionGroupId: 'default', activeStartDate: actualAlerts.alertC.activeStartDate, + flapping: false, }, alertD: { status: 'OK', muted: true, + flapping: false, }, }; expect(actualAlerts).to.eql(expectedAlerts); @@ -294,20 +299,24 @@ export default function createGetAlertSummaryTests({ getService }: FtrProviderCo muted: false, actionGroupId: 'default', activeStartDate: actualAlerts.alertA.activeStartDate, + flapping: false, }, alertB: { status: 'OK', muted: false, + flapping: false, }, alertC: { status: 'Active', muted: true, actionGroupId: 'default', activeStartDate: actualAlerts.alertC.activeStartDate, + flapping: false, }, alertD: { status: 'OK', muted: true, + flapping: false, }, }; expect(actualAlerts).to.eql(expectedAlerts); diff --git a/x-pack/test/functional/services/actions/common.ts b/x-pack/test/functional/services/actions/common.ts index 55ba48001bfe3..145f56e49a351 100644 --- a/x-pack/test/functional/services/actions/common.ts +++ b/x-pack/test/functional/services/actions/common.ts @@ -25,5 +25,16 @@ export function ActionsCommonServiceProvider({ getService, getPageObject }: FtrP await testSubjects.click(`.${name}-card`); }, + + async cancelConnectorForm() { + const flyOutCancelButton = await testSubjects.find('edit-connector-flyout-close-btn'); + const isEnabled = await flyOutCancelButton.isEnabled(); + const isDisplayed = await flyOutCancelButton.isDisplayed(); + + if (isEnabled && isDisplayed) { + await flyOutCancelButton.click(); + await testSubjects.missingOrFail('edit-connector-flyout-close-btn'); + } + }, }; } diff --git a/x-pack/test/functional/services/actions/opsgenie.ts b/x-pack/test/functional/services/actions/opsgenie.ts index 32f4d82068354..6a0fb18c8658b 100644 --- a/x-pack/test/functional/services/actions/opsgenie.ts +++ b/x-pack/test/functional/services/actions/opsgenie.ts @@ -20,6 +20,7 @@ export function ActionsOpsgenieServiceProvider( common: ActionsCommon ) { const testSubjects = getService('testSubjects'); + const find = getService('find'); return { async createNewConnector(fields: ConnectorFormFields) { @@ -44,5 +45,20 @@ export function ActionsOpsgenieServiceProvider( expect(await editFlyOutSaveButton.isEnabled()).to.be(true); await editFlyOutSaveButton.click(); }, + + async getObjFromJsonEditor() { + const jsonEditor = await find.byCssSelector('.monaco-editor .view-lines'); + + return JSON.parse(await jsonEditor.getVisibleText()); + }, + + async setJsonEditor(value: object) { + const stringified = JSON.stringify(value); + + await find.clickByCssSelector('.monaco-editor'); + const input = await find.activeElement(); + await input.clearValueWithKeyboard({ charByChar: true }); + await input.type(stringified); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts index ff428bdb44e2e..574505a7c4e88 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts @@ -140,6 +140,149 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await (await testSubjects.find('executeActionButton')).isEnabled()).to.be(false); }); + + describe('test page', () => { + let connectorId = ''; + + before(async () => { + const connectorName = generateUniqueKey(); + const createdAction = await createOpsgenieConnector(connectorName); + connectorId = createdAction.id; + objectRemover.add(createdAction.id, 'action', 'actions'); + }); + + beforeEach(async () => { + await testSubjects.click(`edit${connectorId}`); + await testSubjects.click('testConnectorTab'); + }); + + afterEach(async () => { + await actions.common.cancelConnectorForm(); + }); + + it('should show the sub action selector when in test mode', async () => { + await testSubjects.existOrFail('opsgenie-subActionSelect'); + }); + + it('should preserve the alias when switching between create and close alert actions', async () => { + await testSubjects.setValue('aliasInput', 'new alias'); + await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); + + expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.be( + 'closeAlert' + ); + expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be('new alias'); + }); + + it('should not preserve the message when switching to close alert and back to create alert', async () => { + await testSubjects.setValue('messageInput', 'a message'); + await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); + + await testSubjects.missingOrFail('messageInput'); + await retry.waitFor('message input to be displayed', async () => { + await testSubjects.selectValue('opsgenie-subActionSelect', 'createAlert'); + return await testSubjects.exists('messageInput'); + }); + + expect(await testSubjects.getAttribute('messageInput', 'value')).to.be(''); + }); + + describe('createAlert', () => { + it('should show the additional options for creating an alert when clicking more options', async () => { + await testSubjects.click('opsgenie-display-more-options'); + + await testSubjects.existOrFail('entityInput'); + await testSubjects.existOrFail('sourceInput'); + await testSubjects.existOrFail('userInput'); + await testSubjects.existOrFail('noteTextArea'); + }); + + it('should show and then hide the additional form options for creating an alert when clicking the button twice', async () => { + await testSubjects.click('opsgenie-display-more-options'); + + await testSubjects.existOrFail('entityInput'); + + await testSubjects.click('opsgenie-display-more-options'); + await testSubjects.missingOrFail('entityInput'); + }); + + it('should populate the json editor with the message, description, and alias', async () => { + await testSubjects.setValue('messageInput', 'a message'); + await testSubjects.setValue('descriptionTextArea', 'a description'); + await testSubjects.setValue('aliasInput', 'an alias'); + await testSubjects.setValue('opsgenie-prioritySelect', 'P5'); + await testSubjects.setValue('opsgenie-tags', 'a tag'); + + await testSubjects.click('opsgenie-show-json-editor-toggle'); + + const parsedValue = await actions.opsgenie.getObjFromJsonEditor(); + expect(parsedValue).to.eql({ + message: 'a message', + description: 'a description', + alias: 'an alias', + priority: 'P5', + tags: ['a tag'], + }); + }); + + it('should populate the form with the values from the json editor', async () => { + await testSubjects.click('opsgenie-show-json-editor-toggle'); + + await actions.opsgenie.setJsonEditor({ + message: 'a message', + description: 'a description', + alias: 'an alias', + priority: 'P3', + tags: ['tag1'], + }); + await testSubjects.click('opsgenie-show-json-editor-toggle'); + + expect(await testSubjects.getAttribute('messageInput', 'value')).to.be('a message'); + expect(await testSubjects.getAttribute('descriptionTextArea', 'value')).to.be( + 'a description' + ); + expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be('an alias'); + expect(await testSubjects.getAttribute('opsgenie-prioritySelect', 'value')).to.eql( + 'P3' + ); + expect(await (await testSubjects.find('opsgenie-tags')).getVisibleText()).to.eql( + 'tag1' + ); + }); + + it('should disable the run button when the json editor validation fails', async () => { + await testSubjects.click('opsgenie-show-json-editor-toggle'); + + await actions.opsgenie.setJsonEditor({ + message: '', + }); + + expect(await testSubjects.isEnabled('executeActionButton')).to.be(false); + }); + }); + + describe('closeAlert', () => { + it('should show the additional options for closing an alert when clicking more options', async () => { + await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); + + await testSubjects.click('opsgenie-display-more-options'); + + await testSubjects.existOrFail('sourceInput'); + await testSubjects.existOrFail('userInput'); + }); + + it('should show and then hide the additional form options for closing an alert when clicking the button twice', async () => { + await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); + + await testSubjects.click('opsgenie-display-more-options'); + + await testSubjects.existOrFail('sourceInput'); + + await testSubjects.click('opsgenie-display-more-options'); + await testSubjects.missingOrFail('sourceInput'); + }); + }); + }); }); describe('alerts page', () => { @@ -163,9 +306,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should default to the create alert action', async () => { - expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.eql( - 'createAlert' - ); + await testSubjects.existOrFail('messageInput'); expect(await testSubjects.getAttribute('aliasInput', 'value')).to.eql(defaultAlias); }); @@ -174,33 +315,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('addNewActionConnectorActionGroup-0'); await testSubjects.click('addNewActionConnectorActionGroup-0-option-recovered'); - expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.eql( - 'closeAlert' - ); expect(await testSubjects.getAttribute('aliasInput', 'value')).to.eql(defaultAlias); - }); - - it('should preserve the alias when switching between create and close alert actions', async () => { - await testSubjects.setValue('aliasInput', 'new alias'); - await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); - - expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.be( - 'closeAlert' - ); - expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be('new alias'); - }); - - it('should not preserve the message when switching to close alert and back to create alert', async () => { - await testSubjects.setValue('messageInput', 'a message'); - await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); - + await testSubjects.existOrFail('noteTextArea'); await testSubjects.missingOrFail('messageInput'); - await retry.waitFor('message input to be displayed', async () => { - await testSubjects.selectValue('opsgenie-subActionSelect', 'createAlert'); - return await testSubjects.exists('messageInput'); - }); - - expect(await testSubjects.getAttribute('messageInput', 'value')).to.be(''); }); it('should not preserve the alias when switching run when to recover', async () => { @@ -225,6 +342,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be(defaultAlias); }); + + it('should show the message is required error when clicking the save button', async () => { + await testSubjects.click('saveRuleButton'); + const messageError = await find.byClassName('euiFormErrorText'); + + expect(await messageError.getVisibleText()).to.eql('Message is required.'); + }); }); const setupRule = async () => { diff --git a/yarn.lock b/yarn.lock index c0c566d6a4ceb..20083798865c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3595,6 +3595,10 @@ version "0.0.0" uid "" +"@kbn/health-gateway-server@link:bazel-bin/packages/kbn-health-gateway-server": + version "0.0.0" + uid "" + "@kbn/home-sample-data-card@link:bazel-bin/packages/home/sample_data_card": version "0.0.0" uid ""