Extended existing alerting functionality to support preconfigured only action types (#64030)

* Extended existing alerting functionality to support preconfigured only action types

* fixed functional test

* Adding documentation

* Fixed UI part due to comments

* added missing tests

* fixed action type execution

* Fixed documentation

* Fixed due to comments

* fixed type checks

* extended isActionExecutable to check exact action id if it is in the preconfigured list
This commit is contained in:
Yuliia Naumenko 2020-04-24 14:38:28 -07:00 committed by GitHub
parent 74bf87721f
commit 6bf0e731b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 676 additions and 178 deletions

View file

@ -41,12 +41,14 @@ see https://www.elastic.co/subscriptions[the subscription page].
[float]
[[create-connectors]]
=== Connectors
=== Preconfigured connectors and action types
You can create connectors for actions in <<managing-alerts-and-actions, Alerts and Actions>> or via the action API.
For out-of-the-box and standardized connectors, you can <<pre-configured-connectors, preconfigure connectors>>
before {kib} starts.
Action type with only preconfigured connectors could be specified as a <<pre-configured-action-types, preconfigured action type>>.
include::action-types/email.asciidoc[]
include::action-types/index.asciidoc[]
include::action-types/pagerduty.asciidoc[]
@ -54,3 +56,4 @@ include::action-types/server-log.asciidoc[]
include::action-types/slack.asciidoc[]
include::action-types/webhook.asciidoc[]
include::pre-configured-connectors.asciidoc[]
include::pre-configured-action-types.asciidoc[]

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

View file

@ -0,0 +1,61 @@
[role="xpack"]
[[pre-configured-action-types]]
== Preconfigured action types
A preconfigure an action type has all the information it needs prior to startup.
A preconfigured action type offers the following capabilities:
- Requires no setup. Configuration and credentials needed to execute an
action are predefined.
- Has only <<pre-configured-connectors, preconfigured connectors>>.
- Connectors of the preconfigured action type cannot be edited or deleted.
[float]
[[preconfigured-action-type-example]]
=== Creating a preconfigured action
In the `kibana.yml` file:
. Exclude the action type from `xpack.actions.enabledActionTypes`.
. Add all its connectors.
The following example shows a valid configuration of preconfigured action type with one out-of-the box connector.
```js
xpack.actions.enabledActionTypes: ['.slack', '.email', '.index'] <1>
xpack.actions.preconfigured: <2>
- id: 'my-server-log'
actionTypeId: .server-log
name: 'Server log #xyz'
```
<1> `enabledActionTypes` should exclude preconfigured action type to prevent creating and deleting connectors.
<2> `preconfigured` is the setting for defining the list of available connectors for the preconfigured action type.
[float]
[[pre-configured-action-type-alert-form]]
=== Attaching a preconfigured action to an alert
To attach an action to an alert,
select from a list of available action types, and
then select the *Server log* type. This action type was configured previously.
[role="screenshot"]
image::images/pre-configured-action-type-alert-form.png[Create alert with selected Server log action type]
[float]
[[managing-pre-configured-action-types]]
=== Managing preconfigured actions
Connectors with preconfigured actions appear in the connector list, regardless of which space the user is in.
They are tagged as “preconfigured” and cannot be deleted.
[role="screenshot"]
image::images/pre-configured-action-type-managing.png[Connectors managing tab with pre-cofigured]
Clicking *Create connector* shows the list of available action types.
Preconfigured action types are not included because you can't create a connector with a preconfigured action type.
[role="screenshot"]
image::images/pre-configured-action-type-select-type.png[Pre-configured connector create menu]

View file

@ -14,6 +14,7 @@ const createActionTypeRegistryMock = () => {
list: jest.fn(),
ensureActionTypeEnabled: jest.fn(),
isActionTypeEnabled: jest.fn(),
isActionExecutable: jest.fn(),
};
return mocked;
};

View file

@ -28,6 +28,16 @@ beforeEach(() => {
),
actionsConfigUtils: mockedActionsConfig,
licenseState: mockedLicenseState,
preconfiguredActions: [
{
actionTypeId: 'foo',
config: {},
id: 'my-slack1',
name: 'Slack #xyz',
secrets: {},
isPreconfigured: true,
},
],
};
});
@ -194,6 +204,19 @@ describe('isActionTypeEnabled', () => {
expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo');
});
test('should call isActionExecutable of the actions config', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionExecutable('my-slack1', 'foo');
expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo');
});
test('should return true when isActionTypeEnabled is false and isLicenseValidForActionType is true and it has preconfigured connectors', async () => {
mockedActionsConfig.isActionTypeEnabled.mockReturnValue(false);
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
expect(actionTypeRegistry.isActionExecutable('my-slack1', 'foo')).toEqual(true);
});
test('should call isLicenseValidForActionType of the license state', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionTypeEnabled('foo');

View file

@ -8,7 +8,7 @@ import Boom from 'boom';
import { i18n } from '@kbn/i18n';
import { RunContext, TaskManagerSetupContract } from '../../task_manager/server';
import { ExecutorError, TaskRunnerFactory, ILicenseState } from './lib';
import { ActionType } from './types';
import { ActionType, PreConfiguredAction } from './types';
import { ActionType as CommonActionType } from '../common';
import { ActionsConfigurationUtilities } from './actions_config';
@ -17,6 +17,7 @@ export interface ActionTypeRegistryOpts {
taskRunnerFactory: TaskRunnerFactory;
actionsConfigUtils: ActionsConfigurationUtilities;
licenseState: ILicenseState;
preconfiguredActions: PreConfiguredAction[];
}
export class ActionTypeRegistry {
@ -25,12 +26,14 @@ export class ActionTypeRegistry {
private readonly taskRunnerFactory: TaskRunnerFactory;
private readonly actionsConfigUtils: ActionsConfigurationUtilities;
private readonly licenseState: ILicenseState;
private readonly preconfiguredActions: PreConfiguredAction[];
constructor(constructorParams: ActionTypeRegistryOpts) {
this.taskManager = constructorParams.taskManager;
this.taskRunnerFactory = constructorParams.taskRunnerFactory;
this.actionsConfigUtils = constructorParams.actionsConfigUtils;
this.licenseState = constructorParams.licenseState;
this.preconfiguredActions = constructorParams.preconfiguredActions;
}
/**
@ -58,6 +61,19 @@ export class ActionTypeRegistry {
);
}
/**
* Returns true if action type is enabled or it is a preconfigured action type.
*/
public isActionExecutable(actionId: string, actionTypeId: string) {
return (
this.isActionTypeEnabled(actionTypeId) ||
(!this.isActionTypeEnabled(actionTypeId) &&
this.preconfiguredActions.find(
preconfiguredAction => preconfiguredAction.id === actionId
) !== undefined)
);
}
/**
* Registers an action type to the action type registry
*/

View file

@ -44,6 +44,7 @@ beforeEach(() => {
),
actionsConfigUtils: actionsConfigMock.create(),
licenseState: mockedLicenseState,
preconfiguredActions: [],
};
actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionsClient = new ActionsClient({
@ -221,6 +222,7 @@ describe('create()', () => {
),
actionsConfigUtils: localConfigUtils,
licenseState: licenseStateMock.create(),
preconfiguredActions: [],
};
actionTypeRegistry = new ActionTypeRegistry(localActionTypeRegistryParams);

View file

@ -27,6 +27,7 @@ export function createActionTypeRegistry(): {
),
actionsConfigUtils: actionsConfigMock.create(),
licenseState: licenseStateMock.create(),
preconfiguredActions: [],
});
registerBuiltInActionTypes({
logger,

View file

@ -282,4 +282,65 @@ describe('execute()', () => {
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
});
test('should skip ensure action type if action type is preconfigured and license is valid', async () => {
const mockedActionTypeRegistry = actionTypeRegistryMock.create();
const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient);
const executeFn = createExecuteFunction({
getBasePath,
taskManager: mockTaskManager,
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey: false,
actionTypeRegistry: mockedActionTypeRegistry,
preconfiguredActions: [
{
actionTypeId: 'mock-action',
config: {},
id: 'my-slack1',
name: 'Slack #xyz',
secrets: {},
isPreconfigured: true,
},
],
});
mockedActionTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => {
throw new Error('Fail');
});
mockedActionTypeRegistry.isActionExecutable.mockImplementation(() => true);
savedObjectsClient.get.mockResolvedValueOnce({
id: '123',
type: 'action',
attributes: {
actionTypeId: 'mock-action',
},
references: [],
});
savedObjectsClient.create.mockResolvedValueOnce({
id: '234',
type: 'action_task_params',
attributes: {},
references: [],
});
await executeFn({
id: '123',
params: { baz: false },
spaceId: 'default',
apiKey: null,
});
expect(getScopedSavedObjectsClient).toHaveBeenCalledWith({
getBasePath: expect.anything(),
headers: {},
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
});
});
});

View file

@ -70,7 +70,9 @@ export function createExecuteFunction({
const savedObjectsClient = getScopedSavedObjectsClient(fakeRequest as KibanaRequest);
const actionTypeId = await getActionTypeId(id);
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
if (!actionTypeRegistry.isActionExecutable(id, actionTypeId)) {
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
}
const actionTaskParamsRecord = await savedObjectsClient.create('action_task_params', {
actionId: id,

View file

@ -224,6 +224,50 @@ test('throws an error if actionType is not enabled', async () => {
expect(actionTypeRegistry.ensureActionTypeEnabled).toHaveBeenCalledWith('test');
});
test('should not throws an error if actionType is preconfigured', async () => {
const actionType: jest.Mocked<ActionType> = {
id: 'test',
name: 'Test',
minimumLicenseRequired: 'basic',
executor: jest.fn(),
};
const actionSavedObject = {
id: '1',
type: 'action',
attributes: {
actionTypeId: 'test',
config: {
bar: true,
},
secrets: {
baz: true,
},
},
references: [],
};
savedObjectsClient.get.mockResolvedValueOnce(actionSavedObject);
encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject);
actionTypeRegistry.get.mockReturnValueOnce(actionType);
actionTypeRegistry.ensureActionTypeEnabled.mockImplementationOnce(() => {
throw new Error('not enabled for test');
});
actionTypeRegistry.isActionExecutable.mockImplementationOnce(() => true);
await actionExecutor.execute(executeParams);
expect(actionTypeRegistry.ensureActionTypeEnabled).toHaveBeenCalledTimes(0);
expect(actionType.executor).toHaveBeenCalledWith({
actionId: '1',
services: expect.anything(),
config: {
bar: true,
},
secrets: {
baz: true,
},
params: { foo: true },
});
});
test('throws an error when passing isESOUsingEphemeralEncryptionKey with value of true', async () => {
const customActionExecutor = new ActionExecutor({ isESOUsingEphemeralEncryptionKey: true });
customActionExecutor.initialize({

View file

@ -90,7 +90,9 @@ export class ActionExecutor {
namespace.namespace
);
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
if (!actionTypeRegistry.isActionExecutable(actionId, actionTypeId)) {
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
}
const actionType = actionTypeRegistry.get(actionTypeId);
let validatedParams: Record<string, unknown>;

View file

@ -20,6 +20,7 @@ const createStartMock = () => {
const mock: jest.Mocked<PluginStartContract> = {
execute: jest.fn(),
isActionTypeEnabled: jest.fn(),
isActionExecutable: jest.fn(),
getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClientMock.create()),
preconfiguredActions: [],
};

View file

@ -65,6 +65,7 @@ export interface PluginSetupContract {
export interface PluginStartContract {
isActionTypeEnabled(id: string): boolean;
isActionExecutable(actionId: string, actionTypeId: string): boolean;
execute(options: ExecuteOptions): Promise<void>;
getActionsClientWithRequest(request: KibanaRequest): Promise<PublicMethodsOf<ActionsClient>>;
preconfiguredActions: PreConfiguredAction[];
@ -170,6 +171,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
taskManager: plugins.taskManager,
actionsConfigUtils,
licenseState: this.licenseState,
preconfiguredActions: this.preconfiguredActions,
});
this.taskRunnerFactory = taskRunnerFactory;
this.actionTypeRegistry = actionTypeRegistry;
@ -271,6 +273,9 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
isActionTypeEnabled: id => {
return this.actionTypeRegistry!.isActionTypeEnabled(id);
},
isActionExecutable: (actionId: string, actionTypeId: string) => {
return this.actionTypeRegistry!.isActionExecutable(actionId, actionTypeId);
},
// Ability to get an actions client from legacy code
async getActionsClientWithRequest(request: KibanaRequest) {
if (isESOUsingEphemeralEncryptionKey === true) {

View file

@ -51,6 +51,7 @@ const createExecutionHandlerParams = {
beforeEach(() => {
jest.resetAllMocks();
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
createExecutionHandlerParams.actionsPlugin.isActionExecutable.mockReturnValue(true);
});
test('calls actionsPlugin.execute per selected action', async () => {
@ -111,6 +112,7 @@ test('calls actionsPlugin.execute per selected action', async () => {
test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => {
// Mock two calls, one for check against actions[0] and the second for actions[1]
createExecutionHandlerParams.actionsPlugin.isActionExecutable.mockReturnValueOnce(false);
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValueOnce(false);
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValueOnce(true);
const executionHandler = createExecutionHandler({
@ -148,6 +150,50 @@ test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () =>
});
});
test('trow error error message when action type is disabled', async () => {
createExecutionHandlerParams.actionsPlugin.preconfiguredActions = [];
createExecutionHandlerParams.actionsPlugin.isActionExecutable.mockReturnValue(false);
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(false);
const executionHandler = createExecutionHandler({
...createExecutionHandlerParams,
actions: [
...createExecutionHandlerParams.actions,
{
id: '2',
group: 'default',
actionTypeId: '.slack',
params: {
foo: true,
contextVal: 'My other {{context.value}} goes here',
stateVal: 'My other {{state.value}} goes here',
},
},
],
});
await executionHandler({
actionGroup: 'default',
state: {},
context: {},
alertInstanceId: '2',
});
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(0);
createExecutionHandlerParams.actionsPlugin.isActionExecutable.mockImplementation(() => true);
const executionHandlerForPreconfiguredAction = createExecutionHandler({
...createExecutionHandlerParams,
actions: [...createExecutionHandlerParams.actions],
});
await executionHandlerForPreconfiguredAction({
actionGroup: 'default',
state: {},
context: {},
alertInstanceId: '2',
});
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
});
test('limits actionsPlugin.execute per action group', async () => {
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
await executionHandler({

View file

@ -71,7 +71,7 @@ export function createExecutionHandler({
const alertLabel = `${alertType.id}:${alertId}: '${alertName}'`;
for (const action of actions) {
if (!actionsPlugin.isActionTypeEnabled(action.actionTypeId)) {
if (!actionsPlugin.isActionExecutable(action.id, action.actionTypeId)) {
logger.warn(
`Alert "${alertId}" skipped scheduling action "${action.id}" because it is disabled`
);

View file

@ -185,6 +185,7 @@ describe('Task Runner', () => {
test('actionsPlugin.execute is called per alert instance that is scheduled', async () => {
taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true);
alertType.executor.mockImplementation(
({ services: executorServices }: AlertExecutorOptions) => {
executorServices.alertInstanceFactory('1').scheduleActions('default');

View file

@ -15943,7 +15943,6 @@
"xpack.triggersActionsUI.sections.alertForm.loadingActionTypesDescription": "アクションタイプを読み込み中...",
"xpack.triggersActionsUI.sections.alertForm.renotifyFieldLabel": "通知間隔",
"xpack.triggersActionsUI.sections.alertForm.renotifyWithTooltip": "アラートがアクティブな間にアクションを繰り返す頻度を定義します。",
"xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle": "{actionConnectorName}",
"xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle": "アクション:アクションタイプを選択してください",
"xpack.triggersActionsUI.sections.alertForm.selectAlertTypeTitle": "トリガータイプを選択してください",
"xpack.triggersActionsUI.sections.alertForm.selectedAlertTypeTitle": "{alertType}",

View file

@ -15948,7 +15948,6 @@
"xpack.triggersActionsUI.sections.alertForm.loadingActionTypesDescription": "正在加载操作类型……",
"xpack.triggersActionsUI.sections.alertForm.renotifyFieldLabel": "通知频率",
"xpack.triggersActionsUI.sections.alertForm.renotifyWithTooltip": "定义告警处于活动状态时重复操作的频率。",
"xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle": "{actionConnectorName}",
"xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle": "操作:选择操作类型",
"xpack.triggersActionsUI.sections.alertForm.selectAlertTypeTitle": "选择触发器类型",
"xpack.triggersActionsUI.sections.alertForm.selectedAlertTypeTitle": "{alertType}",

View file

@ -33,11 +33,20 @@ test('should sort enabled action types first', async () => {
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '4',
minimumLicenseRequired: 'basic',
name: 'x-fourth',
enabled: true,
enabledInConfig: false,
enabledInLicense: true,
},
];
const result = [...actionTypes].sort(actionTypeCompare);
expect(result[0]).toEqual(actionTypes[0]);
expect(result[1]).toEqual(actionTypes[2]);
expect(result[2]).toEqual(actionTypes[1]);
expect(result[2]).toEqual(actionTypes[3]);
expect(result[3]).toEqual(actionTypes[1]);
});
test('should sort by name when all enabled', async () => {
@ -66,9 +75,18 @@ test('should sort by name when all enabled', async () => {
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '4',
minimumLicenseRequired: 'basic',
name: 'x-fourth',
enabled: true,
enabledInConfig: false,
enabledInLicense: true,
},
];
const result = [...actionTypes].sort(actionTypeCompare);
expect(result[0]).toEqual(actionTypes[1]);
expect(result[1]).toEqual(actionTypes[2]);
expect(result[2]).toEqual(actionTypes[0]);
expect(result[3]).toEqual(actionTypes[3]);
});

View file

@ -4,14 +4,35 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ActionType } from '../../types';
import { ActionType, ActionConnector } from '../../types';
export function actionTypeCompare(a: ActionType, b: ActionType) {
if (a.enabled === true && b.enabled === false) {
export function actionTypeCompare(
a: ActionType,
b: ActionType,
preconfiguredConnectors?: ActionConnector[]
) {
const aEnabled = getIsEnabledValue(a, preconfiguredConnectors);
const bEnabled = getIsEnabledValue(b, preconfiguredConnectors);
if (aEnabled === true && bEnabled === false) {
return -1;
}
if (a.enabled === false && b.enabled === true) {
if (aEnabled === false && bEnabled === true) {
return 1;
}
return a.name.localeCompare(b.name);
}
const getIsEnabledValue = (actionType: ActionType, preconfiguredConnectors?: ActionConnector[]) => {
let isEnabled = actionType.enabled;
if (
!actionType.enabledInConfig &&
preconfiguredConnectors &&
preconfiguredConnectors.length > 0
) {
isEnabled =
preconfiguredConnectors.find(connector => connector.actionTypeId === actionType.id) !==
undefined;
}
return isEnabled;
};

View file

@ -4,43 +4,47 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ActionType } from '../../types';
import { checkActionTypeEnabled } from './check_action_type_enabled';
import { ActionType, ActionConnector } from '../../types';
import {
checkActionTypeEnabled,
checkActionFormActionTypeEnabled,
} from './check_action_type_enabled';
test(`returns isEnabled:true when action type isn't provided`, async () => {
expect(checkActionTypeEnabled()).toMatchInlineSnapshot(`
describe('checkActionTypeEnabled', () => {
test(`returns isEnabled:true when action type isn't provided`, async () => {
expect(checkActionTypeEnabled()).toMatchInlineSnapshot(`
Object {
"isEnabled": true,
}
`);
});
});
test('returns isEnabled:true when action type is enabled', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
};
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
test('returns isEnabled:true when action type is enabled', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
};
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
Object {
"isEnabled": true,
}
`);
});
});
test('returns isEnabled:false when action type is disabled by license', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: false,
enabledInConfig: true,
enabledInLicense: false,
};
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
test('returns isEnabled:false when action type is disabled by license', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: false,
enabledInConfig: true,
enabledInLicense: false,
};
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
Object {
"isEnabled": false,
"message": "This connector requires a Basic license.",
@ -63,18 +67,18 @@ test('returns isEnabled:false when action type is disabled by license', async ()
</EuiCard>,
}
`);
});
});
test('returns isEnabled:false when action type is disabled by config', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: false,
enabledInConfig: false,
enabledInLicense: true,
};
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
test('returns isEnabled:false when action type is disabled by config', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: false,
enabledInConfig: false,
enabledInLicense: true,
};
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
Object {
"isEnabled": false,
"message": "This connector is disabled by the Kibana configuration.",
@ -85,4 +89,69 @@ test('returns isEnabled:false when action type is disabled by config', async ()
/>,
}
`);
});
});
describe('checkActionFormActionTypeEnabled', () => {
const preconfiguredConnectors: ActionConnector[] = [
{
actionTypeId: '1',
config: {},
id: 'test1',
isPreconfigured: true,
name: 'test',
secrets: {},
referencedByCount: 0,
},
{
actionTypeId: '2',
config: {},
id: 'test2',
isPreconfigured: true,
name: 'test',
secrets: {},
referencedByCount: 0,
},
];
test('returns isEnabled:true when action type is preconfigured', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: true,
enabledInConfig: false,
enabledInLicense: true,
};
expect(checkActionFormActionTypeEnabled(actionType, preconfiguredConnectors))
.toMatchInlineSnapshot(`
Object {
"isEnabled": true,
}
`);
});
test('returns isEnabled:false when action type is disabled by config and not preconfigured', async () => {
const actionType: ActionType = {
id: 'disabled-by-config',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: true,
enabledInConfig: false,
enabledInLicense: true,
};
expect(checkActionFormActionTypeEnabled(actionType, preconfiguredConnectors))
.toMatchInlineSnapshot(`
Object {
"isEnabled": false,
"message": "This connector is disabled by the Kibana configuration.",
"messageCard": <EuiCard
className="actCheckActionTypeEnabled__disabledActionWarningCard"
description=""
title="This feature is disabled by the Kibana configuration."
/>,
}
`);
});
});

View file

@ -9,7 +9,7 @@ import { capitalize } from 'lodash';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiCard, EuiLink } from '@elastic/eui';
import { ActionType } from '../../types';
import { ActionType, ActionConnector } from '../../types';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../common/constants';
import './check_action_type_enabled.scss';
@ -22,71 +22,98 @@ export interface IsDisabledResult {
messageCard: JSX.Element;
}
const getLicenseCheckResult = (actionType: ActionType) => {
return {
isEnabled: false,
message: i18n.translate(
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage',
{
defaultMessage: 'This connector requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: capitalize(actionType.minimumLicenseRequired),
},
}
),
messageCard: (
<EuiCard
titleSize="xs"
title={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseMessageTitle',
{
defaultMessage: 'This feature requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: capitalize(actionType.minimumLicenseRequired),
},
}
)}
// The "re-enable" terminology is used here because this message is used when an alert
// action was previously enabled and needs action to be re-enabled.
description={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseMessageDescription',
{ defaultMessage: 'To re-enable this action, please upgrade your license.' }
)}
className="actCheckActionTypeEnabled__disabledActionWarningCard"
children={
<EuiLink href={VIEW_LICENSE_OPTIONS_LINK} target="_blank">
<FormattedMessage
defaultMessage="View license options"
id="xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseLinkTitle"
/>
</EuiLink>
}
/>
),
};
};
const configurationCheckResult = {
isEnabled: false,
message: i18n.translate(
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage',
{ defaultMessage: 'This connector is disabled by the Kibana configuration.' }
),
messageCard: (
<EuiCard
title={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByConfigMessageTitle',
{ defaultMessage: 'This feature is disabled by the Kibana configuration.' }
)}
description=""
className="actCheckActionTypeEnabled__disabledActionWarningCard"
/>
),
};
export function checkActionTypeEnabled(
actionType?: ActionType
): IsEnabledResult | IsDisabledResult {
if (actionType?.enabledInLicense === false) {
return {
isEnabled: false,
message: i18n.translate(
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage',
{
defaultMessage: 'This connector requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: capitalize(actionType.minimumLicenseRequired),
},
}
),
messageCard: (
<EuiCard
titleSize="xs"
title={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseMessageTitle',
{
defaultMessage: 'This feature requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: capitalize(actionType.minimumLicenseRequired),
},
}
)}
// The "re-enable" terminology is used here because this message is used when an alert
// action was previously enabled and needs action to be re-enabled.
description={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseMessageDescription',
{ defaultMessage: 'To re-enable this action, please upgrade your license.' }
)}
className="actCheckActionTypeEnabled__disabledActionWarningCard"
children={
<EuiLink href={VIEW_LICENSE_OPTIONS_LINK} target="_blank">
<FormattedMessage
defaultMessage="View license options"
id="xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseLinkTitle"
/>
</EuiLink>
}
/>
),
};
return getLicenseCheckResult(actionType);
}
if (actionType?.enabledInConfig === false) {
return {
isEnabled: false,
message: i18n.translate(
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage',
{ defaultMessage: 'This connector is disabled by the Kibana configuration.' }
),
messageCard: (
<EuiCard
title={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByConfigMessageTitle',
{ defaultMessage: 'This feature is disabled by the Kibana configuration.' }
)}
description=""
className="actCheckActionTypeEnabled__disabledActionWarningCard"
/>
),
};
return configurationCheckResult;
}
return { isEnabled: true };
}
export function checkActionFormActionTypeEnabled(
actionType: ActionType,
preconfiguredConnectors: ActionConnector[]
): IsEnabledResult | IsDisabledResult {
if (actionType?.enabledInLicense === false) {
return getLicenseCheckResult(actionType);
}
if (
actionType?.enabledInConfig === false &&
// do not disable action type if it contains preconfigured connectors (is preconfigured)
!preconfiguredConnectors.find(
preconfiguredConnector => preconfiguredConnector.actionTypeId === actionType.id
)
) {
return configurationCheckResult;
}
return { isEnabled: true };

View file

@ -73,6 +73,21 @@ describe('action_form', () => {
actionParamsFields: null,
};
const preconfiguredOnly = {
id: 'preconfigured',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
describe('action_form in alert', () => {
let wrapper: ReactWrapper<any>;
@ -95,6 +110,22 @@ describe('action_form', () => {
config: {},
isPreconfigured: true,
},
{
secrets: {},
id: 'test3',
actionTypeId: preconfiguredOnly.id,
name: 'Preconfigured Only',
config: {},
isPreconfigured: true,
},
{
secrets: {},
id: 'test4',
actionTypeId: preconfiguredOnly.id,
name: 'Regular connector',
config: {},
isPreconfigured: false,
},
]);
const mockes = coreMock.createSetup();
deps = {
@ -106,6 +137,7 @@ describe('action_form', () => {
actionType,
disabledByConfigActionType,
disabledByLicenseActionType,
preconfiguredOnly,
]);
actionTypeRegistry.has.mockReturnValue(true);
actionTypeRegistry.get.mockReturnValue(actionType);
@ -166,6 +198,14 @@ describe('action_form', () => {
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
{
id: 'preconfigured',
name: 'Preconfigured only',
enabled: true,
enabledInConfig: false,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
{
id: 'disabled-by-config',
name: 'Disabled by config',
@ -207,21 +247,27 @@ describe('action_form', () => {
).toBeFalsy();
});
it(`doesn't render action types disabled by config`, async () => {
it('does not render action types disabled by config', async () => {
await setup();
const actionOption = wrapper.find(
`[data-test-subj="disabled-by-config-ActionTypeSelectOption"]`
'[data-test-subj="disabled-by-config-ActionTypeSelectOption"]'
);
expect(actionOption.exists()).toBeFalsy();
});
it(`renders available connectors for the selected action type`, async () => {
it('render action types which is preconfigured only (disabled by config and with preconfigured connectors)', async () => {
await setup();
const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]');
expect(actionOption.exists()).toBeTruthy();
});
it('renders available connectors for the selected action type', async () => {
await setup();
const actionOption = wrapper.find(
`[data-test-subj="${actionType.id}-ActionTypeSelectOption"]`
);
actionOption.first().simulate('click');
const combobox = wrapper.find(`[data-test-subj="selectActionConnector"]`);
const combobox = wrapper.find(`[data-test-subj="selectActionConnector-${actionType.id}"]`);
expect((combobox.first().props() as any).options).toMatchInlineSnapshot(`
Array [
Object {
@ -238,10 +284,37 @@ describe('action_form', () => {
`);
});
it('renders only preconfigured connectors for the selected preconfigured action type', async () => {
await setup();
const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]');
actionOption.first().simulate('click');
const combobox = wrapper.find('[data-test-subj="selectActionConnector-preconfigured"]');
expect((combobox.first().props() as any).options).toMatchInlineSnapshot(`
Array [
Object {
"id": "test3",
"key": "test3",
"label": "Preconfigured Only (preconfigured)",
},
]
`);
});
it('does not render "Add new" button for preconfigured only action type', async () => {
await setup();
const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]');
actionOption.first().simulate('click');
const preconfigPannel = wrapper.find('[data-test-subj="alertActionAccordion-default"]');
const addNewConnectorButton = preconfigPannel.find(
'[data-test-subj="addNewActionConnectorButton-preconfigured"]'
);
expect(addNewConnectorButton.exists()).toBeFalsy();
});
it('renders action types disabled by license', async () => {
await setup();
const actionOption = wrapper.find(
`[data-test-subj="disabled-by-license-ActionTypeSelectOption"]`
'[data-test-subj="disabled-by-license-ActionTypeSelectOption"]'
);
expect(actionOption.exists()).toBeTruthy();
expect(

View file

@ -29,7 +29,7 @@ import {
EuiText,
} from '@elastic/eui';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api';
import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api';
import {
IErrorObject,
ActionTypeModel,
@ -42,7 +42,7 @@ import { SectionLoading } from '../../components/section_loading';
import { ConnectorAddModal } from './connector_add_modal';
import { TypeRegistry } from '../../type_registry';
import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled';
import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
interface ActionAccordionFormProps {
@ -111,14 +111,12 @@ export const ActionForm = ({
setHasActionsDisabled(hasActionsDisabled);
}
} catch (e) {
if (toastNotifications) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage',
{ defaultMessage: 'Unable to load action types' }
),
});
}
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage',
{ defaultMessage: 'Unable to load action types' }
),
});
} finally {
setIsLoadingActionTypes(false);
}
@ -126,41 +124,50 @@ export const ActionForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// load connectors
useEffect(() => {
loadConnectors();
(async () => {
try {
setIsLoadingConnectors(true);
setConnectors(await loadConnectors({ http }));
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage',
{
defaultMessage: 'Unable to load connectors',
}
),
});
} finally {
setIsLoadingConnectors(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadConnectors() {
try {
setIsLoadingConnectors(true);
const actionsResponse = await loadAllActions({ http });
setConnectors(actionsResponse);
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage',
{
defaultMessage: 'Unable to load connectors',
}
),
});
} finally {
setIsLoadingConnectors(false);
}
}
const preconfiguredMessage = i18n.translate(
'xpack.triggersActionsUI.sections.actionForm.preconfiguredTitleMessage',
{
defaultMessage: '(preconfigured)',
}
);
const getSelectedOptions = (actionItemId: string) => {
const val = connectors.find(connector => connector.id === actionItemId);
if (!val) {
const selectedConnector = connectors.find(connector => connector.id === actionItemId);
if (
!selectedConnector ||
// if selected connector is not preconfigured and action type is for preconfiguration only,
// do not show regular connectors of this type
(actionTypesIndex &&
!actionTypesIndex[selectedConnector.actionTypeId].enabledInConfig &&
!selectedConnector.isPreconfigured)
) {
return [];
}
const optionTitle = `${val.name} ${val.isPreconfigured ? preconfiguredMessage : ''}`;
const optionTitle = `${selectedConnector.name} ${
selectedConnector.isPreconfigured ? preconfiguredMessage : ''
}`;
return [
{
label: optionTitle,
@ -179,8 +186,15 @@ export const ActionForm = ({
},
index: number
) => {
const actionType = actionTypesIndex![actionItem.actionTypeId];
const optionsList = connectors
.filter(connectorItem => connectorItem.actionTypeId === actionItem.actionTypeId)
.filter(
connectorItem =>
connectorItem.actionTypeId === actionItem.actionTypeId &&
// include only enabled by config connectors or preconfigured
(actionType.enabledInConfig || connectorItem.isPreconfigured)
)
.map(({ name, id, isPreconfigured }) => ({
label: `${name} ${isPreconfigured ? preconfiguredMessage : ''}`,
key: id,
@ -189,8 +203,9 @@ export const ActionForm = ({
const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId);
if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null;
const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields;
const checkEnabledResult = checkActionTypeEnabled(
actionTypesIndex && actionTypesIndex[actionConnector.actionTypeId]
const checkEnabledResult = checkActionFormActionTypeEnabled(
actionTypesIndex![actionConnector.actionTypeId],
connectors.filter(connector => connector.isPreconfigured)
);
const accordionContent = checkEnabledResult.isEnabled ? (
@ -211,19 +226,21 @@ export const ActionForm = ({
/>
}
labelAppend={
<EuiButtonEmpty
size="xs"
data-test-subj="createActionConnectorButton"
onClick={() => {
setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index });
setAddModalVisibility(true);
}}
>
<FormattedMessage
defaultMessage="Add new"
id="xpack.triggersActionsUI.sections.alertForm.addNewConnectorEmptyButton"
/>
</EuiButtonEmpty>
actionTypesIndex![actionConnector.actionTypeId].enabledInConfig ? (
<EuiButtonEmpty
size="xs"
data-test-subj={`addNewActionConnectorButton-${actionItem.actionTypeId}`}
onClick={() => {
setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index });
setAddModalVisibility(true);
}}
>
<FormattedMessage
defaultMessage="Add new"
id="xpack.triggersActionsUI.sections.alertForm.addNewConnectorEmptyButton"
/>
</EuiButtonEmpty>
) : null
}
>
<EuiComboBox
@ -231,7 +248,7 @@ export const ActionForm = ({
singleSelection={{ asPlainText: true }}
options={optionsList}
id={`selectActionConnector-${actionItem.id}`}
data-test-subj="selectActionConnector"
data-test-subj={`selectActionConnector-${actionItem.actionTypeId}`}
selectedOptions={getSelectedOptions(actionItem.id)}
onChange={selectedOptions => {
setActionIdByIndex(selectedOptions[0].id ?? '', index);
@ -258,10 +275,9 @@ export const ActionForm = ({
);
return (
<Fragment>
<Fragment key={index}>
<EuiAccordion
initialIsOpen={true}
key={index}
id={index.toString()}
className="actAccordionActionForm"
buttonContentClassName="actAccordionActionForm__button"
@ -273,12 +289,12 @@ export const ActionForm = ({
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<p>
<div>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<FormattedMessage
defaultMessage="{actionConnectorName}"
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle"
id="xpack.triggersActionsUI.sections.alertForm.existingAlertActionTypeEditTitle"
values={{
actionConnectorName: `${actionConnector.name} ${
actionConnector.isPreconfigured ? preconfiguredMessage : ''
@ -304,7 +320,7 @@ export const ActionForm = ({
)}
</EuiFlexItem>
</EuiFlexGroup>
</p>
</div>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
@ -349,10 +365,9 @@ export const ActionForm = ({
const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId);
if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null;
return (
<Fragment>
<Fragment key={index}>
<EuiAccordion
initialIsOpen={true}
key={index}
id={index.toString()}
className="actAccordionActionForm"
buttonContentClassName="actAccordionActionForm__button"
@ -364,15 +379,15 @@ export const ActionForm = ({
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<p>
<div>
<FormattedMessage
defaultMessage="{actionConnectorName}"
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle"
id="xpack.triggersActionsUI.sections.alertForm.newAlertActionTypeEditTitle"
values={{
actionConnectorName: actionTypeRegistered.actionTypeTitle,
}}
/>
</p>
</div>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
@ -486,18 +501,26 @@ export const ActionForm = ({
}
}
let actionTypeNodes: JSX.Element[] | null = null;
let actionTypeNodes: Array<JSX.Element | null> | null = null;
let hasDisabledByLicenseActionTypes = false;
if (actionTypesIndex) {
const preconfiguredConnectors = connectors.filter(connector => connector.isPreconfigured);
actionTypeNodes = actionTypeRegistry
.list()
.filter(
item => actionTypesIndex[item.id] && actionTypesIndex[item.id].enabledInConfig === true
.filter(item => actionTypesIndex[item.id])
.sort((a, b) =>
actionTypeCompare(actionTypesIndex[a.id], actionTypesIndex[b.id], preconfiguredConnectors)
)
.sort((a, b) => actionTypeCompare(actionTypesIndex[a.id], actionTypesIndex[b.id]))
.map(function(item, index) {
const actionType = actionTypesIndex[item.id];
const checkEnabledResult = checkActionTypeEnabled(actionTypesIndex[item.id]);
const checkEnabledResult = checkActionFormActionTypeEnabled(
actionTypesIndex[item.id],
preconfiguredConnectors
);
// if action type is not enabled in config and not preconfigured, it shouldn't be displayed
if (!actionType.enabledInConfig && !checkEnabledResult.isEnabled) {
return null;
}
if (!actionType.enabledInLicense) {
hasDisabledByLicenseActionTypes = true;
}

View file

@ -68,7 +68,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await nameInput.click();
await testSubjects.click('.slack-ActionTypeSelectOption');
await testSubjects.click('createActionConnectorButton');
await testSubjects.click('addNewActionConnectorButton-.slack');
const slackConnectorName = generateUniqueKey();
await testSubjects.setValue('nameInput', slackConnectorName);
await testSubjects.setValue('slackWebhookUrlInput', 'https://test');