Co-authored-by: Josh <josh.rickard@swimlane.com> Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co> Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co> Co-authored-by: Josh <josh.rickard@swimlane.com> Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
dbfb32bbfd
commit
c6e6462e2f
110 changed files with 5531 additions and 233 deletions
|
@ -43,6 +43,10 @@ a| <<slack-action-type, Slack>>
|
|||
|
||||
| Send a message to a Slack channel or user.
|
||||
|
||||
a| <<swimlane-action-type, Swimlane>>
|
||||
|
||||
| Create an incident in Swimlane.
|
||||
|
||||
a| <<webhook-action-type, Webhook>>
|
||||
|
||||
| Send a request to a web service.
|
||||
|
|
105
docs/management/connectors/action-types/swimlane.asciidoc
Normal file
105
docs/management/connectors/action-types/swimlane.asciidoc
Normal file
|
@ -0,0 +1,105 @@
|
|||
[role="xpack"]
|
||||
[[swimlane-action-type]]
|
||||
=== Swimlane connector and action
|
||||
++++
|
||||
<titleabbrev>Swimlane</titleabbrev>
|
||||
++++
|
||||
|
||||
The Swimlane connector uses the https://swimlane.com/knowledge-center/docs/developer-guide/rest-api/[Swimlane REST API] to create Swimlane records.
|
||||
|
||||
[float]
|
||||
[[swimlane-connector-configuration]]
|
||||
==== Connector configuration
|
||||
|
||||
Swimlane connectors have the following configuration properties.
|
||||
|
||||
Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action.
|
||||
URL:: Swimlane instance URL.
|
||||
Application ID:: Swimlane application ID.
|
||||
API token:: Swimlane API authentication token for HTTP Basic authentication.
|
||||
|
||||
[float]
|
||||
[[Preconfigured-swimlane-configuration]]
|
||||
==== Preconfigured connector type
|
||||
|
||||
[source,text]
|
||||
--
|
||||
my-swimlane:
|
||||
name: preconfigured-swimlane-connector-type
|
||||
actionTypeId: .swimlane
|
||||
config:
|
||||
apiUrl: https://elastic.swimlaneurl.us
|
||||
appId: app-id
|
||||
mappings:
|
||||
alertIdConfig:
|
||||
fieldType: text
|
||||
id: agp4s
|
||||
key: alert-id
|
||||
name: Alert ID
|
||||
caseIdConfig:
|
||||
fieldType: text
|
||||
id: ae1mi
|
||||
key: case-id
|
||||
name: Case ID
|
||||
caseNameConfig:
|
||||
fieldType: text
|
||||
id: anxnr
|
||||
key: case-name
|
||||
name: Case Name
|
||||
commentsConfig:
|
||||
fieldType: comments
|
||||
id: au18d
|
||||
key: comments
|
||||
name: Comments
|
||||
descriptionConfig:
|
||||
fieldType: text
|
||||
id: ae1gd
|
||||
key: description
|
||||
name: Description
|
||||
ruleNameConfig:
|
||||
fieldType: text
|
||||
id: avfsl
|
||||
key: rule-name
|
||||
name: Rule Name
|
||||
severityConfig:
|
||||
fieldType: text
|
||||
id: a71ik
|
||||
key: severity
|
||||
name: severity
|
||||
secrets:
|
||||
apiToken: tokenkeystorevalue
|
||||
--
|
||||
|
||||
Config defines information for the connector type.
|
||||
|
||||
`apiUrl`:: An address that corresponds to *URL*.
|
||||
`appId`:: A key that corresponds to *Application ID*.
|
||||
|
||||
Secrets defines sensitive information for the connector type.
|
||||
|
||||
`apiToken`:: A string that corresponds to *API Token*. Should be stored in the <<creating-keystore, {kib} keystore>>.
|
||||
|
||||
[float]
|
||||
[[define-swimlane-ui]]
|
||||
==== Define connector in Stack Management
|
||||
|
||||
Define Swimlane connector properties.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/swimlane-connector.png[Swimlane connector]
|
||||
|
||||
Test Swimlane action parameters.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/swimlane-params-test.png[Swimlane params test]
|
||||
|
||||
[float]
|
||||
[[swimlane-action-configuration]]
|
||||
==== Action configuration
|
||||
|
||||
Swimlane actions have the following configuration properties.
|
||||
|
||||
Comments:: Additional information for the client, such as how to troubleshoot the issue.
|
||||
Severity:: The severity of the incident.
|
||||
|
||||
NOTE: Alert ID and Rule Name are filled automatically. Specifically, Alert ID is set to `{{alert.id}}` and Rule Name to `{{rule.name}}`.
|
BIN
docs/management/connectors/images/swimlane-connector.png
Normal file
BIN
docs/management/connectors/images/swimlane-connector.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 73 KiB |
BIN
docs/management/connectors/images/swimlane-params-test.png
Normal file
BIN
docs/management/connectors/images/swimlane-params-test.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 171 KiB |
|
@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[]
|
|||
include::action-types/pagerduty.asciidoc[]
|
||||
include::action-types/server-log.asciidoc[]
|
||||
include::action-types/servicenow.asciidoc[]
|
||||
include::action-types/swimlane.asciidoc[]
|
||||
include::action-types/slack.asciidoc[]
|
||||
include::action-types/webhook.asciidoc[]
|
||||
include::pre-configured-connectors.asciidoc[]
|
||||
|
|
|
@ -19,7 +19,7 @@ Table of Contents
|
|||
- [Usage](#usage)
|
||||
- [Kibana Actions Configuration](#kibana-actions-configuration)
|
||||
- [Configuration Options](#configuration-options)
|
||||
- [Adding Built-in Action Types to allowedHosts](#adding-built-in-action-types-to-allowedhosts)
|
||||
- [**allowedHosts** configuration](#allowedhosts-configuration)
|
||||
- [Configuration Utilities](#configuration-utilities)
|
||||
- [Action types](#action-types)
|
||||
- [Methods](#methods)
|
||||
|
@ -54,6 +54,9 @@ Table of Contents
|
|||
- [`subActionParams (getFields)`](#subactionparams-getfields-2)
|
||||
- [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes)
|
||||
- [`subActionParams (severity)`](#subactionparams-severity)
|
||||
- [Swimlane](#swimlane)
|
||||
- [`params`](#params-3)
|
||||
- [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-)
|
||||
- [Command Line Utility](#command-line-utility)
|
||||
- [Developing New Action Types](#developing-new-action-types)
|
||||
- [licensing](#licensing)
|
||||
|
@ -102,8 +105,8 @@ This module provides utilities for interacting with the configuration.
|
|||
| ensureUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all URI's are allowed (using an "\*") then it will never throw. | No return value, throws if URI isn't allowed |
|
||||
| ensureHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will never throw | No return value, throws if Hostname isn't allowed . |
|
||||
| ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled |
|
||||
| isRejectUnauthorizedCertificatesEnabled | _none_ | Returns value of `rejectUnauthorized` from configuration. | Boolean |
|
||||
| getProxySettings | _none_ | If `proxyUrl` is set in the configuration, returns the proxy settings `proxyUrl`, `proxyHeaders` and `proxyRejectUnauthorizedCertificates`. Otherwise returns _undefined_. | Undefined or ProxySettings |
|
||||
| isRejectUnauthorizedCertificatesEnabled | _none_ | Returns value of `rejectUnauthorized` from configuration. | Boolean |
|
||||
| getProxySettings | _none_ | If `proxyUrl` is set in the configuration, returns the proxy settings `proxyUrl`, `proxyHeaders` and `proxyRejectUnauthorizedCertificates`. Otherwise returns _undefined_. | Undefined or ProxySettings |
|
||||
|
||||
## Action types
|
||||
|
||||
|
@ -113,17 +116,17 @@ This module provides utilities for interacting with the configuration.
|
|||
|
||||
The following table describes the properties of the `options` object.
|
||||
|
||||
| Property | Description | Type |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `<plugin_id>.mySpecialAction` for your action types. | string |
|
||||
| name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string |
|
||||
| maxAttempts | The maximum number of times this action will attempt to execute when scheduled. | number |
|
||||
| minimumLicenseRequired | The license required to use the action type. | string |
|
||||
| Property | Description | Type |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `<plugin_id>.mySpecialAction` for your action types. | string |
|
||||
| name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string |
|
||||
| maxAttempts | The maximum number of times this action will attempt to execute when scheduled. | number |
|
||||
| minimumLicenseRequired | The license required to use the action type. | string |
|
||||
| validate.params | When developing an action type, it needs to accept parameters to know what to do with the action. (Example `to`, `from`, `subject`, `body` of an email). See the current built-in email action type for an example of the state-of-the-art validation. <p>Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns a sanitized version of that object to pass to the execution function. Validation errors should be thrown from the `validate()` function and will be available as an error message | schema / validation function |
|
||||
| validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function |
|
||||
| validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function |
|
||||
| executor | This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function |
|
||||
| renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function |
|
||||
| validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function |
|
||||
| validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function |
|
||||
| executor | This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function |
|
||||
| renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function |
|
||||
|
||||
**Important** - The config object is persisted in ElasticSearch and updated via the ElasticSearch update document API. This API allows "partial updates" - and this can cause issues with the encryption used on specified properties. So, a `validate()` function should return values for all configuration properties, so that partial updates do not occur. Setting property values to `null` rather than `undefined`, or not including a property in the config object, is all you need to do to ensure partial updates won't occur.
|
||||
|
||||
|
@ -133,15 +136,15 @@ This is the primary function for an action type. Whenever the action needs to ex
|
|||
|
||||
**executor(options)**
|
||||
|
||||
| Property | Description |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| actionId | The action saved object id that the action type is executing for. |
|
||||
| config | The action configuration. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. |
|
||||
| secrets | The decrypted secrets object given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the secrets object before being passed to the executor, define `validate.secrets` within the action type. |
|
||||
| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. |
|
||||
| services.scopedClusterClient | Use this to do Elasticsearch queries on the cluster Kibana connects to. Serves the same purpose as the normal IClusterClient, but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead.|
|
||||
| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.<br><br>The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). |
|
||||
| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log)
|
||||
| Property | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| actionId | The action saved object id that the action type is executing for. |
|
||||
| config | The action configuration. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. |
|
||||
| secrets | The decrypted secrets object given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the secrets object before being passed to the executor, define `validate.secrets` within the action type. |
|
||||
| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. |
|
||||
| services.scopedClusterClient | Use this to do Elasticsearch queries on the cluster Kibana connects to. Serves the same purpose as the normal IClusterClient, but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. |
|
||||
| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.<br><br>The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). |
|
||||
| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) |
|
||||
|
||||
### Example
|
||||
|
||||
|
@ -262,16 +265,16 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib
|
|||
|
||||
The following table describes the properties of the `incident` object.
|
||||
|
||||
| Property | Description | Type |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------- |
|
||||
| short_description | The title of the incident. | string |
|
||||
| description | The description of the incident. | string _(optional)_ |
|
||||
| Property | Description | Type |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- |
|
||||
| short_description | The title of the incident. | string |
|
||||
| description | The description of the incident. | string _(optional)_ |
|
||||
| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
|
||||
| severity | The severity in ServiceNow. | string _(optional)_ |
|
||||
| urgency | The urgency in ServiceNow. | string _(optional)_ |
|
||||
| impact | The impact in ServiceNow. | string _(optional)_ |
|
||||
| category | The category in ServiceNow. | string _(optional)_ |
|
||||
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
|
||||
| severity | The severity in ServiceNow. | string _(optional)_ |
|
||||
| urgency | The urgency in ServiceNow. | string _(optional)_ |
|
||||
| impact | The impact in ServiceNow. | string _(optional)_ |
|
||||
| category | The category in ServiceNow. | string _(optional)_ |
|
||||
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
|
||||
|
||||
#### `subActionParams (getFields)`
|
||||
|
||||
|
@ -311,20 +314,20 @@ The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/ma
|
|||
|
||||
The following table describes the properties of the `incident` object.
|
||||
|
||||
| Property | Description | Type |
|
||||
| ----------- | ---------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| summary | The title of the issue. | string |
|
||||
| description | The description of the issue. | string _(optional)_ |
|
||||
| Property | Description | Type |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| summary | The title of the issue. | string |
|
||||
| description | The description of the issue. | string _(optional)_ |
|
||||
| externalId | The ID of the issue in Jira. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
|
||||
| issueType | The ID of the issue type in Jira. | string _(optional)_ |
|
||||
| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ |
|
||||
| labels | An array of labels. Labels cannot contain spaces. | string[] _(optional)_ |
|
||||
| parent | The ID or key of the parent issue. Only for `Sub-task` issue types. | string _(optional)_ |
|
||||
| issueType | The ID of the issue type in Jira. | string _(optional)_ |
|
||||
| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ |
|
||||
| labels | An array of labels. Labels cannot contain spaces. | string[] _(optional)_ |
|
||||
| parent | The ID or key of the parent issue. Only for `Sub-task` issue types. | string _(optional)_ |
|
||||
|
||||
#### `subActionParams (getIncident)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| ---------- | --------------------------- | ------ |
|
||||
| Property | Description | Type |
|
||||
| ---------- | ---------------------------- | ------ |
|
||||
| externalId | The ID of the issue in Jira. | string |
|
||||
|
||||
#### `subActionParams (issueTypes)`
|
||||
|
@ -333,20 +336,20 @@ No parameters for the `issueTypes` subaction. Provide an empty object `{}`.
|
|||
|
||||
#### `subActionParams (fieldsByIssueType)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| -------- | -------------------------------- | ------ |
|
||||
| Property | Description | Type |
|
||||
| -------- | --------------------------------- | ------ |
|
||||
| id | The ID of the issue type in Jira. | string |
|
||||
|
||||
#### `subActionParams (issues)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| -------- | ----------------------- | ------ |
|
||||
| Property | Description | Type |
|
||||
| -------- | ------------------------ | ------ |
|
||||
| title | The title to search for. | string |
|
||||
|
||||
#### `subActionParams (issue)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| -------- | --------------------------- | ------ |
|
||||
| Property | Description | Type |
|
||||
| -------- | ---------------------------- | ------ |
|
||||
| id | The ID of the issue in Jira. | string |
|
||||
|
||||
#### `subActionParams (getFields)`
|
||||
|
@ -360,10 +363,10 @@ The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/
|
|||
|
||||
### `params`
|
||||
|
||||
| Property | Description | Type |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------- | ------ |
|
||||
| Property | Description | Type |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------- | ------ |
|
||||
| subAction | The subaction to perform. It can be `pushToService`, `getFields`, `incidentTypes`, and `severity. | string |
|
||||
| subActionParams | The parameters of the subaction. | object |
|
||||
| subActionParams | The parameters of the subaction. | object |
|
||||
|
||||
#### `subActionParams (pushToService)`
|
||||
|
||||
|
@ -374,13 +377,13 @@ The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/
|
|||
|
||||
The following table describes the properties of the `incident` object.
|
||||
|
||||
| Property | Description | Type |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| name | The title of the incident. | string _(optional)_ |
|
||||
| description | The description of the incident. | string _(optional)_ |
|
||||
| Property | Description | Type |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| name | The title of the incident. | string _(optional)_ |
|
||||
| description | The description of the incident. | string _(optional)_ |
|
||||
| externalId | The ID of the incident in IBM Resilient. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
|
||||
| incidentTypes | An array with the IDs of IBM Resilient incident types. | number[] _(optional)_ |
|
||||
| severityCode | IBM Resilient ID of the severity code. | number _(optional)_ |
|
||||
| incidentTypes | An array with the IDs of IBM Resilient incident types. | number[] _(optional)_ |
|
||||
| severityCode | IBM Resilient ID of the severity code. | number _(optional)_ |
|
||||
|
||||
#### `subActionParams (getFields)`
|
||||
|
||||
|
@ -394,6 +397,36 @@ No parameters for the `incidentTypes` subaction. Provide an empty object `{}`.
|
|||
|
||||
No parameters for the `severity` subaction. Provide an empty object `{}`.
|
||||
|
||||
---
|
||||
## Swimlane
|
||||
|
||||
|
||||
### `params`
|
||||
|
||||
| Property | Description | Type |
|
||||
| --------------- | ---------------------------------------------------- | ------ |
|
||||
| subAction | The subaction to perform. It can be `pushToService`. | string |
|
||||
| subActionParams | The parameters of the subaction. | object |
|
||||
|
||||
|
||||
`subActionParams (pushToService)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| incident | The Swimlane incident. | object |
|
||||
| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ |
|
||||
|
||||
|
||||
The following table describes the properties of the `incident` object.
|
||||
|
||||
| Property | Description | Type |
|
||||
| ----------- | -------------------------------- | ------------------- |
|
||||
| alertId | The alert id. | string _(optional)_ |
|
||||
| caseId | The case id of the incident. | string _(optional)_ |
|
||||
| caseName | The case name of the incident. | string _(optional)_ |
|
||||
| description | The description of the incident. | string _(optional)_ |
|
||||
| ruleName | The rule name. | string _(optional)_ |
|
||||
| severity | The severity of the incident. | string _(optional)_ |
|
||||
---
|
||||
# Command Line Utility
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ const ACTION_TYPE_IDS = [
|
|||
'.pagerduty',
|
||||
'.server-log',
|
||||
'.slack',
|
||||
'.swimlane',
|
||||
'.teams',
|
||||
'.webhook',
|
||||
];
|
||||
|
|
|
@ -12,6 +12,7 @@ import { Logger } from '../../../../../src/core/server';
|
|||
import { getActionType as getEmailActionType } from './email';
|
||||
import { getActionType as getIndexActionType } from './es_index';
|
||||
import { getActionType as getPagerDutyActionType } from './pagerduty';
|
||||
import { getActionType as getSwimlaneActionType } from './swimlane';
|
||||
import { getActionType as getServerLogActionType } from './server_log';
|
||||
import { getActionType as getSlackActionType } from './slack';
|
||||
import { getActionType as getWebhookActionType } from './webhook';
|
||||
|
@ -65,6 +66,7 @@ export function registerBuiltInActionTypes({
|
|||
);
|
||||
actionTypeRegistry.register(getIndexActionType({ logger }));
|
||||
actionTypeRegistry.register(getPagerDutyActionType({ logger, configurationUtilities }));
|
||||
actionTypeRegistry.register(getSwimlaneActionType({ logger, configurationUtilities }));
|
||||
actionTypeRegistry.register(getServerLogActionType({ logger }));
|
||||
actionTypeRegistry.register(getSlackActionType({ logger, configurationUtilities }));
|
||||
actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities }));
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
JiraSecretConfigurationType,
|
||||
JiraExecutorResultData,
|
||||
ExecutorSubActionGetFieldsByIssueTypeParams,
|
||||
ExecutorSubActionGetIssueTypesParams,
|
||||
ExecutorSubActionCommonFieldsParams,
|
||||
ExecutorSubActionGetIssuesParams,
|
||||
ExecutorSubActionGetIssueParams,
|
||||
ExecutorSubActionGetIncidentParams,
|
||||
|
@ -137,7 +137,7 @@ async function executor(
|
|||
}
|
||||
|
||||
if (subAction === 'issueTypes') {
|
||||
const getIssueTypesParams = subActionParams as ExecutorSubActionGetIssueTypesParams;
|
||||
const getIssueTypesParams = subActionParams as ExecutorSubActionCommonFieldsParams;
|
||||
data = await api.issueTypes({
|
||||
externalService,
|
||||
params: getIssueTypesParams,
|
||||
|
|
|
@ -25,14 +25,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
|
|||
ExternalIncidentServiceSecretConfiguration
|
||||
);
|
||||
|
||||
export const ExecutorSubActionSchema = schema.oneOf([
|
||||
schema.literal('getIncident'),
|
||||
schema.literal('pushToService'),
|
||||
schema.literal('handshake'),
|
||||
schema.literal('issueTypes'),
|
||||
schema.literal('fieldsByIssueType'),
|
||||
]);
|
||||
|
||||
export const ExecutorSubActionPushParamsSchema = schema.object({
|
||||
incident: schema.object({
|
||||
summary: schema.string(),
|
||||
|
|
|
@ -155,12 +155,12 @@ describe('Jira service', () => {
|
|||
).toThrow();
|
||||
});
|
||||
|
||||
test('throws without username', () => {
|
||||
test('throws without email/username', () => {
|
||||
expect(() =>
|
||||
createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'test.com' },
|
||||
secrets: { apiToken: '', email: 'elastic@elastic.com' },
|
||||
config: { apiUrl: 'test.com', projectKey: 'CK' },
|
||||
secrets: { apiToken: 'token' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
|
@ -168,12 +168,12 @@ describe('Jira service', () => {
|
|||
).toThrow();
|
||||
});
|
||||
|
||||
test('throws without password', () => {
|
||||
test('throws without apiToken/password', () => {
|
||||
expect(() =>
|
||||
createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'test.com' },
|
||||
secrets: { apiToken: '', email: undefined },
|
||||
config: { apiUrl: 'test.com', projectKey: 'CK' },
|
||||
secrets: { email: 'elastic@elastic.com' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
|
|
|
@ -16,10 +16,10 @@ import {
|
|||
ExecutorSubActionGetIncidentParamsSchema,
|
||||
ExecutorSubActionHandshakeParamsSchema,
|
||||
ExecutorSubActionGetCapabilitiesParamsSchema,
|
||||
ExecutorSubActionGetIssueTypesParamsSchema,
|
||||
ExecutorSubActionGetFieldsByIssueTypeParamsSchema,
|
||||
ExecutorSubActionGetIssuesParamsSchema,
|
||||
ExecutorSubActionGetIssueParamsSchema,
|
||||
ExecutorSubActionCommonFieldsParamsSchema,
|
||||
} from './schema';
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
|
@ -124,8 +124,8 @@ export type ExecutorSubActionGetCapabilitiesParams = TypeOf<
|
|||
typeof ExecutorSubActionGetCapabilitiesParamsSchema
|
||||
>;
|
||||
|
||||
export type ExecutorSubActionGetIssueTypesParams = TypeOf<
|
||||
typeof ExecutorSubActionGetIssueTypesParamsSchema
|
||||
export type ExecutorSubActionCommonFieldsParams = TypeOf<
|
||||
typeof ExecutorSubActionCommonFieldsParamsSchema
|
||||
>;
|
||||
|
||||
export type ExecutorSubActionGetFieldsByIssueTypeParams = TypeOf<
|
||||
|
@ -157,12 +157,12 @@ export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs {
|
|||
|
||||
export interface GetIssueTypesHandlerArgs {
|
||||
externalService: ExternalService;
|
||||
params: ExecutorSubActionGetIssueTypesParams;
|
||||
params: ExecutorSubActionCommonFieldsParams;
|
||||
}
|
||||
|
||||
export interface GetCommonFieldsHandlerArgs {
|
||||
externalService: ExternalService;
|
||||
params: ExecutorSubActionGetIssueTypesParams;
|
||||
params: ExecutorSubActionCommonFieldsParams;
|
||||
}
|
||||
|
||||
export interface GetFieldsByIssueTypeHandlerArgs {
|
||||
|
|
|
@ -25,14 +25,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
|
|||
ExternalIncidentServiceSecretConfiguration
|
||||
);
|
||||
|
||||
export const ExecutorSubActionSchema = schema.oneOf([
|
||||
schema.literal('getIncident'),
|
||||
schema.literal('pushToService'),
|
||||
schema.literal('handshake'),
|
||||
schema.literal('incidentTypes'),
|
||||
schema.literal('severity'),
|
||||
]);
|
||||
|
||||
export const ExecutorSubActionPushParamsSchema = schema.object({
|
||||
incident: schema.object({
|
||||
name: schema.string(),
|
||||
|
|
|
@ -24,14 +24,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
|
|||
ExternalIncidentServiceSecretConfiguration
|
||||
);
|
||||
|
||||
export const ExecutorSubActionSchema = schema.oneOf([
|
||||
schema.literal('getFields'),
|
||||
schema.literal('getIncident'),
|
||||
schema.literal('pushToService'),
|
||||
schema.literal('handshake'),
|
||||
schema.literal('getChoices'),
|
||||
]);
|
||||
|
||||
const CommentsSchema = schema.nullable(
|
||||
schema.arrayOf(
|
||||
schema.object({
|
||||
|
|
|
@ -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 { api } from './api';
|
||||
import { ExternalService } from './types';
|
||||
import {
|
||||
apiParams,
|
||||
externalServiceMock,
|
||||
recordResponseCreate,
|
||||
recordResponseUpdate,
|
||||
} from './mocks';
|
||||
import { Logger } from '@kbn/logging';
|
||||
|
||||
let mockedLogger: jest.Mocked<Logger>;
|
||||
|
||||
describe('api', () => {
|
||||
let externalService: jest.Mocked<ExternalService>;
|
||||
|
||||
beforeEach(() => {
|
||||
externalService = externalServiceMock.create();
|
||||
});
|
||||
|
||||
describe('pushToService', () => {
|
||||
test('it pushes a new record', async () => {
|
||||
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
|
||||
const res = await api.pushToService({
|
||||
externalService,
|
||||
logger: mockedLogger,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(externalService.createComment).toHaveBeenCalled();
|
||||
expect(externalService.createRecord).toHaveBeenCalled();
|
||||
expect(externalService.updateRecord).not.toHaveBeenCalled();
|
||||
|
||||
expect(res).toEqual({
|
||||
...recordResponseCreate,
|
||||
comments: [
|
||||
{
|
||||
commentId: '123456',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
},
|
||||
{
|
||||
commentId: '123456',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('it pushes a new record without comment', async () => {
|
||||
const params = {
|
||||
...apiParams,
|
||||
incident: { ...apiParams.incident, externalId: null },
|
||||
comments: [],
|
||||
};
|
||||
const res = await api.pushToService({
|
||||
externalService,
|
||||
logger: mockedLogger,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(externalService.createComment).not.toHaveBeenCalled();
|
||||
expect(externalService.createRecord).toHaveBeenCalled();
|
||||
expect(res).toEqual(recordResponseCreate);
|
||||
});
|
||||
|
||||
test('updates existing record', async () => {
|
||||
const res = await api.pushToService({
|
||||
externalService,
|
||||
logger: mockedLogger,
|
||||
params: apiParams,
|
||||
});
|
||||
|
||||
expect(externalService.createComment).toHaveBeenCalled();
|
||||
expect(externalService.createRecord).not.toHaveBeenCalled();
|
||||
expect(externalService.updateRecord).toHaveBeenCalled();
|
||||
expect(res).toEqual({
|
||||
...recordResponseUpdate,
|
||||
comments: [
|
||||
{
|
||||
commentId: '123456',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
},
|
||||
{
|
||||
commentId: '123456',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('it calls createRecord correctly', async () => {
|
||||
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
|
||||
await api.pushToService({ externalService, params, logger: mockedLogger });
|
||||
|
||||
expect(externalService.createRecord).toHaveBeenCalledWith({
|
||||
incident: {
|
||||
alertId: '123456',
|
||||
caseId: '123456',
|
||||
caseName: 'case name',
|
||||
description: 'case desc',
|
||||
ruleName: 'rule name',
|
||||
severity: 'critical',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it calls createComment correctly', async () => {
|
||||
const mockedToISOString = jest
|
||||
.spyOn(Date.prototype, 'toISOString')
|
||||
.mockReturnValue('2021-06-15T18:02:29.404Z');
|
||||
|
||||
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
|
||||
await api.pushToService({ externalService, params, logger: mockedLogger });
|
||||
|
||||
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
|
||||
createdDate: '2021-06-15T18:02:29.404Z',
|
||||
incidentId: '123456',
|
||||
comment: {
|
||||
commentId: 'case-comment-1',
|
||||
comment: 'A comment',
|
||||
},
|
||||
});
|
||||
|
||||
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
|
||||
createdDate: '2021-06-15T18:02:29.404Z',
|
||||
incidentId: '123456',
|
||||
comment: {
|
||||
commentId: 'case-comment-2',
|
||||
comment: 'Another comment',
|
||||
},
|
||||
});
|
||||
|
||||
mockedToISOString.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 {
|
||||
ExternalServiceIncidentResponse,
|
||||
ExternalServiceApi,
|
||||
Incident,
|
||||
PushToServiceApiHandlerArgs,
|
||||
PushToServiceResponse,
|
||||
} from './types';
|
||||
|
||||
const pushToServiceHandler = async ({
|
||||
externalService,
|
||||
params,
|
||||
}: PushToServiceApiHandlerArgs): Promise<ExternalServiceIncidentResponse> => {
|
||||
const { comments } = params;
|
||||
let res: PushToServiceResponse;
|
||||
const { externalId, ...rest } = params.incident;
|
||||
const incident: Incident = rest;
|
||||
|
||||
if (externalId != null) {
|
||||
res = await externalService.updateRecord({
|
||||
incidentId: externalId,
|
||||
incident,
|
||||
});
|
||||
} else {
|
||||
res = await externalService.createRecord({ incident });
|
||||
}
|
||||
|
||||
const createdDate = new Date().toISOString();
|
||||
|
||||
if (comments && Array.isArray(comments) && comments.length > 0) {
|
||||
res.comments = [];
|
||||
for (const currentComment of comments) {
|
||||
const comment = await externalService.createComment({
|
||||
incidentId: res.id,
|
||||
comment: currentComment,
|
||||
createdDate,
|
||||
});
|
||||
|
||||
res.comments = [
|
||||
...(res.comments ?? []),
|
||||
{
|
||||
commentId: comment.commentId,
|
||||
pushedDate: comment.pushedDate,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const api: ExternalServiceApi = {
|
||||
pushToService: pushToServiceHandler,
|
||||
};
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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 { getBodyForEventAction } from './helpers';
|
||||
import { mappings } from './mocks';
|
||||
|
||||
describe('Create Record Mapping', () => {
|
||||
const appId = '45678';
|
||||
|
||||
test('it maps successfully', () => {
|
||||
const params = {
|
||||
alertId: 'al123',
|
||||
ruleName: 'Rule Name',
|
||||
severity: 'Critical',
|
||||
caseName: 'Case Name',
|
||||
caseId: 'es3456789',
|
||||
description: 'case desc',
|
||||
externalId: null,
|
||||
};
|
||||
|
||||
const data = getBodyForEventAction(appId, mappings, params);
|
||||
expect(data.applicationId).toEqual(appId);
|
||||
expect(data.id).not.toBeDefined();
|
||||
expect(data.values?.[mappings.alertIdConfig?.id ?? 0]).toEqual(params.alertId);
|
||||
expect(data.values?.[mappings.ruleNameConfig.id]).toEqual(params.ruleName);
|
||||
expect(data.values?.[mappings.caseNameConfig?.id ?? 0]).toEqual(params.caseName);
|
||||
expect(data.values?.[mappings.caseIdConfig?.id ?? 0]).toEqual(params.caseId);
|
||||
expect(data.values?.[mappings?.severityConfig?.id ?? 0]).toEqual(params.severity);
|
||||
expect(data.values?.[mappings?.descriptionConfig?.id ?? 0]).toEqual(params.description);
|
||||
});
|
||||
|
||||
test('it contains the id if defined', () => {
|
||||
const params = {
|
||||
alertId: 'al123',
|
||||
ruleName: 'Rule Name',
|
||||
severity: 'Critical',
|
||||
caseName: 'Case Name',
|
||||
caseId: 'es3456789',
|
||||
description: 'case desc',
|
||||
externalId: null,
|
||||
};
|
||||
const data = getBodyForEventAction(appId, mappings, params, '123');
|
||||
expect(data.id).toEqual('123');
|
||||
});
|
||||
|
||||
test('it does not includes null mappings', () => {
|
||||
const params = {
|
||||
alertId: 'al123',
|
||||
ruleName: 'Rule Name',
|
||||
severity: 'Critical',
|
||||
caseName: 'Case Name',
|
||||
caseId: 'es3456789',
|
||||
description: 'case desc',
|
||||
externalId: null,
|
||||
};
|
||||
|
||||
// @ts-expect-error
|
||||
const data = getBodyForEventAction(appId, { ...mappings, test: null }, params);
|
||||
expect(data.values?.test).not.toBeDefined();
|
||||
});
|
||||
|
||||
test('it converts a numeric values correctly', () => {
|
||||
const params = {
|
||||
alertId: 'thisIsNotANumber',
|
||||
ruleName: 'Rule Name',
|
||||
severity: 'Critical',
|
||||
caseName: 'Case Name',
|
||||
caseId: '123',
|
||||
description: 'case desc',
|
||||
externalId: null,
|
||||
};
|
||||
|
||||
const data = getBodyForEventAction(
|
||||
appId,
|
||||
{
|
||||
...mappings,
|
||||
caseIdConfig: { ...mappings.caseIdConfig, fieldType: 'numeric' },
|
||||
alertIdConfig: { ...mappings.alertIdConfig, fieldType: 'numeric' },
|
||||
},
|
||||
params
|
||||
);
|
||||
|
||||
expect(data.values?.[mappings.alertIdConfig?.id ?? 0]).toBe(0);
|
||||
expect(data.values?.[mappings.caseIdConfig?.id ?? 0]).toBe(123);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { CreateRecordParams, Incident, SwimlaneRecordPayload, MappingConfigType } from './types';
|
||||
|
||||
type ConfigMapping = Omit<MappingConfigType, 'commentsConfig'>;
|
||||
|
||||
const mappingKeysToIncidentKeys: Record<keyof ConfigMapping, keyof Incident> = {
|
||||
ruleNameConfig: 'ruleName',
|
||||
alertIdConfig: 'alertId',
|
||||
caseIdConfig: 'caseId',
|
||||
caseNameConfig: 'caseName',
|
||||
severityConfig: 'severity',
|
||||
descriptionConfig: 'description',
|
||||
};
|
||||
|
||||
export const getBodyForEventAction = (
|
||||
applicationId: string,
|
||||
mappingConfig: MappingConfigType,
|
||||
params: CreateRecordParams['incident'],
|
||||
incidentId?: string
|
||||
): SwimlaneRecordPayload => {
|
||||
const data: SwimlaneRecordPayload = {
|
||||
applicationId,
|
||||
...(incidentId ? { id: incidentId } : {}),
|
||||
values: {},
|
||||
};
|
||||
|
||||
return (Object.keys(mappingConfig) as Array<keyof ConfigMapping>).reduce((acc, key) => {
|
||||
const fieldMap = mappingConfig[key];
|
||||
|
||||
if (!fieldMap) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const { id, fieldType } = fieldMap;
|
||||
const paramName = mappingKeysToIncidentKeys[key];
|
||||
const value = params[paramName];
|
||||
|
||||
if (value) {
|
||||
switch (fieldType) {
|
||||
case 'numeric': {
|
||||
const number = Number(value);
|
||||
return { ...acc, values: { ...acc.values, [id]: isNaN(number) ? 0 : number } };
|
||||
}
|
||||
default: {
|
||||
return { ...acc, values: { ...acc.values, [id]: value } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, data);
|
||||
};
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* 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 { curry } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { Logger } from '@kbn/logging';
|
||||
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
import {
|
||||
SwimlaneExecutorResultData,
|
||||
SwimlanePublicConfigurationType,
|
||||
SwimlaneSecretConfigurationType,
|
||||
ExecutorParams,
|
||||
ExecutorSubActionPushParams,
|
||||
} from './types';
|
||||
import { validate } from './validators';
|
||||
import {
|
||||
ExecutorParamsSchema,
|
||||
SwimlaneSecretsConfiguration,
|
||||
SwimlaneServiceConfiguration,
|
||||
} from './schema';
|
||||
import { createExternalService } from './service';
|
||||
import { api } from './api';
|
||||
|
||||
interface GetActionTypeParams {
|
||||
logger: Logger;
|
||||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
}
|
||||
|
||||
const supportedSubActions: string[] = ['pushToService'];
|
||||
|
||||
// action type definition
|
||||
export function getActionType(
|
||||
params: GetActionTypeParams
|
||||
): ActionType<
|
||||
SwimlanePublicConfigurationType,
|
||||
SwimlaneSecretConfigurationType,
|
||||
ExecutorParams,
|
||||
SwimlaneExecutorResultData | {}
|
||||
> {
|
||||
const { logger, configurationUtilities } = params;
|
||||
|
||||
return {
|
||||
id: '.swimlane',
|
||||
minimumLicenseRequired: 'gold',
|
||||
name: i18n.translate('xpack.actions.builtin.swimlaneTitle', {
|
||||
defaultMessage: 'Swimlane',
|
||||
}),
|
||||
validate: {
|
||||
config: schema.object(SwimlaneServiceConfiguration, {
|
||||
validate: curry(validate.config)(configurationUtilities),
|
||||
}),
|
||||
secrets: schema.object(SwimlaneSecretsConfiguration, {
|
||||
validate: curry(validate.secrets)(configurationUtilities),
|
||||
}),
|
||||
params: ExecutorParamsSchema,
|
||||
},
|
||||
executor: curry(executor)({ logger, configurationUtilities }),
|
||||
};
|
||||
}
|
||||
|
||||
async function executor(
|
||||
{
|
||||
logger,
|
||||
configurationUtilities,
|
||||
}: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities },
|
||||
execOptions: ActionTypeExecutorOptions<
|
||||
SwimlanePublicConfigurationType,
|
||||
SwimlaneSecretConfigurationType,
|
||||
ExecutorParams
|
||||
>
|
||||
): Promise<ActionTypeExecutorResult<SwimlaneExecutorResultData | {}>> {
|
||||
const { actionId, config, params, secrets } = execOptions;
|
||||
const { subAction, subActionParams } = params as ExecutorParams;
|
||||
let data: SwimlaneExecutorResultData | null = null;
|
||||
|
||||
const externalService = createExternalService(
|
||||
{
|
||||
config,
|
||||
secrets,
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
);
|
||||
|
||||
if (!api[subAction]) {
|
||||
const errorMessage = `[Action][ExternalService] -> [Swimlane] Unsupported subAction type ${subAction}.`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (!supportedSubActions.includes(subAction)) {
|
||||
const errorMessage = `[Action][ExternalService] -> [Swimlane] subAction ${subAction} not implemented.`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (subAction === 'pushToService') {
|
||||
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
|
||||
|
||||
data = await api.pushToService({
|
||||
externalService,
|
||||
params: pushToServiceParams,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.debug(`response push to service for incident id: ${data.id}`);
|
||||
}
|
||||
|
||||
return { status: 'ok', data: data ?? {}, actionId };
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ExecutorSubActionPushParams, ExternalService, PushToServiceApiParams } from './types';
|
||||
|
||||
export const applicationFields = [
|
||||
{
|
||||
id: 'adnlas',
|
||||
name: 'Severity',
|
||||
key: 'severity',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'adnfls',
|
||||
name: 'Rule Name',
|
||||
key: 'rule-name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'a6sst',
|
||||
name: 'Case Id',
|
||||
key: 'case-id-name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'a6fst',
|
||||
name: 'Case Name',
|
||||
key: 'case-name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'a6fdf',
|
||||
name: 'Comments',
|
||||
key: 'comments',
|
||||
fieldType: 'notes',
|
||||
},
|
||||
{
|
||||
id: 'a6fde',
|
||||
name: 'Description',
|
||||
key: 'description',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'dfnkls',
|
||||
name: 'Alert ID',
|
||||
key: 'alert-id',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
export const mappings = {
|
||||
severityConfig: applicationFields[0],
|
||||
ruleNameConfig: applicationFields[1],
|
||||
caseIdConfig: applicationFields[2],
|
||||
caseNameConfig: applicationFields[3],
|
||||
commentsConfig: applicationFields[4],
|
||||
descriptionConfig: applicationFields[5],
|
||||
alertIdConfig: applicationFields[6],
|
||||
};
|
||||
|
||||
export const getApplicationResponse = { fields: applicationFields };
|
||||
|
||||
export const recordResponseCreate = {
|
||||
id: '123456',
|
||||
title: 'neato',
|
||||
url: 'swimlane.com',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
};
|
||||
|
||||
export const recordResponseUpdate = {
|
||||
id: '98765',
|
||||
title: 'not neato',
|
||||
url: 'laneswim.com',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
};
|
||||
|
||||
export const commentResponse = {
|
||||
commentId: '123456',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
};
|
||||
|
||||
const createMock = (): jest.Mocked<ExternalService> => {
|
||||
return {
|
||||
createComment: jest.fn().mockImplementation(() => Promise.resolve(commentResponse)),
|
||||
createRecord: jest.fn().mockImplementation(() => Promise.resolve(recordResponseCreate)),
|
||||
updateRecord: jest.fn().mockImplementation(() => Promise.resolve(recordResponseUpdate)),
|
||||
};
|
||||
};
|
||||
|
||||
const externalServiceMock = {
|
||||
create: createMock,
|
||||
};
|
||||
|
||||
const executorParams: ExecutorSubActionPushParams = {
|
||||
incident: {
|
||||
ruleName: 'rule name',
|
||||
alertId: '123456',
|
||||
caseName: 'case name',
|
||||
severity: 'critical',
|
||||
caseId: '123456',
|
||||
description: 'case desc',
|
||||
externalId: 'incident-3',
|
||||
},
|
||||
comments: [
|
||||
{
|
||||
commentId: 'case-comment-1',
|
||||
comment: 'A comment',
|
||||
},
|
||||
{
|
||||
commentId: 'case-comment-2',
|
||||
comment: 'Another comment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const apiParams: PushToServiceApiParams = {
|
||||
...executorParams,
|
||||
};
|
||||
|
||||
export { externalServiceMock, executorParams, apiParams };
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
export const ConfigMap = {
|
||||
id: schema.string(),
|
||||
key: schema.string(),
|
||||
name: schema.string(),
|
||||
fieldType: schema.string(),
|
||||
};
|
||||
|
||||
export const ConfigMapSchema = schema.object(ConfigMap);
|
||||
|
||||
export const ConfigMapping = {
|
||||
ruleNameConfig: schema.nullable(ConfigMapSchema),
|
||||
alertIdConfig: schema.nullable(ConfigMapSchema),
|
||||
caseIdConfig: schema.nullable(ConfigMapSchema),
|
||||
caseNameConfig: schema.nullable(ConfigMapSchema),
|
||||
commentsConfig: schema.nullable(ConfigMapSchema),
|
||||
severityConfig: schema.nullable(ConfigMapSchema),
|
||||
descriptionConfig: schema.nullable(ConfigMapSchema),
|
||||
};
|
||||
|
||||
export const ConfigMappingSchema = schema.object(ConfigMapping);
|
||||
|
||||
export const SwimlaneServiceConfiguration = {
|
||||
apiUrl: schema.string(),
|
||||
appId: schema.string(),
|
||||
connectorType: schema.string(),
|
||||
mappings: ConfigMappingSchema,
|
||||
};
|
||||
|
||||
export const SwimlaneServiceConfigurationSchema = schema.object(SwimlaneServiceConfiguration);
|
||||
|
||||
export const SwimlaneSecretsConfiguration = {
|
||||
apiToken: schema.string(),
|
||||
};
|
||||
|
||||
export const SwimlaneSecretsConfigurationSchema = schema.object(SwimlaneSecretsConfiguration);
|
||||
|
||||
const SwimlaneFields = {
|
||||
alertId: schema.nullable(schema.string()),
|
||||
ruleName: schema.nullable(schema.string()),
|
||||
caseId: schema.nullable(schema.string()),
|
||||
caseName: schema.nullable(schema.string()),
|
||||
severity: schema.nullable(schema.string()),
|
||||
description: schema.nullable(schema.string()),
|
||||
};
|
||||
|
||||
export const ExecutorSubActionPushParamsSchema = schema.object({
|
||||
incident: schema.object({
|
||||
...SwimlaneFields,
|
||||
externalId: schema.nullable(schema.string()),
|
||||
}),
|
||||
comments: schema.nullable(
|
||||
schema.arrayOf(
|
||||
schema.object({
|
||||
comment: schema.string(),
|
||||
commentId: schema.string(),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
export const ExecutorParamsSchema = schema.oneOf([
|
||||
schema.object({
|
||||
subAction: schema.literal('pushToService'),
|
||||
subActionParams: ExecutorSubActionPushParamsSchema,
|
||||
}),
|
||||
]);
|
|
@ -0,0 +1,434 @@
|
|||
/*
|
||||
* 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 axios from 'axios';
|
||||
|
||||
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import { actionsConfigMock } from '../../actions_config.mock';
|
||||
import * as utils from '../lib/axios_utils';
|
||||
import { createExternalService } from './service';
|
||||
import { mappings } from './mocks';
|
||||
import { ExternalService } from './types';
|
||||
|
||||
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
||||
|
||||
jest.mock('axios');
|
||||
jest.mock('../lib/axios_utils', () => {
|
||||
const originalUtils = jest.requireActual('../lib/axios_utils');
|
||||
return {
|
||||
...originalUtils,
|
||||
request: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
axios.create = jest.fn(() => axios);
|
||||
const requestMock = utils.request as jest.Mock;
|
||||
const configurationUtilities = actionsConfigMock.create();
|
||||
|
||||
describe('Swimlane Service', () => {
|
||||
let service: ExternalService;
|
||||
const config = {
|
||||
apiUrl: 'https://test.swimlane.com/',
|
||||
appId: 'bcq16kdTbz5jlwM6h',
|
||||
connectorType: 'all',
|
||||
mappings,
|
||||
};
|
||||
const apiToken = 'token';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Private-Token': apiToken,
|
||||
};
|
||||
|
||||
const incident = {
|
||||
ruleName: 'Rule Name',
|
||||
caseId: 'Case Id',
|
||||
caseName: 'Case Name',
|
||||
severity: 'Severity',
|
||||
externalId: null,
|
||||
description: 'Description',
|
||||
alertId: 'Alert Id',
|
||||
};
|
||||
|
||||
const url = config.apiUrl.slice(0, -1);
|
||||
|
||||
beforeAll(() => {
|
||||
service = createExternalService(
|
||||
{
|
||||
// The trailing slash at the end of the url is intended.
|
||||
// All API calls need to have the trailing slash removed.
|
||||
config,
|
||||
secrets: { apiToken },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
);
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createExternalService', () => {
|
||||
test('throws without url', () => {
|
||||
expect(() =>
|
||||
createExternalService(
|
||||
{
|
||||
config: {
|
||||
// @ts-ignore
|
||||
apiUrl: null,
|
||||
appId: '99999',
|
||||
mappings,
|
||||
},
|
||||
secrets: { apiToken },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test('throws without app id', () => {
|
||||
expect(() =>
|
||||
createExternalService(
|
||||
{
|
||||
config: {
|
||||
apiUrl: 'test.com',
|
||||
// @ts-ignore
|
||||
appId: null,
|
||||
},
|
||||
secrets: { apiToken },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test('throws without mappings', () => {
|
||||
expect(() =>
|
||||
createExternalService(
|
||||
{
|
||||
config: {
|
||||
apiUrl: 'test.com',
|
||||
appId: '987987',
|
||||
// @ts-ignore
|
||||
mappings: null,
|
||||
},
|
||||
secrets: { apiToken },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test('throws without api token', () => {
|
||||
expect(() => {
|
||||
return createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'test.com', appId: '78978', mappings, connectorType: 'all' },
|
||||
secrets: {
|
||||
// @ts-ignore
|
||||
apiToken: null,
|
||||
},
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRecord', () => {
|
||||
const data = {
|
||||
id: '123',
|
||||
name: 'title',
|
||||
createdDate: '2021-06-01T17:29:51.092Z',
|
||||
};
|
||||
|
||||
test('it creates a record correctly', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data,
|
||||
}));
|
||||
|
||||
const res = await service.createRecord({
|
||||
incident,
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
id: '123',
|
||||
title: 'title',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
url: `${url}/record/${config.appId}/123`,
|
||||
});
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data,
|
||||
}));
|
||||
|
||||
await service.createRecord({
|
||||
incident,
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
headers,
|
||||
data: {
|
||||
applicationId: config.appId,
|
||||
values: {
|
||||
[mappings.ruleNameConfig.id]: 'Rule Name',
|
||||
[mappings.caseNameConfig.id]: 'Case Name',
|
||||
[mappings.caseIdConfig.id]: 'Case Id',
|
||||
[mappings.severityConfig.id]: 'Severity',
|
||||
[mappings.descriptionConfig.id]: 'Description',
|
||||
[mappings.alertIdConfig.id]: 'Alert Id',
|
||||
},
|
||||
},
|
||||
url: `${url}/api/app/${config.appId}/record`,
|
||||
method: 'post',
|
||||
configurationUtilities,
|
||||
});
|
||||
});
|
||||
|
||||
test('it should throw an error', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
throw new Error('An error has occurred');
|
||||
});
|
||||
|
||||
await expect(service.createRecord({ incident })).rejects.toThrow(
|
||||
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRecord', () => {
|
||||
const data = {
|
||||
id: '123',
|
||||
name: 'title',
|
||||
modifiedDate: '2021-06-01T17:29:51.092Z',
|
||||
};
|
||||
const incidentId = '123';
|
||||
|
||||
test('it updates a record correctly', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data,
|
||||
}));
|
||||
|
||||
const res = await service.updateRecord({
|
||||
incident,
|
||||
incidentId,
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
id: '123',
|
||||
title: 'title',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
url: `${url}/record/${config.appId}/123`,
|
||||
});
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data,
|
||||
}));
|
||||
|
||||
await service.updateRecord({
|
||||
incident,
|
||||
incidentId,
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
headers,
|
||||
data: {
|
||||
applicationId: config.appId,
|
||||
id: incidentId,
|
||||
values: {
|
||||
[mappings.ruleNameConfig.id]: 'Rule Name',
|
||||
[mappings.caseNameConfig.id]: 'Case Name',
|
||||
[mappings.caseIdConfig.id]: 'Case Id',
|
||||
[mappings.severityConfig.id]: 'Severity',
|
||||
[mappings.descriptionConfig.id]: 'Description',
|
||||
[mappings.alertIdConfig.id]: 'Alert Id',
|
||||
},
|
||||
},
|
||||
url: `${url}/api/app/${config.appId}/record/${incidentId}`,
|
||||
method: 'patch',
|
||||
configurationUtilities,
|
||||
});
|
||||
});
|
||||
|
||||
test('it should throw an error', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
throw new Error('An error has occurred');
|
||||
});
|
||||
|
||||
await expect(service.updateRecord({ incident, incidentId })).rejects.toThrow(
|
||||
`[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createComment', () => {
|
||||
const data = {
|
||||
id: '123',
|
||||
name: 'title',
|
||||
modifiedDate: '2021-06-01T17:29:51.092Z',
|
||||
};
|
||||
const incidentId = '123';
|
||||
const comment = { commentId: '456', comment: 'A comment' };
|
||||
const createdDate = '2021-06-01T17:29:51.092Z';
|
||||
|
||||
test('it updates a record correctly', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data,
|
||||
}));
|
||||
|
||||
const res = await service.createComment({
|
||||
comment,
|
||||
incidentId,
|
||||
createdDate,
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
commentId: '456',
|
||||
pushedDate: '2021-06-01T17:29:51.092Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data,
|
||||
}));
|
||||
|
||||
await service.createComment({
|
||||
comment,
|
||||
incidentId,
|
||||
createdDate,
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
headers,
|
||||
data: {
|
||||
createdDate,
|
||||
fieldId: mappings.commentsConfig.id,
|
||||
isRichText: true,
|
||||
message: comment.comment,
|
||||
},
|
||||
url: `${url}/api/app/${config.appId}/record/${incidentId}/${mappings.commentsConfig.id}/comment`,
|
||||
method: 'post',
|
||||
configurationUtilities,
|
||||
});
|
||||
});
|
||||
|
||||
test('it should throw an error', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
throw new Error('An error has occurred');
|
||||
});
|
||||
|
||||
await expect(service.createComment({ comment, incidentId, createdDate })).rejects.toThrow(
|
||||
`[Action][Swimlane]: Unable to create comment in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error messages', () => {
|
||||
const errorResponse = { ErrorCode: '1', Argument: 'Invalid field' };
|
||||
|
||||
test('it contains the response error', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
const error = new Error('An error has occurred');
|
||||
// @ts-ignore
|
||||
error.response = { data: errorResponse };
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createRecord({
|
||||
incident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: Invalid field (1)`
|
||||
);
|
||||
});
|
||||
|
||||
test('it shows an empty string for reason if the ErrorCode is undefined', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
const error = new Error('An error has occurred');
|
||||
// @ts-ignore
|
||||
error.response = { data: { ErrorCode: '1' } };
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createRecord({
|
||||
incident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
|
||||
);
|
||||
});
|
||||
|
||||
test('it shows an empty string for reason if the Argument is undefined', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
const error = new Error('An error has occurred');
|
||||
// @ts-ignore
|
||||
error.response = { data: { Argument: 'Invalid field' } };
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createRecord({
|
||||
incident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
|
||||
);
|
||||
});
|
||||
|
||||
test('it shows an empty string for reason if data is undefined', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
const error = new Error('An error has occurred');
|
||||
// @ts-ignore
|
||||
error.response = {};
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createRecord({
|
||||
incident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
|
||||
);
|
||||
});
|
||||
|
||||
test('it shows the status code', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
const error = new Error('An error has occurred');
|
||||
// @ts-ignore
|
||||
error.response = { data: errorResponse, status: 400 };
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createRecord({
|
||||
incident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 400. Error: An error has occurred. Reason: Invalid field (1)`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,196 @@
|
|||
/*
|
||||
* 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/logging';
|
||||
import axios from 'axios';
|
||||
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
import { getErrorMessage, request } from '../lib/axios_utils';
|
||||
import { getBodyForEventAction } from './helpers';
|
||||
import {
|
||||
CreateCommentParams,
|
||||
CreateRecordParams,
|
||||
ExternalService,
|
||||
ExternalServiceCredentials,
|
||||
ExternalServiceIncidentResponse,
|
||||
MappingConfigType,
|
||||
ResponseError,
|
||||
SwimlanePublicConfigurationType,
|
||||
SwimlaneRecordPayload,
|
||||
SwimlaneSecretConfigurationType,
|
||||
UpdateRecordParams,
|
||||
} from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const createErrorMessage = (errorResponse: ResponseError | null | undefined): string => {
|
||||
if (errorResponse == null) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const { ErrorCode, Argument } = errorResponse;
|
||||
return Argument != null && ErrorCode != null ? `${Argument} (${ErrorCode})` : 'unknown';
|
||||
};
|
||||
|
||||
export const createExternalService = (
|
||||
{ config, secrets }: ExternalServiceCredentials,
|
||||
logger: Logger,
|
||||
configurationUtilities: ActionsConfigurationUtilities
|
||||
): ExternalService => {
|
||||
const { apiUrl: url, appId, mappings } = config as SwimlanePublicConfigurationType;
|
||||
const { apiToken } = secrets as SwimlaneSecretConfigurationType;
|
||||
|
||||
const axiosInstance = axios.create();
|
||||
|
||||
if (!url || !appId || !apiToken || !mappings) {
|
||||
throw Error(`[Action]${i18n.NAME}: Wrong configuration.`);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Private-Token': `${secrets.apiToken}`,
|
||||
};
|
||||
|
||||
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
const apiUrl = urlWithoutTrailingSlash.endsWith('api')
|
||||
? urlWithoutTrailingSlash
|
||||
: urlWithoutTrailingSlash + '/api';
|
||||
|
||||
const getPostRecordUrl = (id: string) => `${apiUrl}/app/${id}/record`;
|
||||
|
||||
const getPostRecordIdUrl = (id: string, recordId: string) =>
|
||||
`${getPostRecordUrl(id)}/${recordId}`;
|
||||
|
||||
const getRecordIdUrl = (id: string, recordId: string) =>
|
||||
`${urlWithoutTrailingSlash}/record/${id}/${recordId}`;
|
||||
|
||||
const getPostCommentUrl = (id: string, recordId: string, commentFieldId: string) =>
|
||||
`${getPostRecordIdUrl(id, recordId)}/${commentFieldId}/comment`;
|
||||
|
||||
const getCommentFieldId = (fieldMappings: MappingConfigType): string | null =>
|
||||
fieldMappings.commentsConfig?.id || null;
|
||||
|
||||
const createRecord = async (
|
||||
params: CreateRecordParams
|
||||
): Promise<ExternalServiceIncidentResponse> => {
|
||||
try {
|
||||
const mappingConfig = mappings as MappingConfigType;
|
||||
const data = getBodyForEventAction(appId, mappingConfig, params.incident);
|
||||
|
||||
const res = await request({
|
||||
axios: axiosInstance,
|
||||
configurationUtilities,
|
||||
data,
|
||||
headers,
|
||||
logger,
|
||||
method: 'post',
|
||||
url: getPostRecordUrl(appId),
|
||||
});
|
||||
return {
|
||||
id: res.data.id,
|
||||
title: res.data.name,
|
||||
url: getRecordIdUrl(appId, res.data.id),
|
||||
pushedDate: new Date(res.data.createdDate).toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
getErrorMessage(
|
||||
i18n.NAME,
|
||||
`Unable to create record in application with id ${appId}. Status: ${
|
||||
error.response?.status ?? 500
|
||||
}. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRecord = async (
|
||||
params: UpdateRecordParams
|
||||
): Promise<ExternalServiceIncidentResponse> => {
|
||||
try {
|
||||
const mappingConfig = mappings as MappingConfigType;
|
||||
const data = getBodyForEventAction(appId, mappingConfig, params.incident, params.incidentId);
|
||||
|
||||
const res = await request<SwimlaneRecordPayload>({
|
||||
axios: axiosInstance,
|
||||
configurationUtilities,
|
||||
data,
|
||||
headers,
|
||||
logger,
|
||||
method: 'patch',
|
||||
url: getPostRecordIdUrl(appId, params.incidentId),
|
||||
});
|
||||
|
||||
return {
|
||||
id: res.data.id,
|
||||
title: res.data.name,
|
||||
url: getRecordIdUrl(appId, params.incidentId),
|
||||
pushedDate: new Date(res.data.modifiedDate).toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
getErrorMessage(
|
||||
i18n.NAME,
|
||||
`Unable to update record in application with id ${appId}. Status: ${
|
||||
error.response?.status ?? 500
|
||||
}. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const createComment = async ({ incidentId, comment, createdDate }: CreateCommentParams) => {
|
||||
try {
|
||||
const mappingConfig = mappings as MappingConfigType;
|
||||
const fieldId = getCommentFieldId(mappingConfig);
|
||||
|
||||
if (fieldId == null) {
|
||||
throw new Error(`No comment field mapped in ${i18n.NAME} connector`);
|
||||
}
|
||||
|
||||
const data = {
|
||||
createdDate,
|
||||
fieldId,
|
||||
isRichText: true,
|
||||
message: comment.comment,
|
||||
};
|
||||
|
||||
await request({
|
||||
axios: axiosInstance,
|
||||
configurationUtilities,
|
||||
data,
|
||||
headers,
|
||||
logger,
|
||||
method: 'post',
|
||||
url: getPostCommentUrl(appId, incidentId, fieldId),
|
||||
});
|
||||
|
||||
/**
|
||||
* Swimlane response does not contain any data.
|
||||
* We cannot get an externalCommentId
|
||||
*/
|
||||
return {
|
||||
commentId: comment.commentId,
|
||||
pushedDate: createdDate,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
getErrorMessage(
|
||||
i18n.NAME,
|
||||
`Unable to create comment in application with id ${appId}. Status: ${
|
||||
error.response?.status ?? 500
|
||||
}. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createComment,
|
||||
createRecord,
|
||||
updateRecord,
|
||||
};
|
||||
};
|
|
@ -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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const NAME = i18n.translate('xpack.actions.builtin.case.swimlaneTitle', {
|
||||
defaultMessage: 'Swimlane',
|
||||
});
|
||||
|
||||
export const ALLOWED_HOSTS_ERROR = (message: string) =>
|
||||
i18n.translate('xpack.actions.builtin.swimlane.configuration.apiAllowedHostsError', {
|
||||
defaultMessage: 'error configuring connector action: {message}',
|
||||
values: {
|
||||
message,
|
||||
},
|
||||
});
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { TypeOf } from '@kbn/config-schema';
|
||||
import { Logger } from '@kbn/logging';
|
||||
import {
|
||||
ConfigMappingSchema,
|
||||
ExecutorParamsSchema,
|
||||
ExecutorSubActionPushParamsSchema,
|
||||
SwimlaneSecretsConfigurationSchema,
|
||||
SwimlaneServiceConfigurationSchema,
|
||||
} from './schema';
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
|
||||
export type SwimlanePublicConfigurationType = TypeOf<typeof SwimlaneServiceConfigurationSchema>;
|
||||
export type SwimlaneSecretConfigurationType = TypeOf<typeof SwimlaneSecretsConfigurationSchema>;
|
||||
|
||||
export type MappingConfigType = TypeOf<typeof ConfigMappingSchema>;
|
||||
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
|
||||
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
|
||||
|
||||
export interface ExternalServiceCredentials {
|
||||
config: SwimlanePublicConfigurationType;
|
||||
secrets: SwimlaneSecretConfigurationType;
|
||||
}
|
||||
|
||||
export interface ExternalServiceValidation {
|
||||
config: (configurationUtilities: ActionsConfigurationUtilities, configObject: any) => void;
|
||||
secrets: (configurationUtilities: ActionsConfigurationUtilities, secrets: any) => void;
|
||||
}
|
||||
|
||||
export interface CreateRecordParams {
|
||||
incident: Incident;
|
||||
}
|
||||
export interface UpdateRecordParams extends CreateRecordParams {
|
||||
incidentId: string;
|
||||
}
|
||||
|
||||
export type PushToServiceApiParams = ExecutorSubActionPushParams;
|
||||
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
|
||||
params: PushToServiceApiParams;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface ExternalServiceIncidentResponse {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
pushedDate: string;
|
||||
}
|
||||
export interface ExternalServiceCommentResponse {
|
||||
commentId: string;
|
||||
pushedDate: string;
|
||||
externalCommentId?: string;
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
fieldType: string;
|
||||
}
|
||||
|
||||
export interface SwimlaneRecordPayload {
|
||||
applicationId: string;
|
||||
values: SwimlaneDataValues;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ExternalService {
|
||||
createComment: (params: CreateCommentParams) => Promise<ExternalServiceCommentResponse>;
|
||||
createRecord: (params: CreateRecordParams) => Promise<ExternalServiceIncidentResponse>;
|
||||
updateRecord: (params: UpdateRecordParams) => Promise<ExternalServiceIncidentResponse>;
|
||||
}
|
||||
|
||||
export type Incident = Omit<ExecutorSubActionPushParams['incident'], 'externalId'>;
|
||||
|
||||
export interface ExternalServiceApiHandlerArgs {
|
||||
externalService: ExternalService;
|
||||
}
|
||||
|
||||
export interface GetApplicationHandlerArgs {
|
||||
externalService: ExternalService;
|
||||
}
|
||||
|
||||
export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
|
||||
comments?: ExternalServiceCommentResponse[];
|
||||
}
|
||||
|
||||
export interface ExternalServiceApi {
|
||||
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<ExternalServiceIncidentResponse>;
|
||||
}
|
||||
|
||||
export type SwimlaneExecutorResultData = ExternalServiceIncidentResponse;
|
||||
export type SwimlaneDataValues = Record<string, string | number>;
|
||||
export interface SwimlaneComment {
|
||||
fieldId: string;
|
||||
message: string | number;
|
||||
createdDate: string;
|
||||
isRichText: boolean;
|
||||
}
|
||||
export type SwimlaneDataComments = Record<string, SwimlaneComment[]>;
|
||||
|
||||
export interface SimpleComment {
|
||||
comment: SwimlaneComment['message'];
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
export interface CreateCommentParams {
|
||||
incidentId: string;
|
||||
comment: SimpleComment;
|
||||
createdDate: string;
|
||||
}
|
||||
|
||||
export interface ResponseError {
|
||||
ErrorCode: number;
|
||||
Argument: string;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
import { ExternalServiceValidation, SwimlanePublicConfigurationType } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const validateCommonConfig = (
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
configObject: SwimlanePublicConfigurationType
|
||||
) => {
|
||||
try {
|
||||
configurationUtilities.ensureUriAllowed(configObject.apiUrl);
|
||||
} catch (allowedListError) {
|
||||
return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const validateCommonSecrets = () => {};
|
||||
|
||||
export const validate: ExternalServiceValidation = {
|
||||
config: validateCommonConfig,
|
||||
secrets: validateCommonSecrets,
|
||||
};
|
|
@ -47,7 +47,6 @@ export type {
|
|||
TeamsActionTypeId,
|
||||
TeamsActionParams,
|
||||
} from './builtin_action_types';
|
||||
|
||||
export type { PluginSetupContract, PluginStartContract } from './plugin';
|
||||
|
||||
export { asSavedObjectExecutionSource, asHttpRequestExecutionSource } from './lib';
|
||||
|
|
|
@ -22,7 +22,7 @@ export { ActionTypeExecutorResult } from '../common';
|
|||
export { GetFieldsByIssueTypeResponse as JiraGetFieldsResponse } from './builtin_action_types/jira/types';
|
||||
export { GetCommonFieldsResponse as ServiceNowGetFieldsResponse } from './builtin_action_types/servicenow/types';
|
||||
export { GetCommonFieldsResponse as ResilientGetFieldsResponse } from './builtin_action_types/resilient/types';
|
||||
|
||||
export { SwimlanePublicConfigurationType } from './builtin_action_types/swimlane/types';
|
||||
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
|
||||
export type GetServicesFunction = (request: KibanaRequest) => Services;
|
||||
export type ActionTypeRegistryContract = PublicMethodsOf<ActionTypeRegistry>;
|
||||
|
|
|
@ -18,6 +18,7 @@ const byTypeSchema: MakeSchemaFrom<ActionsUsage>['count_by_type'] = {
|
|||
__email: { type: 'long' },
|
||||
__index: { type: 'long' },
|
||||
__pagerduty: { type: 'long' },
|
||||
__swimlane: { type: 'long' },
|
||||
'__server-log': { type: 'long' },
|
||||
__slack: { type: 'long' },
|
||||
__webhook: { type: 'long' },
|
||||
|
|
|
@ -215,7 +215,7 @@ This action type has no `secrets` properties.
|
|||
| -------- | ------------------------------------------------------------------------------------------------- | ----------------- |
|
||||
| id | ID of the connector used for pushing case updates to external systems. | string |
|
||||
| name | The connector name. | string |
|
||||
| type | The type of the connector. Must be one of these: `.servicenow`, `jira`, `.resilient`, and `.none` | string |
|
||||
| type | The type of the connector. Must be one of these: `.servicenow`, `.servicenow-sir`, `.swimlane`, `jira`, `.resilient`, and `.none` | string |
|
||||
| fields | Object containing the connector’s fields. | [fields](#fields) |
|
||||
|
||||
#### `fields`
|
||||
|
|
|
@ -12,12 +12,14 @@ import { JiraFieldsRT } from './jira';
|
|||
import { ResilientFieldsRT } from './resilient';
|
||||
import { ServiceNowITSMFieldsRT } from './servicenow_itsm';
|
||||
import { ServiceNowSIRFieldsRT } from './servicenow_sir';
|
||||
import { SwimlaneFieldsRT } from './swimlane';
|
||||
|
||||
export * from './jira';
|
||||
export * from './servicenow_itsm';
|
||||
export * from './servicenow_sir';
|
||||
export * from './resilient';
|
||||
export * from './mappings';
|
||||
export * from './swimlane';
|
||||
|
||||
export type ActionConnector = ActionResult;
|
||||
export type ActionTypeConnector = ActionType;
|
||||
|
@ -32,10 +34,11 @@ export const ConnectorFieldsRt = rt.union([
|
|||
|
||||
export enum ConnectorTypes {
|
||||
jira = '.jira',
|
||||
none = '.none',
|
||||
resilient = '.resilient',
|
||||
serviceNowITSM = '.servicenow',
|
||||
serviceNowSIR = '.servicenow-sir',
|
||||
none = '.none',
|
||||
swimlane = '.swimlane',
|
||||
}
|
||||
|
||||
export const connectorTypes = Object.values(ConnectorTypes);
|
||||
|
@ -55,6 +58,11 @@ const ConnectorServiceNowITSMTypeFieldsRt = rt.type({
|
|||
fields: rt.union([ServiceNowITSMFieldsRT, rt.null]),
|
||||
});
|
||||
|
||||
const ConnectorSwimlaneTypeFieldsRt = rt.type({
|
||||
type: rt.literal(ConnectorTypes.swimlane),
|
||||
fields: rt.union([SwimlaneFieldsRT, rt.null]),
|
||||
});
|
||||
|
||||
const ConnectorServiceNowSIRTypeFieldsRt = rt.type({
|
||||
type: rt.literal(ConnectorTypes.serviceNowSIR),
|
||||
fields: rt.union([ServiceNowSIRFieldsRT, rt.null]),
|
||||
|
@ -67,10 +75,11 @@ const ConnectorNoneTypeFieldsRt = rt.type({
|
|||
|
||||
export const ConnectorTypeFieldsRt = rt.union([
|
||||
ConnectorJiraTypeFieldsRt,
|
||||
ConnectorNoneTypeFieldsRt,
|
||||
ConnectorResillientTypeFieldsRt,
|
||||
ConnectorServiceNowITSMTypeFieldsRt,
|
||||
ConnectorServiceNowSIRTypeFieldsRt,
|
||||
ConnectorNoneTypeFieldsRt,
|
||||
ConnectorSwimlaneTypeFieldsRt,
|
||||
]);
|
||||
|
||||
export const CaseConnectorRt = rt.intersection([
|
||||
|
@ -85,6 +94,7 @@ export type CaseConnector = rt.TypeOf<typeof CaseConnectorRt>;
|
|||
export type ConnectorTypeFields = rt.TypeOf<typeof ConnectorTypeFieldsRt>;
|
||||
export type ConnectorJiraTypeFields = rt.TypeOf<typeof ConnectorJiraTypeFieldsRt>;
|
||||
export type ConnectorResillientTypeFields = rt.TypeOf<typeof ConnectorResillientTypeFieldsRt>;
|
||||
export type ConnectorSwimlaneTypeFields = rt.TypeOf<typeof ConnectorSwimlaneTypeFieldsRt>;
|
||||
export type ConnectorServiceNowITSMTypeFields = rt.TypeOf<
|
||||
typeof ConnectorServiceNowITSMTypeFieldsRt
|
||||
>;
|
||||
|
|
|
@ -48,9 +48,6 @@ const ConnectorFieldRt = rt.type({
|
|||
|
||||
export type ConnectorField = rt.TypeOf<typeof ConnectorFieldRt>;
|
||||
|
||||
const GetFieldsResponseRt = rt.type({
|
||||
defaultMappings: rt.array(ConnectorMappingsAttributesRT),
|
||||
fields: rt.array(ConnectorFieldRt),
|
||||
});
|
||||
const GetDefaultMappingsResponseRt = rt.array(ConnectorMappingsAttributesRT);
|
||||
|
||||
export type GetFieldsResponse = rt.TypeOf<typeof GetFieldsResponseRt>;
|
||||
export type GetDefaultMappingsResponse = rt.TypeOf<typeof GetDefaultMappingsResponseRt>;
|
||||
|
|
21
x-pack/plugins/cases/common/api/connectors/swimlane.ts
Normal file
21
x-pack/plugins/cases/common/api/connectors/swimlane.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import * as rt from 'io-ts';
|
||||
|
||||
// New fields should also be added at: x-pack/plugins/cases/server/connectors/case/schema.ts
|
||||
export const SwimlaneFieldsRT = rt.type({
|
||||
caseId: rt.union([rt.string, rt.null]),
|
||||
});
|
||||
|
||||
export enum SwimlaneConnectorType {
|
||||
All = 'all',
|
||||
Alerts = 'alerts',
|
||||
Cases = 'cases',
|
||||
}
|
||||
|
||||
export type SwimlaneFieldsType = rt.TypeOf<typeof SwimlaneFieldsRT>;
|
|
@ -4,6 +4,8 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { ConnectorTypes } from './api';
|
||||
|
||||
export const DEFAULT_DATE_FORMAT = 'dateFormat';
|
||||
export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz';
|
||||
|
||||
|
@ -59,16 +61,12 @@ export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts`;
|
|||
|
||||
export const ACTION_URL = '/api/actions';
|
||||
export const ACTION_TYPES_URL = '/api/actions/list_action_types';
|
||||
export const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow';
|
||||
export const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir';
|
||||
export const JIRA_ACTION_TYPE_ID = '.jira';
|
||||
export const RESILIENT_ACTION_TYPE_ID = '.resilient';
|
||||
|
||||
export const SUPPORTED_CONNECTORS = [
|
||||
SERVICENOW_ITSM_ACTION_TYPE_ID,
|
||||
SERVICENOW_SIR_ACTION_TYPE_ID,
|
||||
JIRA_ACTION_TYPE_ID,
|
||||
RESILIENT_ACTION_TYPE_ID,
|
||||
`${ConnectorTypes.serviceNowITSM}`,
|
||||
`${ConnectorTypes.serviceNowSIR}`,
|
||||
`${ConnectorTypes.jira}`,
|
||||
`${ConnectorTypes.resilient}`,
|
||||
`${ConnectorTypes.swimlane}`,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -24,6 +24,8 @@ export {
|
|||
ValidationError,
|
||||
ValidationFunc,
|
||||
VALIDATION_TYPES,
|
||||
FieldConfig,
|
||||
ValidationConfig,
|
||||
} from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
|
||||
export {
|
||||
Field,
|
||||
|
|
|
@ -608,6 +608,7 @@ describe('CaseView ', () => {
|
|||
).toBe(connectorName);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update connector', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
|
@ -628,15 +629,19 @@ describe('CaseView ', () => {
|
|||
|
||||
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
|
||||
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
|
||||
|
||||
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
|
||||
|
||||
await waitFor(() => wrapper.update());
|
||||
await waitFor(() => {
|
||||
wrapper.update();
|
||||
expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
wrapper.find(`button[data-test-subj="edit-connectors-submit"]`).first().simulate('click');
|
||||
|
||||
await waitFor(() => {
|
||||
const updateObject = updateCaseProperty.mock.calls[0][0];
|
||||
wrapper.update();
|
||||
expect(updateCaseProperty).toHaveBeenCalledTimes(1);
|
||||
const updateObject = updateCaseProperty.mock.calls[0][0];
|
||||
expect(updateObject.updateKey).toEqual('connector');
|
||||
expect(updateObject.updateValue).toEqual({
|
||||
id: 'resilient-2',
|
||||
|
|
|
@ -31,17 +31,14 @@ import { useGetCaseUserActions } from '../../containers/use_get_case_user_action
|
|||
import { usePushToService } from '../use_push_to_service';
|
||||
import { EditConnector } from '../edit_connector';
|
||||
import { useConnectors } from '../../containers/configure/use_connectors';
|
||||
import {
|
||||
getConnectorById,
|
||||
normalizeActionConnector,
|
||||
getNoneConnector,
|
||||
} from '../configure_cases/utils';
|
||||
import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils';
|
||||
import { StatusActionButton } from '../status/button';
|
||||
import * as i18n from './translations';
|
||||
import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context';
|
||||
import { useTimelineContext } from '../timeline_context/use_timeline_context';
|
||||
import { CasesNavigation } from '../links';
|
||||
import { OwnerProvider } from '../owner_context';
|
||||
import { getConnectorById } from '../utils';
|
||||
import { DoesNotExist } from './does_not_exist';
|
||||
|
||||
const gutterTimeline = '70px'; // seems to be a timeline reference from the original file
|
||||
|
|
|
@ -24,15 +24,11 @@ import { ActionConnectorTableItem } from '../../../../triggers_actions_ui/public
|
|||
import { SectionWrapper } from '../wrappers';
|
||||
import { Connectors } from './connectors';
|
||||
import { ClosureOptions } from './closure_options';
|
||||
import {
|
||||
getConnectorById,
|
||||
getNoneConnector,
|
||||
normalizeActionConnector,
|
||||
normalizeCaseConnector,
|
||||
} from './utils';
|
||||
import { getNoneConnector, normalizeActionConnector, normalizeCaseConnector } from './utils';
|
||||
import * as i18n from './translations';
|
||||
import { Owner } from '../../types';
|
||||
import { OwnerProvider } from '../owner_context';
|
||||
import { getConnectorById } from '../utils';
|
||||
|
||||
const FormWrapper = styled.div`
|
||||
${({ theme }) => css`
|
||||
|
|
|
@ -10,10 +10,10 @@ import {
|
|||
CaseField,
|
||||
ActionType,
|
||||
ThirdPartyField,
|
||||
ActionConnector,
|
||||
CaseConnector,
|
||||
CaseConnectorMapping,
|
||||
} from '../../containers/configure/types';
|
||||
import { CaseActionConnector } from '../types';
|
||||
|
||||
export const setActionTypeToMapping = (
|
||||
caseField: CaseField,
|
||||
|
@ -54,13 +54,8 @@ export const getNoneConnector = (): CaseConnector => ({
|
|||
fields: null,
|
||||
});
|
||||
|
||||
export const getConnectorById = (
|
||||
id: string,
|
||||
connectors: ActionConnector[]
|
||||
): ActionConnector | null => connectors.find((c) => c.id === id) ?? null;
|
||||
|
||||
export const normalizeActionConnector = (
|
||||
actionConnector: ActionConnector,
|
||||
actionConnector: CaseActionConnector,
|
||||
fields: CaseConnector['fields'] = null
|
||||
): CaseConnector => {
|
||||
const caseConnectorFieldsType = {
|
||||
|
@ -75,6 +70,6 @@ export const normalizeActionConnector = (
|
|||
};
|
||||
|
||||
export const normalizeCaseConnector = (
|
||||
connectors: ActionConnector[],
|
||||
connectors: CaseActionConnector[],
|
||||
caseConnector: CaseConnector
|
||||
): ActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null;
|
||||
): CaseActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null;
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import { EuiFormRow } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports';
|
||||
import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown';
|
||||
|
@ -24,6 +25,13 @@ interface ConnectorSelectorProps {
|
|||
handleChange?: (newValue: string) => void;
|
||||
hideConnectorServiceNowSir?: boolean;
|
||||
}
|
||||
|
||||
const EuiFormRowWrapper = styled(EuiFormRow)`
|
||||
.euiFormErrorText {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ConnectorSelector = ({
|
||||
connectors,
|
||||
dataTestSubj,
|
||||
|
@ -47,7 +55,7 @@ export const ConnectorSelector = ({
|
|||
);
|
||||
|
||||
return isEdit ? (
|
||||
<EuiFormRow
|
||||
<EuiFormRowWrapper
|
||||
data-test-subj={dataTestSubj}
|
||||
describedByIds={idAria ? [idAria] : undefined}
|
||||
error={errorMessage}
|
||||
|
@ -65,6 +73,6 @@ export const ConnectorSelector = ({
|
|||
onChange={onChange}
|
||||
selectedConnector={isEmpty(field.value) ? 'none' : field.value}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFormRowWrapper>
|
||||
) : null;
|
||||
};
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
import React, { memo, Suspense } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
|
||||
|
||||
import { CaseActionConnector, ConnectorFieldsProps } from './types';
|
||||
import { CaseActionConnector } from '../types';
|
||||
import { ConnectorFieldsProps } from './types';
|
||||
import { getCaseConnectors } from '.';
|
||||
import { ConnectorTypeFields } from '../../../common';
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import { CaseConnectorsRegistry } from './types';
|
||||
import { createCaseConnectorsRegistry } from './connectors_registry';
|
||||
import { getCaseConnector as getJiraCaseConnector } from './jira';
|
||||
import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane';
|
||||
import { getCaseConnector as getResilientCaseConnector } from './resilient';
|
||||
import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow';
|
||||
import {
|
||||
|
@ -15,6 +16,7 @@ import {
|
|||
ServiceNowITSMFieldsType,
|
||||
ServiceNowSIRFieldsType,
|
||||
ResilientFieldsType,
|
||||
SwimlaneFieldsType,
|
||||
} from '../../../common';
|
||||
|
||||
export { getActionType as getCaseConnectorUi } from './case';
|
||||
|
@ -40,6 +42,7 @@ class CaseConnectors {
|
|||
getServiceNowITSMCaseConnector()
|
||||
);
|
||||
this.caseConnectorsRegistry.register<ServiceNowSIRFieldsType>(getServiceNowSIRCaseConnector());
|
||||
this.caseConnectorsRegistry.register<SwimlaneFieldsType>(getSwimlaneCaseConnector());
|
||||
}
|
||||
|
||||
registry(): CaseConnectorsRegistry {
|
||||
|
|
|
@ -8,13 +8,13 @@
|
|||
import { lazy } from 'react';
|
||||
|
||||
import { CaseConnector } from '../types';
|
||||
import { JiraFieldsType } from '../../../../common';
|
||||
import { ConnectorTypes, JiraFieldsType } from '../../../../common';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export * from './types';
|
||||
|
||||
export const getCaseConnector = (): CaseConnector<JiraFieldsType> => ({
|
||||
id: '.jira',
|
||||
id: ConnectorTypes.jira,
|
||||
fieldsComponent: lazy(() => import('./case_fields')),
|
||||
});
|
||||
export const fieldLabels = {
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SwimlaneConnectorType } from '../../../common';
|
||||
|
||||
export const connector = {
|
||||
id: '123',
|
||||
name: 'My connector',
|
||||
|
@ -13,6 +15,22 @@ export const connector = {
|
|||
isPreconfigured: false,
|
||||
};
|
||||
|
||||
export const swimlaneConnector = {
|
||||
id: '123',
|
||||
name: 'My connector',
|
||||
actionTypeId: '.swimlane',
|
||||
config: {
|
||||
connectorType: SwimlaneConnectorType.Cases,
|
||||
mappings: {
|
||||
caseIdConfig: {},
|
||||
caseNameConfig: {},
|
||||
descriptionConfig: {},
|
||||
commentsConfig: {},
|
||||
},
|
||||
},
|
||||
isPreconfigured: false,
|
||||
};
|
||||
|
||||
export const issues = [
|
||||
{ id: 'personId', title: 'Person Task', key: 'personKey' },
|
||||
{ id: 'womanId', title: 'Woman Task', key: 'womanKey' },
|
||||
|
|
|
@ -8,13 +8,13 @@
|
|||
import { lazy } from 'react';
|
||||
|
||||
import { CaseConnector } from '../types';
|
||||
import { ResilientFieldsType } from '../../../../common';
|
||||
import { ConnectorTypes, ResilientFieldsType } from '../../../../common';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export * from './types';
|
||||
|
||||
export const getCaseConnector = (): CaseConnector<ResilientFieldsType> => ({
|
||||
id: '.resilient',
|
||||
id: ConnectorTypes.resilient,
|
||||
fieldsComponent: lazy(() => import('./case_fields')),
|
||||
});
|
||||
|
||||
|
|
|
@ -8,16 +8,20 @@
|
|||
import { lazy } from 'react';
|
||||
|
||||
import { CaseConnector } from '../types';
|
||||
import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType } from '../../../../common';
|
||||
import {
|
||||
ConnectorTypes,
|
||||
ServiceNowITSMFieldsType,
|
||||
ServiceNowSIRFieldsType,
|
||||
} from '../../../../common';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const getServiceNowITSMCaseConnector = (): CaseConnector<ServiceNowITSMFieldsType> => ({
|
||||
id: '.servicenow',
|
||||
id: ConnectorTypes.serviceNowITSM,
|
||||
fieldsComponent: lazy(() => import('./servicenow_itsm_case_fields')),
|
||||
});
|
||||
|
||||
export const getServiceNowSIRCaseConnector = (): CaseConnector<ServiceNowSIRFieldsType> => ({
|
||||
id: '.servicenow-sir',
|
||||
id: ConnectorTypes.serviceNowSIR,
|
||||
fieldsComponent: lazy(() => import('./servicenow_sir_case_fields')),
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
|
||||
import { SwimlaneConnectorType } from '../../../../common';
|
||||
import Fields from './case_fields';
|
||||
import * as i18n from './translations';
|
||||
import { swimlaneConnector as connector } from '../mock';
|
||||
|
||||
const fields = {
|
||||
caseId: '123',
|
||||
};
|
||||
|
||||
const onChange = jest.fn();
|
||||
|
||||
describe('Swimlane Cases Fields', () => {
|
||||
test('it does not shows the mapping error callout', () => {
|
||||
render(<Fields connector={connector} fields={fields} onChange={onChange} />);
|
||||
expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeFalsy();
|
||||
});
|
||||
|
||||
test('it shows the mapping error callout when mapping is invalid', () => {
|
||||
const invalidConnector = {
|
||||
...connector,
|
||||
config: {
|
||||
...connector.config,
|
||||
mappings: {},
|
||||
},
|
||||
};
|
||||
|
||||
render(<Fields connector={invalidConnector} fields={fields} onChange={onChange} />);
|
||||
expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it shows the mapping error callout when the connector is of type alerts', () => {
|
||||
const invalidConnector = {
|
||||
...connector,
|
||||
config: {
|
||||
...connector.config,
|
||||
connectorType: SwimlaneConnectorType.Alerts,
|
||||
},
|
||||
};
|
||||
|
||||
render(<Fields connector={invalidConnector} fields={fields} onChange={onChange} />);
|
||||
expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiCallOut } from '@elastic/eui';
|
||||
import * as i18n from './translations';
|
||||
|
||||
import { ConnectorTypes, SwimlaneFieldsType } from '../../../../common';
|
||||
import { ConnectorFieldsProps } from '../types';
|
||||
import { ConnectorCard } from '../card';
|
||||
import { connectorValidator } from './validator';
|
||||
|
||||
const SwimlaneComponent: React.FunctionComponent<ConnectorFieldsProps<SwimlaneFieldsType>> = ({
|
||||
connector,
|
||||
isEdit = true,
|
||||
}) => {
|
||||
const showMappingWarning = useMemo(() => connectorValidator(connector) != null, [connector]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isEdit && (
|
||||
<ConnectorCard
|
||||
connectorType={ConnectorTypes.swimlane}
|
||||
isLoading={false}
|
||||
listItems={[]}
|
||||
title={connector.name}
|
||||
/>
|
||||
)}
|
||||
{showMappingWarning && (
|
||||
<EuiCallOut
|
||||
title={i18n.EMPTY_MAPPING_WARNING_TITLE}
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
data-test-subj="mapping-warning-callout"
|
||||
>
|
||||
{i18n.EMPTY_MAPPING_WARNING_DESC}
|
||||
</EuiCallOut>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { SwimlaneComponent as default };
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { CaseConnector } from '../types';
|
||||
import { ConnectorTypes, SwimlaneFieldsType } from '../../../../common';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const getCaseConnector = (): CaseConnector<SwimlaneFieldsType> => {
|
||||
return {
|
||||
id: ConnectorTypes.swimlane,
|
||||
fieldsComponent: lazy(() => import('./case_fields')),
|
||||
};
|
||||
};
|
||||
|
||||
export const fieldLabels = {
|
||||
caseId: i18n.CASE_ID_LABEL,
|
||||
caseName: i18n.CASE_NAME_LABEL,
|
||||
severity: i18n.SEVERITY_LABEL,
|
||||
};
|
|
@ -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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const ALERT_SOURCE_LABEL = i18n.translate(
|
||||
'xpack.cases.connectors.swimlane.alertSourceLabel',
|
||||
{
|
||||
defaultMessage: 'Alert Source',
|
||||
}
|
||||
);
|
||||
|
||||
export const CASE_ID_LABEL = i18n.translate('xpack.cases.connectors.swimlane.caseIdLabel', {
|
||||
defaultMessage: 'Case Id',
|
||||
});
|
||||
|
||||
export const CASE_NAME_LABEL = i18n.translate('xpack.cases.connectors.swimlane.caseNameLabel', {
|
||||
defaultMessage: 'Case Name',
|
||||
});
|
||||
|
||||
export const SEVERITY_LABEL = i18n.translate('xpack.cases.connectors.swimlane.severityLabel', {
|
||||
defaultMessage: 'Severity',
|
||||
});
|
||||
|
||||
export const EMPTY_MAPPING_WARNING_TITLE = i18n.translate(
|
||||
'xpack.cases.connectors.swimlane.emptyMappingWarningTitle',
|
||||
{
|
||||
defaultMessage: 'This connector has missing field mappings',
|
||||
}
|
||||
);
|
||||
|
||||
export const EMPTY_MAPPING_WARNING_DESC = i18n.translate(
|
||||
'xpack.cases.connectors.swimlane.emptyMappingWarningDesc',
|
||||
{
|
||||
defaultMessage:
|
||||
'This connector cannot be selected because it is missing the required case field mappings. You can edit this connector to add required field mappings or select a connector of type Cases.',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 { SwimlaneConnectorType } from '../../../../common';
|
||||
import { swimlaneConnector as connector } from '../mock';
|
||||
import { isAnyRequiredFieldNotSet, connectorValidator } from './validator';
|
||||
|
||||
describe('Swimlane validator', () => {
|
||||
describe('isAnyRequiredFieldNotSet', () => {
|
||||
test('it returns true if a required field is not set', () => {
|
||||
expect(isAnyRequiredFieldNotSet({ notRequired: 'test' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it returns false if all required fields are set', () => {
|
||||
expect(isAnyRequiredFieldNotSet(connector.config.mappings)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectorValidator', () => {
|
||||
test('it returns an error message if the mapping is not correct', () => {
|
||||
const invalidConnector = {
|
||||
...connector,
|
||||
config: {
|
||||
...connector.config,
|
||||
mappings: {},
|
||||
},
|
||||
};
|
||||
expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' });
|
||||
});
|
||||
|
||||
test('it returns an error message if the connector is of type alerts', () => {
|
||||
const invalidConnector = {
|
||||
...connector,
|
||||
config: {
|
||||
...connector.config,
|
||||
connectorType: SwimlaneConnectorType.Alerts,
|
||||
},
|
||||
};
|
||||
expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' });
|
||||
});
|
||||
|
||||
test.each([SwimlaneConnectorType.Cases, SwimlaneConnectorType.All])(
|
||||
'it does not return an error message if the connector is of type %s',
|
||||
(connectorType) => {
|
||||
const invalidConnector = {
|
||||
...connector,
|
||||
config: {
|
||||
...connector.config,
|
||||
connectorType,
|
||||
},
|
||||
};
|
||||
expect(connectorValidator(invalidConnector)).toBe(undefined);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SwimlaneConnectorType } from '../../../../common';
|
||||
import { ValidationConfig } from '../../../common/shared_imports';
|
||||
import { CaseActionConnector } from '../../types';
|
||||
|
||||
const casesRequiredFields = [
|
||||
'caseIdConfig',
|
||||
'caseNameConfig',
|
||||
'descriptionConfig',
|
||||
'commentsConfig',
|
||||
];
|
||||
|
||||
export const isAnyRequiredFieldNotSet = (mapping: Record<string, unknown> | undefined) =>
|
||||
casesRequiredFields.some((field) => mapping?.[field] == null);
|
||||
|
||||
/**
|
||||
* The user can use either a connector of type cases or all.
|
||||
* If the connector is of type all we should check if all
|
||||
* required field have been configured.
|
||||
*/
|
||||
|
||||
export const connectorValidator = (
|
||||
connector: CaseActionConnector
|
||||
): ReturnType<ValidationConfig['validator']> => {
|
||||
const {
|
||||
config: { mappings, connectorType },
|
||||
} = connector;
|
||||
if (connectorType === SwimlaneConnectorType.Alerts || isAnyRequiredFieldNotSet(mappings)) {
|
||||
return {
|
||||
message: 'Invalid connector',
|
||||
};
|
||||
}
|
||||
};
|
|
@ -11,12 +11,11 @@ import React from 'react';
|
|||
import {
|
||||
ActionType as ThirdPartySupportedActions,
|
||||
CaseField,
|
||||
ActionConnector,
|
||||
ConnectorTypeFields,
|
||||
} from '../../../common';
|
||||
import { CaseActionConnector } from '../types';
|
||||
|
||||
export { ThirdPartyField as AllThirdPartyFields } from '../../../common';
|
||||
export type CaseActionConnector = ActionConnector;
|
||||
|
||||
export interface ThirdPartyField {
|
||||
label: string;
|
||||
|
|
|
@ -18,6 +18,9 @@ import { useGetSeverity } from '../connectors/resilient/use_get_severity';
|
|||
import { useGetChoices } from '../connectors/servicenow/use_get_choices';
|
||||
import { incidentTypes, severity, choices } from '../connectors/mock';
|
||||
import { schema, FormProps } from './schema';
|
||||
import { TestProviders } from '../../common/mock';
|
||||
import { useCaseConfigure } from '../../containers/configure/use_configure';
|
||||
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
|
||||
|
||||
jest.mock('../../common/lib/kibana', () => ({
|
||||
useKibana: () => ({
|
||||
|
@ -39,10 +42,12 @@ jest.mock('../../common/lib/kibana', () => ({
|
|||
jest.mock('../connectors/resilient/use_get_incident_types');
|
||||
jest.mock('../connectors/resilient/use_get_severity');
|
||||
jest.mock('../connectors/servicenow/use_get_choices');
|
||||
jest.mock('../../containers/configure/use_configure');
|
||||
|
||||
const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock;
|
||||
const useGetSeverityMock = useGetSeverity as jest.Mock;
|
||||
const useGetChoicesMock = useGetChoices as jest.Mock;
|
||||
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
|
||||
|
||||
const useGetIncidentTypesResponse = {
|
||||
isLoading: false,
|
||||
|
@ -87,35 +92,30 @@ describe('Connector', () => {
|
|||
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
|
||||
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
|
||||
useGetChoicesMock.mockReturnValue(useGetChoicesResponse);
|
||||
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
|
||||
});
|
||||
|
||||
it('it renders', async () => {
|
||||
const wrapper = mount(
|
||||
<MockHookWrapperComponent>
|
||||
<Connector {...defaultProps} />
|
||||
</MockHookWrapperComponent>
|
||||
<TestProviders>
|
||||
<MockHookWrapperComponent>
|
||||
<Connector {...defaultProps} />
|
||||
</MockHookWrapperComponent>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy();
|
||||
expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(wrapper.find(`button[data-test-subj="dropdown-connectors"]`).first().text()).toBe(
|
||||
'My Connector'
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
wrapper.update();
|
||||
expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy();
|
||||
});
|
||||
// Selected connector is set to none so no fields should be displayed
|
||||
expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('it is disabled and loading when isLoadingConnectors=true', async () => {
|
||||
const wrapper = mount(
|
||||
<MockHookWrapperComponent>
|
||||
<Connector {...{ ...defaultProps, isLoadingConnectors: true }} />
|
||||
</MockHookWrapperComponent>
|
||||
<TestProviders>
|
||||
<MockHookWrapperComponent>
|
||||
<Connector {...{ ...defaultProps, isLoadingConnectors: true }} />
|
||||
</MockHookWrapperComponent>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(
|
||||
|
@ -129,9 +129,11 @@ describe('Connector', () => {
|
|||
|
||||
it('it is disabled and loading when isLoading=true', async () => {
|
||||
const wrapper = mount(
|
||||
<MockHookWrapperComponent>
|
||||
<Connector {...{ ...defaultProps, isLoading: true }} />
|
||||
</MockHookWrapperComponent>
|
||||
<TestProviders>
|
||||
<MockHookWrapperComponent>
|
||||
<Connector {...{ ...defaultProps, isLoading: true }} />
|
||||
</MockHookWrapperComponent>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(
|
||||
|
@ -144,9 +146,11 @@ describe('Connector', () => {
|
|||
|
||||
it(`it should change connector`, async () => {
|
||||
const wrapper = mount(
|
||||
<MockHookWrapperComponent>
|
||||
<Connector {...defaultProps} />
|
||||
</MockHookWrapperComponent>
|
||||
<TestProviders>
|
||||
<MockHookWrapperComponent>
|
||||
<Connector {...defaultProps} />
|
||||
</MockHookWrapperComponent>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy();
|
||||
|
|
|
@ -5,15 +5,22 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useEffect } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
|
||||
import { ActionConnector, ConnectorTypes } from '../../../common';
|
||||
import { UseField, useFormData, FieldHook, useFormContext } from '../../common/shared_imports';
|
||||
import { ConnectorTypes, ActionConnector } from '../../../common';
|
||||
import {
|
||||
UseField,
|
||||
useFormData,
|
||||
FieldHook,
|
||||
useFormContext,
|
||||
FieldConfig,
|
||||
} from '../../common/shared_imports';
|
||||
import { ConnectorSelector } from '../connector_selector/form';
|
||||
import { ConnectorFieldsForm } from '../connectors/fields_form';
|
||||
import { getConnectorById } from '../configure_cases/utils';
|
||||
import { FormProps } from './schema';
|
||||
import { FormProps, schema } from './schema';
|
||||
import { useCaseConfigure } from '../../containers/configure/use_configure';
|
||||
import { getConnectorById, getConnectorsFormValidators } from '../utils';
|
||||
|
||||
interface Props {
|
||||
connectors: ActionConnector[];
|
||||
|
@ -26,6 +33,7 @@ interface ConnectorsFieldProps {
|
|||
connectors: ActionConnector[];
|
||||
field: FieldHook<FormProps['fields']>;
|
||||
isEdit: boolean;
|
||||
setErrors: (errors: boolean) => void;
|
||||
hideConnectorServiceNowSir?: boolean;
|
||||
}
|
||||
|
||||
|
@ -33,11 +41,13 @@ const ConnectorFields = ({
|
|||
connectors,
|
||||
isEdit,
|
||||
field,
|
||||
setErrors,
|
||||
hideConnectorServiceNowSir = false,
|
||||
}: ConnectorsFieldProps) => {
|
||||
const [{ connectorId }] = useFormData({ watch: ['connectorId'] });
|
||||
const { setValue } = field;
|
||||
let connector = getConnectorById(connectorId, connectors) ?? null;
|
||||
|
||||
if (
|
||||
connector &&
|
||||
hideConnectorServiceNowSir &&
|
||||
|
@ -61,18 +71,49 @@ const ConnectorComponent: React.FC<Props> = ({
|
|||
isLoading,
|
||||
isLoadingConnectors,
|
||||
}) => {
|
||||
const { getFields } = useFormContext();
|
||||
const { getFields, setFieldValue } = useFormContext();
|
||||
const { connector: configurationConnector } = useCaseConfigure();
|
||||
|
||||
const handleConnectorChange = useCallback(() => {
|
||||
const { fields } = getFields();
|
||||
fields.setValue(null);
|
||||
}, [getFields]);
|
||||
|
||||
const defaultConnectorId = useMemo(() => {
|
||||
if (
|
||||
hideConnectorServiceNowSir &&
|
||||
configurationConnector.type === ConnectorTypes.serviceNowSIR
|
||||
) {
|
||||
return 'none';
|
||||
}
|
||||
return connectors.some((connector) => connector.id === configurationConnector.id)
|
||||
? configurationConnector.id
|
||||
: 'none';
|
||||
}, [
|
||||
configurationConnector.id,
|
||||
configurationConnector.type,
|
||||
connectors,
|
||||
hideConnectorServiceNowSir,
|
||||
]);
|
||||
|
||||
useEffect(() => setFieldValue('connectorId', defaultConnectorId), [
|
||||
defaultConnectorId,
|
||||
setFieldValue,
|
||||
]);
|
||||
|
||||
const connectorIdConfig = getConnectorsFormValidators({
|
||||
config: schema.connectorId as FieldConfig,
|
||||
connectors,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<UseField
|
||||
path="connectorId"
|
||||
config={connectorIdConfig}
|
||||
component={ConnectorSelector}
|
||||
defaultValue={defaultConnectorId}
|
||||
componentProps={{
|
||||
connectors,
|
||||
handleChange: handleConnectorChange,
|
||||
|
|
|
@ -17,11 +17,16 @@ import { schema, FormProps } from './schema';
|
|||
import { CreateCaseForm } from './form';
|
||||
import { OwnerProvider } from '../owner_context';
|
||||
import { SECURITY_SOLUTION_OWNER } from '../../../common';
|
||||
import { useCaseConfigure } from '../../containers/configure/use_configure';
|
||||
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
|
||||
|
||||
jest.mock('../../containers/use_get_tags');
|
||||
jest.mock('../../containers/configure/use_connectors');
|
||||
jest.mock('../../containers/configure/use_configure');
|
||||
|
||||
const useGetTagsMock = useGetTags as jest.Mock;
|
||||
const useConnectorsMock = useConnectors as jest.Mock;
|
||||
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
|
||||
|
||||
const initialCaseValue: FormProps = {
|
||||
description: '',
|
||||
|
@ -54,6 +59,7 @@ describe('CreateCaseForm', () => {
|
|||
jest.resetAllMocks();
|
||||
useGetTagsMock.mockReturnValue({ tags: ['test'] });
|
||||
useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock });
|
||||
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
|
||||
});
|
||||
|
||||
it('it renders with steps', async () => {
|
||||
|
|
|
@ -5,23 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { schema, FormProps } from './schema';
|
||||
import { Form, useForm } from '../../common/shared_imports';
|
||||
import {
|
||||
getConnectorById,
|
||||
getNoneConnector,
|
||||
normalizeActionConnector,
|
||||
} from '../configure_cases/utils';
|
||||
import { getNoneConnector, normalizeActionConnector } from '../configure_cases/utils';
|
||||
import { usePostCase } from '../../containers/use_post_case';
|
||||
import { usePostPushToService } from '../../containers/use_post_push_to_service';
|
||||
|
||||
import { useConnectors } from '../../containers/configure/use_connectors';
|
||||
import { useCaseConfigure } from '../../containers/configure/use_configure';
|
||||
import { Case } from '../../containers/types';
|
||||
import { CaseType, ConnectorTypes } from '../../../common';
|
||||
import { CaseType } from '../../../common';
|
||||
import { UsePostComment, usePostComment } from '../../containers/use_post_comment';
|
||||
import { useOwnerContext } from '../owner_context/use_owner_context';
|
||||
import { getConnectorById } from '../utils';
|
||||
|
||||
const initialCaseValue: FormProps = {
|
||||
description: '',
|
||||
|
@ -49,28 +45,10 @@ export const FormContext: React.FC<Props> = ({
|
|||
}) => {
|
||||
const { connectors, loading: isLoadingConnectors } = useConnectors();
|
||||
const owner = useOwnerContext();
|
||||
const { connector: configurationConnector } = useCaseConfigure();
|
||||
const { postCase } = usePostCase();
|
||||
const { postComment } = usePostComment();
|
||||
const { pushCaseToExternalService } = usePostPushToService();
|
||||
|
||||
const connectorId = useMemo(() => {
|
||||
if (
|
||||
hideConnectorServiceNowSir &&
|
||||
configurationConnector.type === ConnectorTypes.serviceNowSIR
|
||||
) {
|
||||
return 'none';
|
||||
}
|
||||
return connectors.some((connector) => connector.id === configurationConnector.id)
|
||||
? configurationConnector.id
|
||||
: 'none';
|
||||
}, [
|
||||
configurationConnector.id,
|
||||
configurationConnector.type,
|
||||
connectors,
|
||||
hideConnectorServiceNowSir,
|
||||
]);
|
||||
|
||||
const submitCase = useCallback(
|
||||
async (
|
||||
{ connectorId: dataConnectorId, fields, syncAlerts = true, ...dataWithoutConnectorId },
|
||||
|
@ -125,9 +103,6 @@ export const FormContext: React.FC<Props> = ({
|
|||
schema,
|
||||
onSubmit: submitCase,
|
||||
});
|
||||
const { setFieldValue } = form;
|
||||
// Set the selected connector to the configuration connector
|
||||
useEffect(() => setFieldValue('connectorId', connectorId), [connectorId, setFieldValue]);
|
||||
|
||||
const childrenWithExtraProp = useMemo(
|
||||
() =>
|
||||
|
|
|
@ -49,7 +49,9 @@ export const schema: FormSchema<FormProps> = {
|
|||
label: i18n.CONNECTORS,
|
||||
defaultValue: 'none',
|
||||
},
|
||||
fields: {},
|
||||
fields: {
|
||||
defaultValue: null,
|
||||
},
|
||||
syncAlerts: {
|
||||
helpText: i18n.SYNC_ALERTS_HELP,
|
||||
type: FIELD_TYPES.TOGGLE,
|
||||
|
|
|
@ -20,15 +20,15 @@ import {
|
|||
import styled from 'styled-components';
|
||||
import { noop } from 'lodash/fp';
|
||||
|
||||
import { Form, UseField, useForm } from '../../common/shared_imports';
|
||||
import { FieldConfig, Form, UseField, useForm } from '../../common/shared_imports';
|
||||
import { ActionConnector, ConnectorTypeFields } from '../../../common';
|
||||
import { ConnectorSelector } from '../connector_selector/form';
|
||||
import { ConnectorFieldsForm } from '../connectors/fields_form';
|
||||
import { getConnectorById } from '../configure_cases/utils';
|
||||
import { CaseUserActions } from '../../containers/types';
|
||||
import { schema } from './schema';
|
||||
import { getConnectorFieldsFromUserActions } from './helpers';
|
||||
import * as i18n from './translations';
|
||||
import { getConnectorById, getConnectorsFormValidators } from '../utils';
|
||||
|
||||
export interface EditConnectorProps {
|
||||
caseFields: ConnectorTypeFields['fields'];
|
||||
|
@ -205,6 +205,11 @@ export const EditConnector = React.memo(
|
|||
});
|
||||
}, [dispatch]);
|
||||
|
||||
const connectorIdConfig = getConnectorsFormValidators({
|
||||
config: schema.connectorId as FieldConfig,
|
||||
connectors,
|
||||
});
|
||||
|
||||
/**
|
||||
* if this evaluates to true it means that the connector was likely deleted because the case connector was set to something
|
||||
* other than none but we don't find it in the list of connectors returned from the actions plugin
|
||||
|
@ -243,6 +248,7 @@ export const EditConnector = React.memo(
|
|||
<EuiFlexItem>
|
||||
<UseField
|
||||
path="connectorId"
|
||||
config={connectorIdConfig}
|
||||
component={ConnectorSelector}
|
||||
componentProps={{
|
||||
connectors,
|
||||
|
|
10
x-pack/plugins/cases/public/components/types.ts
Normal file
10
x-pack/plugins/cases/public/components/types.ts
Normal file
|
@ -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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ActionConnector } from '../../common';
|
||||
|
||||
export type CaseActionConnector = ActionConnector;
|
43
x-pack/plugins/cases/public/components/utils.ts
Normal file
43
x-pack/plugins/cases/public/components/utils.ts
Normal file
|
@ -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 { ConnectorTypes } from '../../common';
|
||||
import { FieldConfig, ValidationConfig } from '../common/shared_imports';
|
||||
import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator';
|
||||
import { CaseActionConnector } from './types';
|
||||
|
||||
export const getConnectorById = (
|
||||
id: string,
|
||||
connectors: CaseActionConnector[]
|
||||
): CaseActionConnector | null => connectors.find((c) => c.id === id) ?? null;
|
||||
|
||||
const validators: Record<
|
||||
string,
|
||||
(connector: CaseActionConnector) => ReturnType<ValidationConfig['validator']>
|
||||
> = {
|
||||
[ConnectorTypes.swimlane]: swimlaneConnectorValidator,
|
||||
};
|
||||
|
||||
export const getConnectorsFormValidators = ({
|
||||
connectors = [],
|
||||
config = {},
|
||||
}: {
|
||||
connectors: CaseActionConnector[];
|
||||
config: FieldConfig;
|
||||
}): FieldConfig => ({
|
||||
...config,
|
||||
validations: [
|
||||
{
|
||||
validator: ({ value: connectorId }) => {
|
||||
const connector = getConnectorById(connectorId as string, connectors);
|
||||
if (connector != null) {
|
||||
return validators[connector.actionTypeId]?.(connector);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
|
@ -11,6 +11,7 @@ import { useToasts } from '../common/lib/kibana';
|
|||
import { getActionLicense } from './api';
|
||||
import * as i18n from './translations';
|
||||
import { ActionLicense } from './types';
|
||||
import { ConnectorTypes } from '../../common';
|
||||
|
||||
export interface ActionLicenseState {
|
||||
actionLicense: ActionLicense | null;
|
||||
|
@ -24,7 +25,7 @@ export const initialData: ActionLicenseState = {
|
|||
isError: false,
|
||||
};
|
||||
|
||||
const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira';
|
||||
const MINIMUM_LICENSE_REQUIRED_CONNECTOR = ConnectorTypes.jira;
|
||||
|
||||
export const useGetActionLicense = (): ActionLicenseState => {
|
||||
const [actionLicenseState, setActionLicensesState] = useState<ActionLicenseState>(initialData);
|
||||
|
|
|
@ -173,7 +173,6 @@ export const get = async (
|
|||
|
||||
let theCase: SavedObject<ESCaseAttributes>;
|
||||
let subCaseIds: string[] = [];
|
||||
|
||||
if (ENABLE_CASE_CONNECTOR) {
|
||||
const [caseInfo, subCasesForCaseId] = await Promise.all([
|
||||
caseService.getCase({
|
||||
|
|
|
@ -252,6 +252,7 @@ export const prepareFieldsForTransformation = ({
|
|||
mappings.reduce(
|
||||
(acc: PipedField[], mapping) =>
|
||||
mapping != null &&
|
||||
mapping.target != null &&
|
||||
mapping.target !== 'not_mapped' &&
|
||||
mapping.action_type !== 'nothing' &&
|
||||
mapping.source !== 'comments'
|
||||
|
|
|
@ -60,7 +60,7 @@ describe('case connector', () => {
|
|||
connector: {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
type: '.jira',
|
||||
type: ConnectorTypes.jira,
|
||||
fields: {
|
||||
issueType: '10006',
|
||||
priority: 'High',
|
||||
|
@ -99,7 +99,7 @@ describe('case connector', () => {
|
|||
connector: {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
type: '.jira',
|
||||
type: ConnectorTypes.jira,
|
||||
fields: {
|
||||
issueType: '10006',
|
||||
priority: 'High',
|
||||
|
@ -293,7 +293,7 @@ describe('case connector', () => {
|
|||
connector: {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
type: '.jira',
|
||||
type: ConnectorTypes.jira,
|
||||
fields: {
|
||||
priority: 'High',
|
||||
parent: null,
|
||||
|
@ -438,7 +438,7 @@ describe('case connector', () => {
|
|||
connector: {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
type: '.jira',
|
||||
type: ConnectorTypes.jira,
|
||||
fields: {
|
||||
issueType: '10006',
|
||||
priority: 'High',
|
||||
|
@ -640,7 +640,7 @@ describe('case connector', () => {
|
|||
connector: {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
type: '.jira',
|
||||
type: ConnectorTypes.jira,
|
||||
fields: {
|
||||
priority: 'High',
|
||||
parent: null,
|
||||
|
@ -974,7 +974,7 @@ describe('case connector', () => {
|
|||
connector: {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
type: '.jira',
|
||||
type: ConnectorTypes.jira,
|
||||
fields: {
|
||||
issueType: '10006',
|
||||
priority: 'High',
|
||||
|
@ -1003,7 +1003,7 @@ describe('case connector', () => {
|
|||
connector: {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
type: '.jira',
|
||||
type: ConnectorTypes.jira,
|
||||
fields: {
|
||||
issueType: '10006',
|
||||
priority: 'High',
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { CommentType } from '../../../common';
|
||||
import { CommentType, ConnectorTypes } from '../../../common';
|
||||
import { validateConnector } from './validators';
|
||||
|
||||
// Reserved for future implementation
|
||||
|
@ -77,23 +77,29 @@ const ServiceNowSIRFieldsSchema = schema.object({
|
|||
subcategory: schema.nullable(schema.string()),
|
||||
});
|
||||
|
||||
const SwimlaneFieldsSchema = schema.object({
|
||||
caseId: schema.nullable(schema.string()),
|
||||
});
|
||||
|
||||
const NoneFieldsSchema = schema.nullable(schema.object({}));
|
||||
|
||||
const ReducedConnectorFieldsSchema: { [x: string]: any } = {
|
||||
'.jira': JiraFieldsSchema,
|
||||
'.resilient': ResilientFieldsSchema,
|
||||
'.servicenow-sir': ServiceNowSIRFieldsSchema,
|
||||
[ConnectorTypes.jira]: JiraFieldsSchema,
|
||||
[ConnectorTypes.resilient]: ResilientFieldsSchema,
|
||||
[ConnectorTypes.serviceNowSIR]: ServiceNowSIRFieldsSchema,
|
||||
[ConnectorTypes.swimlane]: SwimlaneFieldsSchema,
|
||||
};
|
||||
|
||||
export const ConnectorProps = {
|
||||
id: schema.string(),
|
||||
name: schema.string(),
|
||||
type: schema.oneOf([
|
||||
schema.literal('.servicenow'),
|
||||
schema.literal('.jira'),
|
||||
schema.literal('.resilient'),
|
||||
schema.literal('.servicenow-sir'),
|
||||
schema.literal('.none'),
|
||||
schema.literal(ConnectorTypes.jira),
|
||||
schema.literal(ConnectorTypes.none),
|
||||
schema.literal(ConnectorTypes.resilient),
|
||||
schema.literal(ConnectorTypes.serviceNowITSM),
|
||||
schema.literal(ConnectorTypes.serviceNowSIR),
|
||||
schema.literal(ConnectorTypes.swimlane),
|
||||
]),
|
||||
// Chain of conditional schemes
|
||||
fields: Object.keys(ReducedConnectorFieldsSchema).reduce(
|
||||
|
@ -106,7 +112,7 @@ export const ConnectorProps = {
|
|||
),
|
||||
schema.conditional(
|
||||
schema.siblingRef('type'),
|
||||
'.servicenow',
|
||||
ConnectorTypes.serviceNowITSM,
|
||||
ServiceNowITSMFieldsSchema,
|
||||
NoneFieldsSchema
|
||||
)
|
||||
|
|
|
@ -6,9 +6,10 @@
|
|||
*/
|
||||
|
||||
import { Connector } from './types';
|
||||
import { ConnectorTypes } from '../../../common';
|
||||
|
||||
export const validateConnector = (connector: Connector) => {
|
||||
if (connector.type === '.none' && connector.fields !== null) {
|
||||
if (connector.type === ConnectorTypes.none && connector.fields !== null) {
|
||||
return 'Fields must be set to null for connectors of type .none';
|
||||
}
|
||||
};
|
||||
|
|
|
@ -6,16 +6,18 @@
|
|||
*/
|
||||
|
||||
import { ConnectorTypes } from '../../common';
|
||||
import { ICasesConnector, CasesConnectorsMap } from './types';
|
||||
import { getCaseConnector as getJiraCaseConnector } from './jira';
|
||||
import { getCaseConnector as getResilientCaseConnector } from './resilient';
|
||||
import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow';
|
||||
import { ICasesConnector, CasesConnectorsMap } from './types';
|
||||
import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane';
|
||||
|
||||
const mapping: Record<ConnectorTypes, ICasesConnector | null> = {
|
||||
[ConnectorTypes.jira]: getJiraCaseConnector(),
|
||||
[ConnectorTypes.serviceNowITSM]: getServiceNowITSMCaseConnector(),
|
||||
[ConnectorTypes.serviceNowSIR]: getServiceNowSIRCaseConnector(),
|
||||
[ConnectorTypes.resilient]: getResilientCaseConnector(),
|
||||
[ConnectorTypes.swimlane]: getSwimlaneCaseConnector(),
|
||||
[ConnectorTypes.none]: null,
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { CaseResponse } from '../../../common';
|
||||
import { format } from './format';
|
||||
|
||||
describe('Swimlane formatter', () => {
|
||||
const theCase = {
|
||||
id: 'case-id',
|
||||
connector: { fields: null },
|
||||
} as CaseResponse;
|
||||
|
||||
it('it formats correctly', async () => {
|
||||
const res = await format(theCase, []);
|
||||
expect(res).toEqual({ caseId: theCase.id });
|
||||
});
|
||||
});
|
15
x-pack/plugins/cases/server/connectors/swimlane/format.ts
Normal file
15
x-pack/plugins/cases/server/connectors/swimlane/format.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { ConnectorSwimlaneTypeFields } from '../../../common';
|
||||
import { Format } from './types';
|
||||
|
||||
export const format: Format = (theCase) => {
|
||||
const { caseId = theCase.id } =
|
||||
(theCase.connector.fields as ConnectorSwimlaneTypeFields['fields']) ?? {};
|
||||
return { caseId };
|
||||
};
|
15
x-pack/plugins/cases/server/connectors/swimlane/index.ts
Normal file
15
x-pack/plugins/cases/server/connectors/swimlane/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { getMapping } from './mapping';
|
||||
import { format } from './format';
|
||||
import { SwimlaneCaseConnector } from './types';
|
||||
|
||||
export const getCaseConnector = (): SwimlaneCaseConnector => ({
|
||||
getMapping,
|
||||
format,
|
||||
});
|
28
x-pack/plugins/cases/server/connectors/swimlane/mapping.ts
Normal file
28
x-pack/plugins/cases/server/connectors/swimlane/mapping.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { GetMapping } from './types';
|
||||
|
||||
export const getMapping: GetMapping = () => {
|
||||
return [
|
||||
{
|
||||
source: 'title',
|
||||
target: 'caseName',
|
||||
action_type: 'overwrite',
|
||||
},
|
||||
{
|
||||
source: 'description',
|
||||
target: 'description',
|
||||
action_type: 'overwrite',
|
||||
},
|
||||
{
|
||||
source: 'comments',
|
||||
target: 'comments',
|
||||
action_type: 'append',
|
||||
},
|
||||
];
|
||||
};
|
13
x-pack/plugins/cases/server/connectors/swimlane/types.ts
Normal file
13
x-pack/plugins/cases/server/connectors/swimlane/types.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SwimlaneFieldsType } from '../../../common/api';
|
||||
import { ICasesConnector } from '../types';
|
||||
|
||||
export type SwimlaneCaseConnector = ICasesConnector<SwimlaneFieldsType>;
|
||||
export type Format = ICasesConnector<SwimlaneFieldsType>['format'];
|
||||
export type GetMapping = ICasesConnector<SwimlaneFieldsType>['getMapping'];
|
|
@ -240,6 +240,7 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
|
|||
'.email',
|
||||
'.slack',
|
||||
'.pagerduty',
|
||||
'.swimlane',
|
||||
'.webhook',
|
||||
'.servicenow',
|
||||
'.jira',
|
||||
|
|
|
@ -31,6 +31,9 @@
|
|||
"__index": {
|
||||
"type": "long"
|
||||
},
|
||||
"__swimlane": {
|
||||
"type": "long"
|
||||
},
|
||||
"__pagerduty": {
|
||||
"type": "long"
|
||||
},
|
||||
|
@ -68,6 +71,9 @@
|
|||
"__index": {
|
||||
"type": "long"
|
||||
},
|
||||
"__swimlane": {
|
||||
"type": "long"
|
||||
},
|
||||
"__pagerduty": {
|
||||
"type": "long"
|
||||
},
|
||||
|
|
|
@ -10,6 +10,7 @@ import { getSlackActionType } from './slack';
|
|||
import { getEmailActionType } from './email';
|
||||
import { getIndexActionType } from './es_index';
|
||||
import { getPagerDutyActionType } from './pagerduty';
|
||||
import { getSwimlaneActionType } from './swimlane';
|
||||
import { getWebhookActionType } from './webhook';
|
||||
import { TypeRegistry } from '../../type_registry';
|
||||
import { ActionTypeModel } from '../../../types';
|
||||
|
@ -28,6 +29,7 @@ export function registerBuiltInActionTypes({
|
|||
actionTypeRegistry.register(getEmailActionType());
|
||||
actionTypeRegistry.register(getIndexActionType());
|
||||
actionTypeRegistry.register(getPagerDutyActionType());
|
||||
actionTypeRegistry.register(getSwimlaneActionType());
|
||||
actionTypeRegistry.register(getWebhookActionType());
|
||||
actionTypeRegistry.register(getServiceNowITSMActionType());
|
||||
actionTypeRegistry.register(getServiceNowSIRActionType());
|
||||
|
|
|
@ -12,7 +12,7 @@ import { JiraActionConnector } from './types';
|
|||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
describe('JiraActionConnectorFields renders', () => {
|
||||
test('alerting Jira connector fields is rendered', () => {
|
||||
test('alerting Jira connector fields are rendered', () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
email: 'email',
|
||||
|
|
|
@ -63,6 +63,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
|
|||
actionConnector,
|
||||
issueType: incident.issueType ?? '',
|
||||
});
|
||||
|
||||
const editSubActionProperty = useCallback(
|
||||
(key: string, value: any) => {
|
||||
if (key === 'issueType') {
|
||||
|
@ -75,9 +76,11 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
|
|||
index
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'comments') {
|
||||
return editAction('subActionParams', { incident, comments: value }, index);
|
||||
}
|
||||
|
||||
return editAction(
|
||||
'subActionParams',
|
||||
{
|
||||
|
@ -124,6 +127,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
|
|||
text: type.name ?? '',
|
||||
}));
|
||||
}, [editSubActionProperty, incident, issueTypes]);
|
||||
|
||||
const prioritiesSelectOptions: EuiSelectOption[] = useMemo(() => {
|
||||
if (incident.issueType != null && fields != null) {
|
||||
const priorities = fields.priority != null ? fields.priority.allowedValues : [];
|
||||
|
@ -141,6 +145,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
|
|||
}
|
||||
return [];
|
||||
}, [editSubActionProperty, fields, incident.issueType, incident.priority]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPriority && incident.priority != null) {
|
||||
editSubActionProperty('priority', null);
|
||||
|
@ -167,6 +172,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
|
|||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionConnector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionParams.subAction) {
|
||||
editAction('subAction', 'pushToService', index);
|
||||
|
|
|
@ -12,7 +12,7 @@ import { ResilientActionConnector } from './types';
|
|||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
describe('ResilientActionConnectorFields renders', () => {
|
||||
test('alerting Resilient connector fields is rendered', () => {
|
||||
test('alerting Resilient connector fields are rendered', () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiKeyId: 'key',
|
||||
|
|
|
@ -147,6 +147,7 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
|
|||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionConnector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionParams.subAction) {
|
||||
editAction('subAction', 'pushToService', index);
|
||||
|
|
|
@ -12,7 +12,7 @@ import { ServiceNowActionConnector } from './types';
|
|||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
describe('ServiceNowActionConnectorFields renders', () => {
|
||||
test('alerting servicenow connector fields is rendered', () => {
|
||||
test('alerting servicenow connector fields are rendered', () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
username: 'user',
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getApplication } from './api';
|
||||
|
||||
const getApplicationResponse = {
|
||||
fields: [],
|
||||
};
|
||||
|
||||
describe('Swimlane API', () => {
|
||||
let fetchMock: jest.SpyInstance<Promise<unknown>>;
|
||||
|
||||
beforeAll(() => jest.spyOn(window, 'fetch'));
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
fetchMock = jest.spyOn(window, 'fetch');
|
||||
});
|
||||
|
||||
describe('getApplication', () => {
|
||||
it('should call getApplication API correctly', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => getApplicationResponse,
|
||||
});
|
||||
const res = await getApplication({
|
||||
signal: abortCtrl.signal,
|
||||
apiToken: '',
|
||||
appId: '',
|
||||
url: '',
|
||||
});
|
||||
|
||||
expect(res).toEqual(getApplicationResponse);
|
||||
});
|
||||
|
||||
it('returns an error when the response fails', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => getApplicationResponse,
|
||||
});
|
||||
|
||||
try {
|
||||
await getApplication({
|
||||
signal: abortCtrl.signal,
|
||||
apiToken: '',
|
||||
appId: '',
|
||||
url: '',
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e.message).toContain('Received status:');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an error when parsing the json fails', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error('bad');
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await getApplication({
|
||||
signal: abortCtrl.signal,
|
||||
apiToken: '',
|
||||
appId: '',
|
||||
url: '',
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e.message).toContain('bad');
|
||||
}
|
||||
});
|
||||
|
||||
it('it removes unsafe fields', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
fields: [
|
||||
{
|
||||
id: '__proto__',
|
||||
name: 'Alert Id',
|
||||
key: 'alert-id',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'a6ide',
|
||||
name: '__proto__',
|
||||
key: 'alert-id',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'a6ide',
|
||||
name: 'Alert Id',
|
||||
key: '__proto__',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'a6ide',
|
||||
name: 'Alert Id',
|
||||
key: 'alert-id',
|
||||
fieldType: '__proto__',
|
||||
},
|
||||
{
|
||||
id: 'safe-id',
|
||||
name: 'Safe',
|
||||
key: 'safe-key',
|
||||
fieldType: 'safe-text',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await getApplication({
|
||||
signal: abortCtrl.signal,
|
||||
apiToken: '',
|
||||
appId: '',
|
||||
url: '',
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
fields: [
|
||||
{
|
||||
id: 'safe-id',
|
||||
name: 'Safe',
|
||||
key: 'safe-key',
|
||||
fieldType: 'safe-text',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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 { SwimlaneFieldMappingConfig } from './types';
|
||||
|
||||
const removeUnsafeFields = (fields: SwimlaneFieldMappingConfig[]): SwimlaneFieldMappingConfig[] =>
|
||||
fields.filter(
|
||||
(filter) =>
|
||||
filter.id !== '__proto__' &&
|
||||
filter.key !== '__proto__' &&
|
||||
filter.name !== '__proto__' &&
|
||||
filter.fieldType !== '__proto__'
|
||||
);
|
||||
export async function getApplication({
|
||||
signal,
|
||||
url,
|
||||
appId,
|
||||
apiToken,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
url: string;
|
||||
appId: string;
|
||||
apiToken: string;
|
||||
}): Promise<Record<string, any>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Private-Token': `${apiToken}`,
|
||||
};
|
||||
|
||||
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
const apiUrl = urlWithoutTrailingSlash.endsWith('api')
|
||||
? urlWithoutTrailingSlash
|
||||
: urlWithoutTrailingSlash + '/api';
|
||||
const applicationUrl = `${apiUrl}/app/{appId}`;
|
||||
|
||||
const getApplicationUrl = (id: string) => applicationUrl.replace('{appId}', id);
|
||||
|
||||
try {
|
||||
const response = await fetch(getApplicationUrl(appId), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch do not throw when there is an HTTP error (status >= 400).
|
||||
* We need to do it manually.
|
||||
*/
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Received status: ${response.status} when attempting to get application with id: ${appId}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { ...data, fields: removeUnsafeFields(data?.fields ?? []) };
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to get application with id ${appId}. Error: ${error.message}`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SwimlaneConnectorType, SwimlaneMappingConfig, MappingConfigurationKeys } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const casesRequiredFields: MappingConfigurationKeys[] = [
|
||||
'caseNameConfig',
|
||||
'descriptionConfig',
|
||||
'commentsConfig',
|
||||
'caseIdConfig',
|
||||
];
|
||||
const casesFields = [...casesRequiredFields];
|
||||
const alertsRequiredFields: MappingConfigurationKeys[] = ['ruleNameConfig', 'alertIdConfig'];
|
||||
const alertsFields = ['severityConfig', 'commentsConfig', ...alertsRequiredFields];
|
||||
|
||||
const translationMapping: Record<string, string> = {
|
||||
caseIdConfig: i18n.SW_REQUIRED_CASE_ID,
|
||||
alertIdConfig: i18n.SW_REQUIRED_ALERT_ID,
|
||||
caseNameConfig: i18n.SW_REQUIRED_CASE_NAME,
|
||||
descriptionConfig: i18n.SW_REQUIRED_DESCRIPTION,
|
||||
commentsConfig: i18n.SW_REQUIRED_COMMENTS,
|
||||
ruleNameConfig: i18n.SW_REQUIRED_RULE_NAME,
|
||||
severityConfig: i18n.SW_REQUIRED_SEVERITY,
|
||||
};
|
||||
|
||||
export const isValidFieldForConnector = (
|
||||
connector: SwimlaneConnectorType,
|
||||
field: MappingConfigurationKeys
|
||||
): boolean => {
|
||||
if (connector === SwimlaneConnectorType.All) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return connector === SwimlaneConnectorType.Alerts
|
||||
? alertsFields.includes(field)
|
||||
: casesFields.includes(field);
|
||||
};
|
||||
|
||||
export const validateMappingForConnector = (
|
||||
connectorType: SwimlaneConnectorType,
|
||||
mapping: SwimlaneMappingConfig
|
||||
): Record<string, string> => {
|
||||
if (connectorType === SwimlaneConnectorType.All || connectorType == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const requiredFields =
|
||||
connectorType === SwimlaneConnectorType.Alerts ? alertsRequiredFields : casesRequiredFields;
|
||||
|
||||
return requiredFields.reduce((errors, field) => {
|
||||
if (mapping?.[field] == null) {
|
||||
errors = { ...errors, [field]: translationMapping[field] };
|
||||
}
|
||||
|
||||
return errors;
|
||||
}, {} as Record<string, string>);
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { getActionType as getSwimlaneActionType } from './swimlane';
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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';
|
||||
|
||||
const Logo = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
stroke="null"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
>
|
||||
<g fillRule="evenodd" stroke="null" clipRule="evenodd">
|
||||
<path
|
||||
fill="#19CCC0"
|
||||
d="M21.536 9.338a1.053 1.053 0 00-1.29 0l-4.564 3.566a.988.988 0 000 1.566l4.725 3.691c.02.016.05.016.07 0l6.12-4.782a.054.054 0 000-.086l-5.061-3.955z"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
fill="#00FFF4"
|
||||
d="M15.684 31.61l10.728-8.382a.627.627 0 00.244-.494v-9.401l-11.787 9.21a.627.627 0 00-.244.494v8.08c0 .531.633.827 1.059.494z"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
fill="#27AFA2"
|
||||
d="M26.655 13.331L20.44 18.19l-1.703-1.331 7.917-3.527z"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
fill="#028ACF"
|
||||
d="M10.464 22.663c.377.294.914.294 1.291 0l4.563-3.566a.987.987 0 000-1.565l-4.724-3.692a.058.058 0 00-.071 0l-6.12 4.782a.054.054 0 000 .086l5.061 3.955z"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
fill="#02AAFF"
|
||||
d="M16.316.39L5.588 8.771a.628.628 0 00-.244.494v9.401l11.787-9.21a.628.628 0 00.244-.494V.883c0-.531-.633-.827-1.059-.494z"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path fill="#0578A5" d="M5.344 18.67l6.214-4.858 1.704 1.331-7.918 3.527z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { Logo as default };
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export const applicationFields = [
|
||||
{
|
||||
id: 'a6ide',
|
||||
name: 'Alert Id',
|
||||
key: 'alert-id',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'adnlas',
|
||||
name: 'Severity',
|
||||
key: 'severity',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'adnfls',
|
||||
name: 'Rule Name',
|
||||
key: 'rule-name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'a6sst',
|
||||
name: 'Case Id',
|
||||
key: 'case-id-name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'a6fst',
|
||||
name: 'Case Name',
|
||||
key: 'case-name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'a6fdf',
|
||||
name: 'Comments',
|
||||
key: 'notes',
|
||||
fieldType: 'comments',
|
||||
},
|
||||
{
|
||||
id: 'a6fde',
|
||||
name: 'Description',
|
||||
key: 'description',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
export const mappings = {
|
||||
alertIdConfig: applicationFields[0],
|
||||
severityConfig: applicationFields[1],
|
||||
ruleNameConfig: applicationFields[2],
|
||||
caseIdConfig: applicationFields[3],
|
||||
caseNameConfig: applicationFields[4],
|
||||
commentsConfig: applicationFields[5],
|
||||
descriptionConfig: applicationFields[6],
|
||||
};
|
|
@ -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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { SwimlaneConnection } from './swimlane_connection';
|
||||
export { SwimlaneFields } from './swimlane_fields';
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiButton,
|
||||
EuiCallOut,
|
||||
EuiFieldText,
|
||||
EuiFormRow,
|
||||
EuiLink,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import React, { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import * as i18n from '../translations';
|
||||
import { useKibana } from '../../../../../common/lib/kibana';
|
||||
import { useGetApplication } from '../use_get_application';
|
||||
import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from '../types';
|
||||
import { IErrorObject } from '../../../../../types';
|
||||
|
||||
interface Props {
|
||||
action: SwimlaneActionConnector;
|
||||
editActionConfig: (property: string, value: any) => void;
|
||||
editActionSecrets: (property: string, value: any) => void;
|
||||
errors: IErrorObject;
|
||||
readOnly: boolean;
|
||||
updateCurrentStep: (step: number) => void;
|
||||
updateFields: (items: SwimlaneFieldMappingConfig[]) => void;
|
||||
}
|
||||
|
||||
const SwimlaneConnectionComponent: React.FunctionComponent<Props> = ({
|
||||
action,
|
||||
editActionConfig,
|
||||
editActionSecrets,
|
||||
errors,
|
||||
readOnly,
|
||||
updateCurrentStep,
|
||||
updateFields,
|
||||
}) => {
|
||||
const {
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
const { apiUrl, appId } = action.config;
|
||||
const { apiToken } = action.secrets;
|
||||
const { docLinks } = useKibana().services;
|
||||
const { getApplication } = useGetApplication({
|
||||
toastNotifications: toasts,
|
||||
apiToken,
|
||||
appId,
|
||||
apiUrl,
|
||||
});
|
||||
const isValid = apiUrl && apiToken && appId;
|
||||
|
||||
const connectSwimlane = useCallback(async () => {
|
||||
// fetch swimlane application configuration
|
||||
const application = await getApplication();
|
||||
|
||||
if (application?.fields) {
|
||||
const allFields = application.fields;
|
||||
updateFields(allFields);
|
||||
updateCurrentStep(2);
|
||||
}
|
||||
}, [getApplication, updateCurrentStep, updateFields]);
|
||||
|
||||
const onChangeConfig = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>, key: 'apiUrl' | 'appId') => {
|
||||
editActionConfig(key, e.target.value);
|
||||
},
|
||||
[editActionConfig]
|
||||
);
|
||||
|
||||
const onBlurConfig = useCallback(
|
||||
(key: 'apiUrl' | 'appId') => {
|
||||
if (!action.config[key]) {
|
||||
editActionConfig(key, '');
|
||||
}
|
||||
},
|
||||
[action.config, editActionConfig]
|
||||
);
|
||||
|
||||
const onChangeSecrets = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
editActionSecrets('apiToken', e.target.value);
|
||||
},
|
||||
[editActionSecrets]
|
||||
);
|
||||
|
||||
const onBlurSecrets = useCallback(() => {
|
||||
if (!apiToken) {
|
||||
editActionSecrets('apiToken', '');
|
||||
}
|
||||
}, [apiToken, editActionSecrets]);
|
||||
|
||||
const isApiUrlInvalid = errors.apiUrl?.length > 0 && apiToken !== undefined;
|
||||
const isAppIdInvalid = errors.appId?.length > 0 && apiToken !== undefined;
|
||||
const isApiTokenInvalid = errors.apiToken?.length > 0 && apiToken !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
id="apiUrl"
|
||||
fullWidth
|
||||
label={i18n.SW_API_URL_TEXT_FIELD_LABEL}
|
||||
error={errors.apiUrl}
|
||||
isInvalid={isApiUrlInvalid}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
name="apiUrl"
|
||||
value={apiUrl ?? ''}
|
||||
readOnly={readOnly}
|
||||
isInvalid={isApiUrlInvalid}
|
||||
data-test-subj="swimlaneApiUrlInput"
|
||||
onChange={(e) => onChangeConfig(e, 'apiUrl')}
|
||||
onBlur={() => onBlurConfig('apiUrl')}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
id="appId"
|
||||
fullWidth
|
||||
label={i18n.SW_APP_ID_TEXT_FIELD_LABEL}
|
||||
error={errors.appId}
|
||||
isInvalid={isAppIdInvalid}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
name="appId"
|
||||
value={appId ?? ''}
|
||||
readOnly={readOnly}
|
||||
isInvalid={isAppIdInvalid}
|
||||
data-test-subj="swimlaneAppIdInput"
|
||||
onChange={(e) => onChangeConfig(e, 'appId')}
|
||||
onBlur={() => onBlurConfig('appId')}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
id="apiToken"
|
||||
fullWidth
|
||||
helpText={
|
||||
<EuiLink
|
||||
href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/swimlane-action-type.html`}
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiTokenNameHelpLabel"
|
||||
defaultMessage="Provide a Swimlane API Token"
|
||||
/>
|
||||
</EuiLink>
|
||||
}
|
||||
error={errors.apiToken}
|
||||
isInvalid={isApiTokenInvalid}
|
||||
label={i18n.SW_API_TOKEN_TEXT_FIELD_LABEL}
|
||||
>
|
||||
<>
|
||||
{!action.id ? (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiText size="s" data-test-subj="rememberValuesMessage">
|
||||
{i18n.SW_REMEMBER_VALUE_LABEL}
|
||||
</EuiText>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
iconType="iInCircle"
|
||||
data-test-subj="reenterValuesMessage"
|
||||
title={i18n.SW_REENTER_VALUE_LABEL}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
isInvalid={isApiTokenInvalid}
|
||||
readOnly={readOnly}
|
||||
value={apiToken ?? ''}
|
||||
data-test-subj="swimlaneApiTokenInput"
|
||||
onChange={onChangeSecrets}
|
||||
onBlur={onBlurSecrets}
|
||||
/>
|
||||
</>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer />
|
||||
<EuiButton
|
||||
disabled={!isValid}
|
||||
onClick={connectSwimlane}
|
||||
data-test-subj="swimlaneConfigureMapping"
|
||||
>
|
||||
{i18n.SW_RETRIEVE_CONFIGURATION_LABEL}
|
||||
</EuiButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SwimlaneConnection = React.memo(SwimlaneConnectionComponent);
|
|
@ -0,0 +1,313 @@
|
|||
/*
|
||||
* 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, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiFormRow,
|
||||
EuiComboBox,
|
||||
EuiComboBoxOptionOption,
|
||||
EuiButtonGroup,
|
||||
} from '@elastic/eui';
|
||||
import * as i18n from '../translations';
|
||||
import {
|
||||
SwimlaneActionConnector,
|
||||
SwimlaneConnectorType,
|
||||
SwimlaneFieldMappingConfig,
|
||||
SwimlaneMappingConfig,
|
||||
} from '../types';
|
||||
import { IErrorObject } from '../../../../../types';
|
||||
import { isValidFieldForConnector } from '../helpers';
|
||||
|
||||
const SINGLE_SELECTION = { asPlainText: true };
|
||||
const EMPTY_COMBO_BOX_ARRAY: Array<EuiComboBoxOptionOption<string>> | undefined = [];
|
||||
|
||||
const formatOption = (field: SwimlaneFieldMappingConfig) => ({
|
||||
label: `${field.name} (${field.key})`,
|
||||
value: field.id,
|
||||
});
|
||||
|
||||
const createSelectedOption = (field: SwimlaneFieldMappingConfig | null | undefined) =>
|
||||
field != null ? [formatOption(field)] : EMPTY_COMBO_BOX_ARRAY;
|
||||
|
||||
interface Props {
|
||||
action: SwimlaneActionConnector;
|
||||
editActionConfig: (property: string, value: any) => void;
|
||||
updateCurrentStep: (step: number) => void;
|
||||
fields: SwimlaneFieldMappingConfig[];
|
||||
errors: IErrorObject;
|
||||
}
|
||||
|
||||
const connectorTypeButtons = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'alerts', label: 'Alerts' },
|
||||
{ id: 'cases', label: 'Cases' },
|
||||
];
|
||||
|
||||
const SwimlaneFieldsComponent: React.FC<Props> = ({
|
||||
action,
|
||||
editActionConfig,
|
||||
updateCurrentStep,
|
||||
fields,
|
||||
errors,
|
||||
}) => {
|
||||
const { mappings, connectorType = SwimlaneConnectorType.All } = action.config;
|
||||
const prevConnectorType = useRef<SwimlaneConnectorType>(connectorType);
|
||||
const hasChangedConnectorType = connectorType !== prevConnectorType.current;
|
||||
|
||||
const [fieldTypeMap, fieldIdMap] = useMemo(
|
||||
() =>
|
||||
fields.reduce(
|
||||
([typeMap, idMap], field) => {
|
||||
if (field != null) {
|
||||
typeMap.set(field.fieldType, [
|
||||
...(typeMap.get(field.fieldType) ?? []),
|
||||
formatOption(field),
|
||||
]);
|
||||
idMap.set(field.id, field);
|
||||
}
|
||||
|
||||
return [typeMap, idMap];
|
||||
},
|
||||
[
|
||||
new Map<string, Array<EuiComboBoxOptionOption<string>>>(),
|
||||
new Map<string, SwimlaneFieldMappingConfig>(),
|
||||
]
|
||||
),
|
||||
[fields]
|
||||
);
|
||||
|
||||
const textOptions = useMemo(() => fieldTypeMap.get('text') ?? [], [fieldTypeMap]);
|
||||
const commentsOptions = useMemo(() => fieldTypeMap.get('comments') ?? [], [fieldTypeMap]);
|
||||
|
||||
const state = useMemo(
|
||||
() => ({
|
||||
alertIdConfig: createSelectedOption(mappings?.alertIdConfig),
|
||||
severityConfig: createSelectedOption(mappings?.severityConfig),
|
||||
ruleNameConfig: createSelectedOption(mappings?.ruleNameConfig),
|
||||
caseIdConfig: createSelectedOption(mappings?.caseIdConfig),
|
||||
caseNameConfig: createSelectedOption(mappings?.caseNameConfig),
|
||||
commentsConfig: createSelectedOption(mappings?.commentsConfig),
|
||||
descriptionConfig: createSelectedOption(mappings?.descriptionConfig),
|
||||
}),
|
||||
[mappings]
|
||||
);
|
||||
|
||||
const mappingErrors: Record<string, string> = useMemo(
|
||||
() => (Array.isArray(errors?.mappings) ? errors?.mappings[0] : {}),
|
||||
[errors]
|
||||
);
|
||||
|
||||
const resetConnection = useCallback(() => {
|
||||
updateCurrentStep(1);
|
||||
}, [updateCurrentStep]);
|
||||
|
||||
const editMappings = useCallback(
|
||||
(key: keyof SwimlaneMappingConfig, e: Array<EuiComboBoxOptionOption<string>>) => {
|
||||
if (e.length === 0) {
|
||||
const newProps = {
|
||||
...mappings,
|
||||
[key]: null,
|
||||
};
|
||||
editActionConfig('mappings', newProps);
|
||||
return;
|
||||
}
|
||||
|
||||
const option = e[0];
|
||||
const item = fieldIdMap.get(option.value ?? '');
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newProps = {
|
||||
...mappings,
|
||||
[key]: { id: item.id, name: item.name, key: item.key, fieldType: item.fieldType },
|
||||
};
|
||||
editActionConfig('mappings', newProps);
|
||||
},
|
||||
[editActionConfig, fieldIdMap, mappings]
|
||||
);
|
||||
|
||||
/**
|
||||
* Connector type needs to be updated on mount to All.
|
||||
* Otherwise it is undefined and this will cause an error
|
||||
* if the user saves the connector without any mapping
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => editActionConfig('connectorType', connectorType), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectorType !== prevConnectorType.current) {
|
||||
prevConnectorType.current = connectorType;
|
||||
}
|
||||
}, [connectorType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow id="connectorType" fullWidth label={i18n.SW_CONNECTOR_TYPE_LABEL}>
|
||||
<EuiButtonGroup
|
||||
name="connectorType"
|
||||
legend={i18n.SW_CONNECTOR_TYPE_LABEL}
|
||||
options={connectorTypeButtons}
|
||||
idSelected={connectorType}
|
||||
onChange={(type) => editActionConfig('connectorType', type)}
|
||||
buttonSize="compressed"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{isValidFieldForConnector(connectorType as SwimlaneConnectorType.All, 'alertIdConfig') && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
id="alertIdConfig"
|
||||
fullWidth
|
||||
label={i18n.SW_ALERT_ID_FIELD_LABEL}
|
||||
error={mappingErrors?.alertIdConfig}
|
||||
isInvalid={mappingErrors?.alertIdConfig != null && !hasChangedConnectorType}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
selectedOptions={state.alertIdConfig}
|
||||
options={textOptions}
|
||||
singleSelection={SINGLE_SELECTION}
|
||||
data-test-subj="swimlaneAlertIdInput"
|
||||
onChange={(e) => editMappings('alertIdConfig', e)}
|
||||
isInvalid={mappingErrors?.alertIdConfig != null && !hasChangedConnectorType}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'ruleNameConfig') && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
id="ruleNameConfig"
|
||||
fullWidth
|
||||
label={i18n.SW_RULE_NAME_FIELD_LABEL}
|
||||
error={mappingErrors?.ruleNameConfig}
|
||||
isInvalid={mappingErrors?.ruleNameConfig != null && !hasChangedConnectorType}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
selectedOptions={state.ruleNameConfig}
|
||||
options={textOptions}
|
||||
singleSelection={SINGLE_SELECTION}
|
||||
data-test-subj="swimlaneAlertNameInput"
|
||||
onChange={(e) => editMappings('ruleNameConfig', e)}
|
||||
isInvalid={mappingErrors?.ruleNameConfig != null && !hasChangedConnectorType}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'severityConfig') && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
id="severityConfig"
|
||||
fullWidth
|
||||
label={i18n.SW_SEVERITY_FIELD_LABEL}
|
||||
error={mappingErrors?.severityConfig}
|
||||
isInvalid={mappingErrors?.severityConfig != null && !hasChangedConnectorType}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
selectedOptions={state.severityConfig}
|
||||
options={textOptions}
|
||||
singleSelection={SINGLE_SELECTION}
|
||||
data-test-subj="swimlaneSeverityInput"
|
||||
onChange={(e) => editMappings('severityConfig', e)}
|
||||
isInvalid={mappingErrors?.severityConfig != null && !hasChangedConnectorType}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'caseIdConfig') && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
id="caseIdConfig"
|
||||
fullWidth
|
||||
label={i18n.SW_CASE_ID_FIELD_LABEL}
|
||||
error={mappingErrors?.caseIdConfig}
|
||||
isInvalid={mappingErrors?.caseIdConfig != null && !hasChangedConnectorType}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
selectedOptions={state.caseIdConfig}
|
||||
options={textOptions}
|
||||
singleSelection={SINGLE_SELECTION}
|
||||
data-test-subj="swimlaneCaseIdConfig"
|
||||
onChange={(e) => editMappings('caseIdConfig', e)}
|
||||
isInvalid={mappingErrors?.caseIdConfig != null && !hasChangedConnectorType}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'caseNameConfig') && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
id="caseNameConfig"
|
||||
fullWidth
|
||||
label={i18n.SW_CASE_NAME_FIELD_LABEL}
|
||||
error={mappingErrors?.caseNameConfig}
|
||||
isInvalid={mappingErrors?.caseNameConfig != null && !hasChangedConnectorType}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
selectedOptions={state.caseNameConfig}
|
||||
options={textOptions}
|
||||
singleSelection={SINGLE_SELECTION}
|
||||
data-test-subj="swimlaneCaseNameConfig"
|
||||
onChange={(e) => editMappings('caseNameConfig', e)}
|
||||
isInvalid={mappingErrors?.caseNameConfig != null && !hasChangedConnectorType}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'commentsConfig') && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
id="commentsConfig"
|
||||
fullWidth
|
||||
label={i18n.SW_COMMENTS_FIELD_LABEL}
|
||||
error={mappingErrors?.commentsConfig}
|
||||
isInvalid={mappingErrors?.commentsConfig != null && !hasChangedConnectorType}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
selectedOptions={state.commentsConfig}
|
||||
options={commentsOptions}
|
||||
singleSelection={SINGLE_SELECTION}
|
||||
data-test-subj="swimlaneCommentsConfig"
|
||||
onChange={(e) => editMappings('commentsConfig', e)}
|
||||
isInvalid={mappingErrors?.commentsConfig != null && !hasChangedConnectorType}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'descriptionConfig') && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
id="descriptionConfig"
|
||||
fullWidth
|
||||
label={i18n.SW_DESCRIPTION_FIELD_LABEL}
|
||||
error={mappingErrors?.descriptionConfig}
|
||||
isInvalid={mappingErrors?.descriptionConfig != null && !hasChangedConnectorType}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
selectedOptions={state.descriptionConfig}
|
||||
options={textOptions}
|
||||
singleSelection={SINGLE_SELECTION}
|
||||
data-test-subj="swimlaneDescriptionConfig"
|
||||
onChange={(e) => editMappings('descriptionConfig', e)}
|
||||
isInvalid={mappingErrors?.descriptionConfig != null && !hasChangedConnectorType}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
<EuiButton onClick={resetConnection}>{i18n.SW_CONFIGURE_API_LABEL}</EuiButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SwimlaneFields = React.memo(SwimlaneFieldsComponent);
|
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
* 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 { TypeRegistry } from '../../../type_registry';
|
||||
import { registerBuiltInActionTypes } from '.././index';
|
||||
import { ActionTypeModel } from '../../../../types';
|
||||
import { SwimlaneActionConnector } from './types';
|
||||
|
||||
const ACTION_TYPE_ID = '.swimlane';
|
||||
let actionTypeModel: ActionTypeModel;
|
||||
|
||||
beforeAll(() => {
|
||||
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
|
||||
registerBuiltInActionTypes({ actionTypeRegistry });
|
||||
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
|
||||
if (getResult !== null) {
|
||||
actionTypeModel = getResult;
|
||||
}
|
||||
});
|
||||
|
||||
describe('actionTypeRegistry.get() works', () => {
|
||||
test('action type static data is as expected', () => {
|
||||
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('swimlane connector validation', () => {
|
||||
test('connector validation succeeds when connector is valid', async () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'all',
|
||||
mappings: {
|
||||
alertIdConfig: { id: '1234' },
|
||||
severityConfig: { id: '1234' },
|
||||
ruleNameConfig: { id: '1234' },
|
||||
caseIdConfig: { id: '1234' },
|
||||
caseNameConfig: { id: '1234' },
|
||||
descriptionConfig: { id: '1234' },
|
||||
commentsConfig: { id: '1234' },
|
||||
},
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
|
||||
config: { errors: { apiUrl: [], appId: [], mappings: [], connectorType: [] } },
|
||||
secrets: { errors: { apiToken: [] } },
|
||||
});
|
||||
});
|
||||
|
||||
test('it validates correctly when connectorType=all', async () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'all',
|
||||
mappings: {},
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
|
||||
config: { errors: { apiUrl: [], appId: [], mappings: [], connectorType: [] } },
|
||||
secrets: { errors: { apiToken: [] } },
|
||||
});
|
||||
});
|
||||
|
||||
test('it validates correctly when connectorType=cases', async () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'cases',
|
||||
mappings: {},
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
|
||||
config: {
|
||||
errors: {
|
||||
apiUrl: [],
|
||||
appId: [],
|
||||
mappings: [
|
||||
{
|
||||
caseIdConfig: 'Case ID is required.',
|
||||
caseNameConfig: 'Case name is required.',
|
||||
commentsConfig: 'Comments are required.',
|
||||
descriptionConfig: 'Description is required.',
|
||||
},
|
||||
],
|
||||
connectorType: [],
|
||||
},
|
||||
},
|
||||
secrets: { errors: { apiToken: [] } },
|
||||
});
|
||||
});
|
||||
|
||||
test('it validates correctly when connectorType=alerts', async () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'alerts',
|
||||
mappings: {},
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
|
||||
config: {
|
||||
errors: {
|
||||
apiUrl: [],
|
||||
appId: [],
|
||||
mappings: [
|
||||
{
|
||||
alertIdConfig: 'Alert ID is required.',
|
||||
ruleNameConfig: 'Rule name is required.',
|
||||
},
|
||||
],
|
||||
connectorType: [],
|
||||
},
|
||||
},
|
||||
secrets: { errors: { apiToken: [] } },
|
||||
});
|
||||
});
|
||||
|
||||
test('it validates correctly required config/secrets fields', async () => {
|
||||
const actionConnector = {
|
||||
secrets: {},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
|
||||
config: {
|
||||
errors: {
|
||||
apiUrl: ['URL is required.'],
|
||||
appId: ['An App ID is required.'],
|
||||
mappings: [],
|
||||
connectorType: [],
|
||||
},
|
||||
},
|
||||
secrets: { errors: { apiToken: ['An API token is required.'] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('swimlane action params validation', () => {
|
||||
test('action params validation succeeds when action params is valid', async () => {
|
||||
const actionParams = {
|
||||
subActionParams: {
|
||||
ruleName: 'Rule Name',
|
||||
alertId: 'alert-id',
|
||||
},
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
'subActionParams.incident.ruleName': [],
|
||||
'subActionParams.incident.alertId': [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it validates correctly required fields', async () => {
|
||||
const actionParams = {
|
||||
subActionParams: { incident: {} },
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
'subActionParams.incident.ruleName': ['Rule name is required.'],
|
||||
'subActionParams.incident.alertId': ['Alert ID is required.'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it succeeds when missing incident', async () => {
|
||||
const actionParams = {
|
||||
subActionParams: {},
|
||||
};
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
'subActionParams.incident.ruleName': [],
|
||||
'subActionParams.incident.alertId': [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* 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 { isEmpty } from 'lodash';
|
||||
import { lazy } from 'react';
|
||||
import {
|
||||
ActionTypeModel,
|
||||
ConnectorValidationResult,
|
||||
GenericValidationResult,
|
||||
} from '../../../../types';
|
||||
import {
|
||||
SwimlaneActionConnector,
|
||||
SwimlaneConfig,
|
||||
SwimlaneSecrets,
|
||||
SwimlaneActionParams,
|
||||
} from './types';
|
||||
import * as i18n from './translations';
|
||||
import { isValidUrl } from '../../../lib/value_validators';
|
||||
import { validateMappingForConnector } from './helpers';
|
||||
|
||||
export function getActionType(): ActionTypeModel<
|
||||
SwimlaneConfig,
|
||||
SwimlaneSecrets,
|
||||
SwimlaneActionParams
|
||||
> {
|
||||
return {
|
||||
id: '.swimlane',
|
||||
iconClass: lazy(() => import('./logo')),
|
||||
selectMessage: i18n.SW_SELECT_MESSAGE_TEXT,
|
||||
actionTypeTitle: i18n.SW_ACTION_TYPE_TITLE,
|
||||
validateConnector: async (
|
||||
action: SwimlaneActionConnector
|
||||
): Promise<ConnectorValidationResult<SwimlaneConfig, SwimlaneSecrets>> => {
|
||||
const configErrors = {
|
||||
apiUrl: new Array<string>(),
|
||||
appId: new Array<string>(),
|
||||
connectorType: new Array<string>(),
|
||||
mappings: new Array<Record<string, string>>(),
|
||||
};
|
||||
const secretsErrors = {
|
||||
apiToken: new Array<string>(),
|
||||
};
|
||||
|
||||
const validationResult = {
|
||||
config: { errors: configErrors },
|
||||
secrets: { errors: secretsErrors },
|
||||
};
|
||||
|
||||
if (!action.config.apiUrl) {
|
||||
configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_REQUIRED];
|
||||
} else if (action.config.apiUrl) {
|
||||
if (!isValidUrl(action.config.apiUrl)) {
|
||||
configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_INVALID];
|
||||
}
|
||||
}
|
||||
|
||||
if (!action.secrets.apiToken) {
|
||||
secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.SW_REQUIRED_API_TOKEN_TEXT];
|
||||
}
|
||||
|
||||
if (!action.config.appId) {
|
||||
configErrors.appId = [...configErrors.appId, i18n.SW_REQUIRED_APP_ID_TEXT];
|
||||
}
|
||||
|
||||
const mappingErrors = validateMappingForConnector(
|
||||
action.config.connectorType,
|
||||
action.config.mappings
|
||||
);
|
||||
|
||||
if (!isEmpty(mappingErrors)) {
|
||||
configErrors.mappings = [...configErrors.mappings, mappingErrors];
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
},
|
||||
validateParams: async (
|
||||
actionParams: SwimlaneActionParams
|
||||
): Promise<GenericValidationResult<unknown>> => {
|
||||
const errors = {
|
||||
'subActionParams.incident.ruleName': new Array<string>(),
|
||||
'subActionParams.incident.alertId': new Array<string>(),
|
||||
};
|
||||
const validationResult = {
|
||||
errors,
|
||||
};
|
||||
|
||||
const hasIncident = actionParams.subActionParams && actionParams.subActionParams.incident;
|
||||
|
||||
if (hasIncident && !actionParams.subActionParams.incident.ruleName?.length) {
|
||||
errors['subActionParams.incident.ruleName'].push(i18n.SW_REQUIRED_RULE_NAME);
|
||||
}
|
||||
|
||||
if (hasIncident && !actionParams.subActionParams.incident.alertId?.length) {
|
||||
errors['subActionParams.incident.alertId'].push(i18n.SW_REQUIRED_ALERT_ID);
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
},
|
||||
actionConnectorFields: lazy(() => import('./swimlane_connectors')),
|
||||
actionParamsFields: lazy(() => import('./swimlane_params')),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,319 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mountWithIntl, nextTick } from '@kbn/test/jest';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { SwimlaneActionConnector } from './types';
|
||||
import SwimlaneActionConnectorFields from './swimlane_connectors';
|
||||
import { useGetApplication } from './use_get_application';
|
||||
import { applicationFields, mappings } from './mocks';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
jest.mock('./use_get_application');
|
||||
|
||||
const useGetApplicationMock = useGetApplication as jest.Mock;
|
||||
const getApplication = jest.fn();
|
||||
|
||||
describe('SwimlaneActionConnectorFields renders', () => {
|
||||
beforeAll(() => {
|
||||
useGetApplicationMock.mockReturnValue({
|
||||
getApplication,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('all connector fields are rendered', async () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'all',
|
||||
mappings,
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<SwimlaneActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-subj="swimlaneApiUrlInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneAppIdInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneApiTokenInput"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should display a message on create to remember credentials', () => {
|
||||
const actionConnector = {
|
||||
actionTypeId: '.swimlane',
|
||||
secrets: {},
|
||||
config: {},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<SwimlaneActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0);
|
||||
});
|
||||
|
||||
test('should display a message on edit to re-enter credentials', () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'all',
|
||||
mappings,
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<SwimlaneActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0);
|
||||
});
|
||||
|
||||
test('renders the mappings correctly - connector type all', async () => {
|
||||
getApplication.mockResolvedValue({
|
||||
fields: applicationFields,
|
||||
});
|
||||
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'all',
|
||||
mappings,
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<SwimlaneActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders the mappings correctly - connector type cases', async () => {
|
||||
getApplication.mockResolvedValue({
|
||||
fields: applicationFields,
|
||||
});
|
||||
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'cases',
|
||||
mappings,
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<SwimlaneActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders the mappings correctly - connector type alerts', async () => {
|
||||
getApplication.mockResolvedValue({
|
||||
fields: applicationFields,
|
||||
});
|
||||
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'alerts',
|
||||
mappings,
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<SwimlaneActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeFalsy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('renders the correct options per field', async () => {
|
||||
getApplication.mockResolvedValue({
|
||||
fields: applicationFields,
|
||||
});
|
||||
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
apiToken: 'test',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'swimlane',
|
||||
config: {
|
||||
apiUrl: 'http:\\test',
|
||||
appId: '1234567asbd32',
|
||||
connectorType: 'all',
|
||||
mappings,
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
const textOptions = [
|
||||
{ label: 'Alert Id (alert-id)', value: 'a6ide' },
|
||||
{ label: 'Severity (severity)', value: 'adnlas' },
|
||||
{ label: 'Rule Name (rule-name)', value: 'adnfls' },
|
||||
{ label: 'Case Id (case-id-name)', value: 'a6sst' },
|
||||
{ label: 'Case Name (case-name)', value: 'a6fst' },
|
||||
{ label: 'Description (description)', value: 'a6fde' },
|
||||
];
|
||||
|
||||
const commentOptions = [{ label: 'Comments (notes)', value: 'a6fdf' }];
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<SwimlaneActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click');
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').first().prop('options')).toEqual(
|
||||
textOptions
|
||||
);
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').first().prop('options')
|
||||
).toEqual(textOptions);
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="swimlaneSeverityInput"]').first().prop('options')
|
||||
).toEqual(textOptions);
|
||||
expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').first().prop('options')).toEqual(
|
||||
textOptions
|
||||
);
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').first().prop('options')
|
||||
).toEqual(textOptions);
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').first().prop('options')
|
||||
).toEqual(commentOptions);
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').first().prop('options')
|
||||
).toEqual(textOptions);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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, { Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import { EuiForm, EuiSpacer, EuiStepsHorizontal, EuiStepStatus } from '@elastic/eui';
|
||||
import * as i18n from './translations';
|
||||
import { ActionConnectorFieldsProps } from '../../../../types';
|
||||
import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from './types';
|
||||
import { SwimlaneConnection, SwimlaneFields } from './steps';
|
||||
|
||||
const SwimlaneActionConnectorFields: React.FunctionComponent<
|
||||
ActionConnectorFieldsProps<SwimlaneActionConnector>
|
||||
> = ({ errors, action, editActionConfig, editActionSecrets, readOnly }) => {
|
||||
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||
const [stepsStatuses, setStepsStatuses] = useState<{
|
||||
connection: EuiStepStatus;
|
||||
fields: EuiStepStatus;
|
||||
}>({ connection: 'incomplete', fields: 'incomplete' });
|
||||
const [fields, setFields] = useState<SwimlaneFieldMappingConfig[]>([]);
|
||||
|
||||
const updateCurrentStep = useCallback(
|
||||
(step: number) => {
|
||||
setCurrentStep(step);
|
||||
if (step === 2) {
|
||||
setStepsStatuses((statuses) => ({ ...statuses, connection: 'complete' }));
|
||||
} else if (step === 1) {
|
||||
setStepsStatuses({
|
||||
fields: 'incomplete',
|
||||
connection: 'incomplete',
|
||||
});
|
||||
editActionConfig('mappings', action.config.mappings);
|
||||
}
|
||||
},
|
||||
[action.config.mappings, editActionConfig]
|
||||
);
|
||||
|
||||
const setupSteps = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: i18n.SW_CONFIGURE_CONNECTION_LABEL,
|
||||
status: stepsStatuses.connection,
|
||||
onClick: () => updateCurrentStep(1),
|
||||
},
|
||||
{
|
||||
title: i18n.SW_MAPPING_TITLE_TEXT_FIELD_LABEL,
|
||||
disabled: stepsStatuses.connection !== 'complete',
|
||||
status: stepsStatuses.fields,
|
||||
onClick: () => updateCurrentStep(2),
|
||||
},
|
||||
],
|
||||
[stepsStatuses.connection, stepsStatuses.fields, updateCurrentStep]
|
||||
);
|
||||
|
||||
const editActionConfigCb = useCallback(
|
||||
(k: string, v: string) => {
|
||||
editActionConfig(k, v);
|
||||
if (
|
||||
Object.values(errors?.mappings ?? {}).every((mappingError) => mappingError.length === 0)
|
||||
) {
|
||||
setStepsStatuses((statuses) => ({ ...statuses, fields: 'complete' }));
|
||||
} else {
|
||||
setStepsStatuses((statuses) => ({ ...statuses, fields: 'incomplete' }));
|
||||
}
|
||||
},
|
||||
[editActionConfig, errors?.mappings]
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiStepsHorizontal steps={setupSteps} />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiForm>
|
||||
{currentStep === 1 && (
|
||||
<SwimlaneConnection
|
||||
action={action}
|
||||
editActionConfig={editActionConfigCb}
|
||||
editActionSecrets={editActionSecrets}
|
||||
readOnly={readOnly}
|
||||
errors={errors}
|
||||
updateCurrentStep={updateCurrentStep}
|
||||
updateFields={setFields}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<SwimlaneFields
|
||||
action={action}
|
||||
editActionConfig={editActionConfigCb}
|
||||
updateCurrentStep={updateCurrentStep}
|
||||
fields={fields}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
</EuiForm>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { SwimlaneActionConnectorFields as default };
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mountWithIntl } from '@kbn/test/jest';
|
||||
import SwimlaneParamsFields from './swimlane_params';
|
||||
import { SwimlaneConnectorType } from './types';
|
||||
import { mappings } from './mocks';
|
||||
|
||||
describe('SwimlaneParamsFields renders', () => {
|
||||
const editAction = jest.fn();
|
||||
const actionParams = {
|
||||
subAction: 'pushToService',
|
||||
subActionParams: {
|
||||
incident: {
|
||||
alertId: '3456789',
|
||||
ruleName: 'rule name',
|
||||
severity: 'critical',
|
||||
caseId: null,
|
||||
caseName: null,
|
||||
description: null,
|
||||
externalId: null,
|
||||
},
|
||||
comments: [],
|
||||
},
|
||||
};
|
||||
|
||||
const connector = {
|
||||
secrets: {},
|
||||
config: { mappings, connectorType: SwimlaneConnectorType.All },
|
||||
id: 'test',
|
||||
actionTypeId: '.test',
|
||||
name: 'Test',
|
||||
isPreconfigured: false,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
actionParams,
|
||||
errors: {
|
||||
'subActionParams.incident.ruleName': [],
|
||||
'subActionParams.incident.alertId': [],
|
||||
},
|
||||
editAction,
|
||||
index: 0,
|
||||
messageVariables: [],
|
||||
actionConnector: connector,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('all params fields are rendered', () => {
|
||||
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="severity"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="comments"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it set the correct default params', () => {
|
||||
mountWithIntl(<SwimlaneParamsFields {...defaultProps} actionParams={{}} />);
|
||||
expect(editAction).toHaveBeenCalledWith('subAction', 'pushToService', 0);
|
||||
expect(editAction).toHaveBeenCalledWith(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
|
||||
comments: [],
|
||||
},
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('it reset the fields when connector changes', () => {
|
||||
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
|
||||
expect(editAction).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
|
||||
expect(editAction).toHaveBeenCalledWith(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
|
||||
comments: [],
|
||||
},
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('it set the severity', () => {
|
||||
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
|
||||
expect(editAction).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
|
||||
expect(editAction).toHaveBeenCalledWith(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
|
||||
comments: [],
|
||||
},
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
describe('UI updates', () => {
|
||||
const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent<HTMLSelectElement>;
|
||||
const simpleFields = [
|
||||
{ dataTestSubj: 'input[data-test-subj="severityInput"]', key: 'severity' },
|
||||
];
|
||||
|
||||
simpleFields.forEach((field) =>
|
||||
test(`${field.key} update triggers editAction`, () => {
|
||||
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
|
||||
const theField = wrapper.find(field.dataTestSubj).first();
|
||||
theField.prop('onChange')!(changeEvent);
|
||||
expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value);
|
||||
})
|
||||
);
|
||||
|
||||
test('A comment triggers editAction', () => {
|
||||
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
|
||||
const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]');
|
||||
expect(comments.simulate('change', changeEvent));
|
||||
expect(editAction.mock.calls[0][1].comments.length).toEqual(1);
|
||||
});
|
||||
|
||||
test('An empty comment does not trigger editAction', () => {
|
||||
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
|
||||
const emptyComment = { target: { value: '' } };
|
||||
const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea');
|
||||
expect(comments.simulate('change', emptyComment));
|
||||
expect(editAction.mock.calls.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* 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, useRef, useMemo } from 'react';
|
||||
import { EuiCallOut, EuiFormRow, EuiSpacer } from '@elastic/eui';
|
||||
import * as i18n from './translations';
|
||||
import { ActionParamsProps } from '../../../../types';
|
||||
import { SwimlaneActionConnector, SwimlaneActionParams, SwimlaneConnectorType } from './types';
|
||||
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
|
||||
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
|
||||
|
||||
const SwimlaneParamsFields: React.FunctionComponent<ActionParamsProps<SwimlaneActionParams>> = ({
|
||||
actionParams,
|
||||
editAction,
|
||||
index,
|
||||
messageVariables,
|
||||
actionConnector,
|
||||
}) => {
|
||||
const { incident, comments } = useMemo(
|
||||
() =>
|
||||
actionParams.subActionParams ??
|
||||
(({
|
||||
incident: {},
|
||||
comments: [],
|
||||
} as unknown) as SwimlaneActionParams['subActionParams']),
|
||||
[actionParams.subActionParams]
|
||||
);
|
||||
|
||||
const actionConnectorRef = useRef(actionConnector?.id ?? '');
|
||||
|
||||
const {
|
||||
mappings,
|
||||
connectorType,
|
||||
} = ((actionConnector as unknown) as SwimlaneActionConnector).config;
|
||||
const { hasAlertId, hasRuleName, hasComments, hasSeverity } = useMemo(
|
||||
() => ({
|
||||
hasAlertId: mappings.alertIdConfig != null,
|
||||
hasRuleName: mappings.ruleNameConfig != null,
|
||||
hasComments: mappings.commentsConfig != null,
|
||||
hasSeverity: mappings.severityConfig != null,
|
||||
}),
|
||||
[
|
||||
mappings.alertIdConfig,
|
||||
mappings.ruleNameConfig,
|
||||
mappings.commentsConfig,
|
||||
mappings.severityConfig,
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* The user can use either a connector of type alerts or all.
|
||||
* If the connector is of type all we should check if all
|
||||
* required field have been configured.
|
||||
*/
|
||||
const showMappingWarning =
|
||||
connectorType === SwimlaneConnectorType.Cases || !hasRuleName || !hasAlertId;
|
||||
|
||||
const editSubActionProperty = useCallback(
|
||||
(key: string, value: any) => {
|
||||
if (key === 'comments') {
|
||||
return editAction('subActionParams', { incident, comments: value }, index);
|
||||
}
|
||||
|
||||
return editAction(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: { ...incident, [key]: value },
|
||||
comments,
|
||||
},
|
||||
index
|
||||
);
|
||||
},
|
||||
[editAction, incident, comments, index]
|
||||
);
|
||||
|
||||
const editComment = useCallback(
|
||||
(key, value) => {
|
||||
if (value.length > 0) {
|
||||
editSubActionProperty(key, [{ commentId: '1', comment: value }]);
|
||||
}
|
||||
},
|
||||
[editSubActionProperty]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) {
|
||||
actionConnectorRef.current = actionConnector.id;
|
||||
editAction(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
|
||||
comments: [],
|
||||
},
|
||||
index
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionConnector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionParams.subAction) {
|
||||
editAction('subAction', 'pushToService', index);
|
||||
}
|
||||
|
||||
if (!actionParams.subActionParams) {
|
||||
editAction(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
|
||||
comments: [],
|
||||
},
|
||||
index
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionParams]);
|
||||
|
||||
return !showMappingWarning ? (
|
||||
<>
|
||||
{hasSeverity && (
|
||||
<>
|
||||
<EuiFormRow fullWidth label={i18n.SW_SEVERITY_FIELD_LABEL}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
data-test-subj="severity"
|
||||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'severity'}
|
||||
inputTargetValue={incident.severity ?? undefined}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
{hasComments && (
|
||||
<TextAreaWithMessageVariables
|
||||
data-test-subj="comments"
|
||||
index={index}
|
||||
editAction={editComment}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'comments'}
|
||||
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined}
|
||||
label={i18n.SW_COMMENTS_FIELD_LABEL}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EuiCallOut title={i18n.EMPTY_MAPPING_WARNING_TITLE} color="warning" iconType="help">
|
||||
{i18n.EMPTY_MAPPING_WARNING_DESC}
|
||||
</EuiCallOut>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { SwimlaneParamsFields as default };
|
|
@ -0,0 +1,282 @@
|
|||
/*
|
||||
* 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 SW_SELECT_MESSAGE_TEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.selectMessageText',
|
||||
{
|
||||
defaultMessage: 'Create record in Swimlane',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_ACTION_TYPE_TITLE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.actionTypeTitle',
|
||||
{
|
||||
defaultMessage: 'Create Swimlane Record',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_RULE_NAME = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredRuleName',
|
||||
{
|
||||
defaultMessage: 'Rule name is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_APP_ID_TEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAppIdText',
|
||||
{
|
||||
defaultMessage: 'An App ID is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_FIELD_MAPPINGS_TEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredFieldMappingsText',
|
||||
{
|
||||
defaultMessage: 'Field mappings are required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_API_TOKEN_TEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredApiTokenText',
|
||||
{
|
||||
defaultMessage: 'An API token is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_GET_APPLICATION_API_ERROR = (id: string | null) =>
|
||||
i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlane.unableToGetApplicationMessage',
|
||||
{
|
||||
defaultMessage: 'Unable to get application with id {id}',
|
||||
values: { id },
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_GET_APPLICATION_API_NO_FIELDS_ERROR = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlane.unableToGetApplicationFieldsMessage',
|
||||
{
|
||||
defaultMessage: 'Unable to get application fields',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_API_URL_TEXT_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiUrlTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'API Url',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_API_URL_REQUIRED = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.requiredApiUrlTextField',
|
||||
{
|
||||
defaultMessage: 'URL is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_API_URL_INVALID = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.invalidApiUrlTextField',
|
||||
{
|
||||
defaultMessage: 'URL is invalid.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_APP_ID_TEXT_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.appIdTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Application ID',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_API_TOKEN_TEXT_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiTokenTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'API Token',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_MAPPING_TITLE_TEXT_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingTitleTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Configure Field Mappings',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_ALERT_SOURCE_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertSourceFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Alert source',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_SEVERITY_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.severityFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Severity',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_MAPPING_DESCRIPTION_TEXT_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingDescriptionTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Used to specify the field names in the Swimlane Application',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_RULE_NAME_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.ruleNameFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Rule name',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_ALERT_ID_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertIdFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Alert ID',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_CASE_ID_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.caseIdFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Case ID',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_CASE_NAME_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.caseNameFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Case name',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_COMMENTS_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.commentsFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Comments',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_DESCRIPTION_FIELD_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.descriptionFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Description',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REMEMBER_VALUE_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.rememberValueLabel',
|
||||
{ defaultMessage: 'Remember this value. You must reenter it each time you edit the connector.' }
|
||||
);
|
||||
|
||||
export const SW_REENTER_VALUE_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.reenterValueLabel',
|
||||
{ defaultMessage: 'This key is encrypted. Please reenter a value for this field.' }
|
||||
);
|
||||
|
||||
export const SW_CONFIGURE_CONNECTION_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.configureConnectionLabel',
|
||||
{ defaultMessage: 'Configure API Connection' }
|
||||
);
|
||||
|
||||
export const SW_RETRIEVE_CONFIGURATION_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.retrieveConfigurationLabel',
|
||||
{ defaultMessage: 'Configure Fields' }
|
||||
);
|
||||
|
||||
export const SW_CONFIGURE_API_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.configureAPILabel',
|
||||
{ defaultMessage: 'Configure API' }
|
||||
);
|
||||
|
||||
export const SW_CONNECTOR_TYPE_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.connectorType',
|
||||
{
|
||||
defaultMessage: 'Connector Type',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_FIELD_MAPPING_IS_REQUIRED = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingFieldRequired',
|
||||
{
|
||||
defaultMessage: 'Field mapping is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const EMPTY_MAPPING_WARNING_TITLE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.emptyMappingWarningTitle',
|
||||
{
|
||||
defaultMessage: 'This connector has missing field mappings',
|
||||
}
|
||||
);
|
||||
|
||||
export const EMPTY_MAPPING_WARNING_DESC = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.emptyMappingWarningDesc',
|
||||
{
|
||||
defaultMessage:
|
||||
'This connector cannot be selected because it is missing the required case field mappings. You can edit this connector to add required field mappings or select a connector of type Alerts.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_ALERT_SOURCE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAlertSource',
|
||||
{
|
||||
defaultMessage: 'Alert source is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_SEVERITY = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredSeverity',
|
||||
{
|
||||
defaultMessage: 'Severity is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_CASE_NAME = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredCaseName',
|
||||
{
|
||||
defaultMessage: 'Case name is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_CASE_ID = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredCaseID',
|
||||
{
|
||||
defaultMessage: 'Case ID is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_COMMENTS = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredComments',
|
||||
{
|
||||
defaultMessage: 'Comments are required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_DESCRIPTION = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredDescription',
|
||||
{
|
||||
defaultMessage: 'Description is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_REQUIRED_ALERT_ID = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAlertID',
|
||||
{
|
||||
defaultMessage: 'Alert ID is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SW_ALERT_SOURCE_TOOLTIP = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertSourceTooltip',
|
||||
{
|
||||
defaultMessage: 'The index of the alert. Use {index} in Detections.',
|
||||
values: { index: '{{context.rule.output_index}}' },
|
||||
}
|
||||
);
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/* eslint-disable @kbn/eslint/no-restricted-paths */
|
||||
|
||||
import { UserConfiguredActionConnector } from '../../../../types';
|
||||
import {
|
||||
ExecutorSubActionPushParams,
|
||||
MappingConfigType,
|
||||
} from '../../../../../../actions/server/builtin_action_types/swimlane/types';
|
||||
|
||||
export type SwimlaneActionConnector = UserConfiguredActionConnector<
|
||||
SwimlaneConfig,
|
||||
SwimlaneSecrets
|
||||
>;
|
||||
|
||||
export interface SwimlaneConfig {
|
||||
apiUrl: string;
|
||||
appId: string;
|
||||
connectorType: SwimlaneConnectorType;
|
||||
mappings: SwimlaneMappingConfig;
|
||||
}
|
||||
|
||||
export type MappingConfigurationKeys = keyof MappingConfigType;
|
||||
export type SwimlaneMappingConfig = Record<keyof MappingConfigType, SwimlaneFieldMappingConfig>;
|
||||
|
||||
export interface SwimlaneFieldMappingConfig {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
fieldType: string;
|
||||
}
|
||||
|
||||
export interface SwimlaneSecrets {
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export interface SwimlaneActionParams {
|
||||
subAction: string;
|
||||
subActionParams: ExecutorSubActionPushParams;
|
||||
}
|
||||
|
||||
export interface SwimlaneFieldMap {
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export enum SwimlaneConnectorType {
|
||||
All = 'all',
|
||||
Alerts = 'alerts',
|
||||
Cases = 'cases',
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react-hooks';
|
||||
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { getApplication } from './api';
|
||||
import { SwimlaneActionConnector } from './types';
|
||||
import { useGetApplication, UseGetApplication } from './use_get_application';
|
||||
|
||||
jest.mock('./api');
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
||||
const getApplicationMock = getApplication as jest.Mock;
|
||||
|
||||
const action = {
|
||||
secrets: { apiToken: 'token' },
|
||||
id: 'test',
|
||||
actionTypeId: '.swimlane',
|
||||
name: 'Swimlane',
|
||||
isPreconfigured: false,
|
||||
config: {
|
||||
apiUrl: 'https://test.swimlane.com/',
|
||||
appId: 'bcq16kdTbz5jlwM6h',
|
||||
mappings: {},
|
||||
},
|
||||
} as SwimlaneActionConnector;
|
||||
|
||||
describe('useGetApplication', () => {
|
||||
const { services } = useKibanaMock();
|
||||
getApplicationMock.mockResolvedValue({
|
||||
data: { fields: [] },
|
||||
});
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('init', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
|
||||
useGetApplication({
|
||||
appId: action.config.appId,
|
||||
apiToken: action.secrets.apiToken,
|
||||
apiUrl: action.config.apiUrl,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
getApplication: result.current.getApplication,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls getApplication with correct arguments', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
|
||||
useGetApplication({
|
||||
appId: action.config.appId,
|
||||
apiToken: action.secrets.apiToken,
|
||||
apiUrl: action.config.apiUrl,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current.getApplication();
|
||||
await waitForNextUpdate();
|
||||
expect(getApplicationMock).toBeCalledWith({
|
||||
signal: abortCtrl.signal,
|
||||
appId: action.config.appId,
|
||||
apiToken: action.secrets.apiToken,
|
||||
url: action.config.apiUrl,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('get application', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
|
||||
useGetApplication({
|
||||
appId: action.config.appId,
|
||||
apiToken: action.secrets.apiToken,
|
||||
apiUrl: action.config.apiUrl,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
result.current.getApplication();
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
getApplication: result.current.getApplication,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('set isLoading to true when getting the application', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
|
||||
useGetApplication({
|
||||
appId: action.config.appId,
|
||||
apiToken: action.secrets.apiToken,
|
||||
apiUrl: action.config.apiUrl,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
result.current.getApplication();
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('it displays an error when http throws an error', async () => {
|
||||
getApplicationMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
|
||||
useGetApplication({
|
||||
appId: action.config.appId,
|
||||
apiToken: action.secrets.apiToken,
|
||||
apiUrl: action.config.apiUrl,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
result.current.getApplication();
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
getApplication: result.current.getApplication,
|
||||
});
|
||||
|
||||
expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({
|
||||
title: 'Unable to get application with id bcq16kdTbz5jlwM6h',
|
||||
text: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('it displays an error when the response does not contain the correct fields', async () => {
|
||||
getApplicationMock.mockResolvedValue({});
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
|
||||
useGetApplication({
|
||||
appId: action.config.appId,
|
||||
apiToken: action.secrets.apiToken,
|
||||
apiUrl: action.config.apiUrl,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
result.current.getApplication();
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({
|
||||
title: 'Unable to get application with id bcq16kdTbz5jlwM6h',
|
||||
text: 'Unable to get application fields',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { ToastsApi } from 'kibana/public';
|
||||
import { getApplication as getApplicationApi } from './api';
|
||||
import * as i18n from './translations';
|
||||
import { SwimlaneFieldMappingConfig } from './types';
|
||||
|
||||
interface Props {
|
||||
toastNotifications: Pick<
|
||||
ToastsApi,
|
||||
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
|
||||
>;
|
||||
appId: string;
|
||||
apiToken: string;
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
export interface UseGetApplication {
|
||||
getApplication: () => Promise<{ fields?: SwimlaneFieldMappingConfig[] } | undefined>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const useGetApplication = ({
|
||||
toastNotifications,
|
||||
appId,
|
||||
apiToken,
|
||||
apiUrl,
|
||||
}: Props): UseGetApplication => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isCancelledRef = useRef(false);
|
||||
const abortCtrlRef = useRef(new AbortController());
|
||||
|
||||
const getApplication = useCallback(async () => {
|
||||
try {
|
||||
isCancelledRef.current = false;
|
||||
abortCtrlRef.current.abort();
|
||||
abortCtrlRef.current = new AbortController();
|
||||
setIsLoading(true);
|
||||
|
||||
const data = await getApplicationApi({
|
||||
signal: abortCtrlRef.current.signal,
|
||||
appId,
|
||||
apiToken,
|
||||
url: apiUrl,
|
||||
});
|
||||
|
||||
if (!isCancelledRef.current) {
|
||||
setIsLoading(false);
|
||||
if (!data.fields) {
|
||||
// If the response was malformed and fields doesn't exist, show an error toast
|
||||
toastNotifications.addDanger({
|
||||
title: i18n.SW_GET_APPLICATION_API_ERROR(appId),
|
||||
text: i18n.SW_GET_APPLICATION_API_NO_FIELDS_ERROR,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelledRef.current) {
|
||||
if (error.name !== 'AbortError') {
|
||||
toastNotifications.addDanger({
|
||||
title: i18n.SW_GET_APPLICATION_API_ERROR(appId),
|
||||
text: error.message,
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [apiToken, apiUrl, appId, toastNotifications]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
getApplication,
|
||||
};
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue