Fixed merge conflicts (#55093)

This commit is contained in:
Yuliia Naumenko 2020-01-16 12:48:18 -08:00 committed by GitHub
parent 7301ebdee0
commit c7cab6c550
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
110 changed files with 11508 additions and 15 deletions

View file

@ -4,6 +4,7 @@
"xpack.actions": "legacy/plugins/actions",
"xpack.advancedUiActions": "plugins/advanced_ui_actions",
"xpack.alerting": "legacy/plugins/alerting",
"xpack.triggersActionsUI": "legacy/plugins/triggers_actions_ui",
"xpack.apm": "legacy/plugins/apm",
"xpack.beatsManagement": "legacy/plugins/beats_management",
"xpack.canvas": "legacy/plugins/canvas",

View file

@ -42,6 +42,7 @@ import { transform } from './legacy/plugins/transform';
import { actions } from './legacy/plugins/actions';
import { alerting } from './legacy/plugins/alerting';
import { lens } from './legacy/plugins/lens';
import { triggersActionsUI } from './legacy/plugins/triggers_actions_ui';
module.exports = function(kibana) {
return [
@ -83,5 +84,6 @@ module.exports = function(kibana) {
snapshotRestore(kibana),
actions(kibana),
alerting(kibana),
triggersActionsUI(kibana),
];
};

View file

@ -49,7 +49,7 @@ beforeEach(() => {
describe('actionTypeRegistry.get() works', () => {
test('action type static data is as expected', () => {
expect(actionType.id).toEqual(ACTION_TYPE_ID);
expect(actionType.name).toEqual('email');
expect(actionType.name).toEqual('Email');
});
});

View file

@ -118,7 +118,9 @@ export function getActionType(params: GetActionTypeParams): ActionType {
const { logger, configurationUtilities } = params;
return {
id: '.email',
name: 'email',
name: i18n.translate('xpack.actions.builtin.emailTitle', {
defaultMessage: 'Email',
}),
validate: {
config: schema.object(ConfigSchemaProps, {
validate: curry(validateConfig)(configurationUtilities),

View file

@ -37,7 +37,7 @@ beforeEach(() => {
describe('actionTypeRegistry.get() works', () => {
test('action type static data is as expected', () => {
expect(actionType.id).toEqual(ACTION_TYPE_ID);
expect(actionType.name).toEqual('index');
expect(actionType.name).toEqual('Index');
});
});

View file

@ -38,7 +38,9 @@ const ParamsSchema = schema.object({
export function getActionType({ logger }: { logger: Logger }): ActionType {
return {
id: '.index',
name: 'index',
name: i18n.translate('xpack.actions.builtin.esIndexTitle', {
defaultMessage: 'Index',
}),
validate: {
config: ConfigSchema,
params: ParamsSchema,

View file

@ -38,7 +38,7 @@ beforeAll(() => {
describe('get()', () => {
test('should return correct action type', () => {
expect(actionType.id).toEqual(ACTION_TYPE_ID);
expect(actionType.name).toEqual('pagerduty');
expect(actionType.name).toEqual('PagerDuty');
});
});

View file

@ -96,7 +96,9 @@ export function getActionType({
}): ActionType {
return {
id: '.pagerduty',
name: 'pagerduty',
name: i18n.translate('xpack.actions.builtin.pagerdutyTitle', {
defaultMessage: 'PagerDuty',
}),
validate: {
config: schema.object(configSchemaProps, {
validate: curry(valdiateActionTypeConfig)(configurationUtilities),

View file

@ -25,7 +25,7 @@ beforeAll(() => {
describe('get()', () => {
test('returns action type', () => {
expect(actionType.id).toEqual(ACTION_TYPE_ID);
expect(actionType.name).toEqual('server-log');
expect(actionType.name).toEqual('Server log');
});
});
@ -98,6 +98,6 @@ describe('execute()', () => {
config: {},
secrets: {},
});
expect(mockedLogger.info).toHaveBeenCalledWith('server-log: message text here');
expect(mockedLogger.info).toHaveBeenCalledWith('Server log: message text here');
});
});

View file

@ -12,7 +12,7 @@ import { Logger } from '../../../../../../src/core/server';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
import { withoutControlCharacters } from './lib/string_utils';
const ACTION_NAME = 'server-log';
const ACTION_NAME = 'Server log';
// params definition

View file

@ -29,7 +29,7 @@ beforeAll(() => {
describe('action registeration', () => {
test('returns action type', () => {
expect(actionType.id).toEqual(ACTION_TYPE_ID);
expect(actionType.name).toEqual('slack');
expect(actionType.name).toEqual('Slack');
});
});

View file

@ -49,7 +49,9 @@ export function getActionType({
}): ActionType {
return {
id: '.slack',
name: 'slack',
name: i18n.translate('xpack.actions.builtin.slackTitle', {
defaultMessage: 'Slack',
}),
validate: {
secrets: schema.object(secretsSchemaProps, {
validate: curry(valdiateActionTypeConfig)(configurationUtilities),

View file

@ -25,7 +25,7 @@ beforeAll(() => {
describe('actionType', () => {
test('exposes the action as `webhook` on its Id and Name', () => {
expect(actionType.id).toEqual('.webhook');
expect(actionType.name).toEqual('webhook');
expect(actionType.name).toEqual('Webhook');
});
});

View file

@ -56,7 +56,9 @@ export function getActionType({
}): ActionType {
return {
id: '.webhook',
name: 'webhook',
name: i18n.translate('xpack.actions.builtin.webhookTitle', {
defaultMessage: 'Webhook',
}),
validate: {
config: schema.object(configSchemaProps, {
validate: curry(valdiateActionTypeConfig)(configurationUtilities),

View file

@ -60,7 +60,16 @@ export class Plugin {
],
read: ['config'],
},
ui: ['show', 'crud'],
ui: [
'show',
'crud',
'alerting:show',
'actions:show',
'alerting:save',
'actions:save',
'alerting:delete',
'actions:delete',
],
},
read: {
api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'],
@ -73,7 +82,15 @@ export class Plugin {
timelineSavedObjectType,
],
},
ui: ['show'],
ui: [
'show',
'alerting:show',
'actions:show',
'alerting:save',
'actions:save',
'alerting:delete',
'actions:delete',
],
},
},
});

View 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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Legacy } from 'kibana';
import { Root } from 'joi';
import { resolve } from 'path';
export function triggersActionsUI(kibana: any) {
return new kibana.Plugin({
id: 'triggers_actions_ui',
configPrefix: 'xpack.triggers_actions_ui',
isEnabled(config: Legacy.KibanaConfig) {
return (
config.get('xpack.triggers_actions_ui.enabled') &&
(config.get('xpack.actions.enabled') || config.get('xpack.alerting.enabled'))
);
},
publicDir: resolve(__dirname, 'public'),
require: ['kibana'],
config(Joi: Root) {
return Joi.object()
.keys({
enabled: Joi.boolean().default(false),
createAlertUiEnabled: Joi.boolean().default(false),
})
.default();
},
uiExports: {
home: ['plugins/triggers_actions_ui/hacks/register'],
managementSections: ['plugins/triggers_actions_ui/legacy'],
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
injectDefaultVars(server: Legacy.Server) {
const serverConfig = server.config();
return {
createAlertUiEnabled: serverConfig.get('xpack.triggers_actions_ui.createAlertUiEnabled'),
};
},
},
});
}

View file

@ -0,0 +1,6 @@
{
"id": "triggers_actions_ui",
"version": "kibana",
"server": false,
"ui": true
}

View 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;
* you may not use this file except in compliance with the Elastic License.
*/
import { ActionTypeRegistryContract } from '../types';
const createActionTypeRegistryMock = () => {
const mocked: jest.Mocked<ActionTypeRegistryContract> = {
has: jest.fn(x => true),
register: jest.fn(),
get: jest.fn(),
list: jest.fn(),
};
return mocked;
};
export const actionTypeRegistryMock = {
create: createActionTypeRegistryMock,
};

View 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;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertTypeRegistryContract } from '../types';
const createAlertTypeRegistryMock = () => {
const mocked: jest.Mocked<AlertTypeRegistryContract> = {
has: jest.fn(),
register: jest.fn(),
get: jest.fn(),
list: jest.fn(),
};
return mocked;
};
export const alertTypeRegistryMock = {
create: createAlertTypeRegistryMock,
};

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { Switch, Route, Redirect, HashRouter } from 'react-router-dom';
import {
ChromeStart,
DocLinksStart,
ToastsSetup,
HttpSetup,
IUiSettingsClient,
} from 'kibana/public';
import { BASE_PATH, Section } from './constants';
import { TriggersActionsUIHome } from './home';
import { AppContextProvider, useAppDependencies } from './app_context';
import { hasShowAlertsCapability } from './lib/capabilities';
import { LegacyDependencies, ActionTypeModel, AlertTypeModel } from '../types';
import { TypeRegistry } from './type_registry';
export interface AppDeps {
chrome: ChromeStart;
docLinks: DocLinksStart;
toastNotifications: ToastsSetup;
injectedMetadata: any;
http: HttpSetup;
uiSettings: IUiSettingsClient;
legacy: LegacyDependencies;
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
alertTypeRegistry: TypeRegistry<AlertTypeModel>;
}
export const App = (appDeps: AppDeps) => {
const sections: Section[] = ['alerts', 'connectors'];
const sectionsRegex = sections.join('|');
return (
<HashRouter>
<AppContextProvider appDeps={appDeps}>
<AppWithoutRouter sectionsRegex={sectionsRegex} />
</AppContextProvider>
</HashRouter>
);
};
export const AppWithoutRouter = ({ sectionsRegex }: any) => {
const {
legacy: { capabilities },
} = useAppDependencies();
const canShowAlerts = hasShowAlertsCapability(capabilities.get());
const DEFAULT_SECTION: Section = canShowAlerts ? 'alerts' : 'connectors';
return (
<Switch>
<Route
exact
path={`${BASE_PATH}/:section(${sectionsRegex})`}
component={TriggersActionsUIHome}
/>
<Redirect from={`${BASE_PATH}`} to={`${BASE_PATH}/${DEFAULT_SECTION}`} />
</Switch>
);
};

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { createContext, useContext } from 'react';
import { AppDeps } from './app';
const AppContext = createContext<AppDeps | null>(null);
export const AppContextProvider = ({
children,
appDeps,
}: {
appDeps: AppDeps | null;
children: React.ReactNode;
}) => {
return appDeps ? <AppContext.Provider value={appDeps}>{children}</AppContext.Provider> : null;
};
export const useAppDependencies = (): AppDeps => {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error(
'The app dependencies Context has not been set. Use the "setAppDependencies()" method when bootstrapping the app.'
);
}
return ctx;
};

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { SavedObjectsClientContract } from 'src/core/public';
import { App, AppDeps } from './app';
import { setSavedObjectsClient } from '../application/components/builtin_alert_types/threshold/lib/api';
import { LegacyDependencies } from '../types';
interface BootDeps extends AppDeps {
element: HTMLElement;
savedObjects: SavedObjectsClientContract;
I18nContext: any;
legacy: LegacyDependencies;
}
export const boot = (bootDeps: BootDeps) => {
const { I18nContext, element, legacy, savedObjects, ...appDeps } = bootDeps;
setSavedObjectsClient(savedObjects);
render(
<I18nContext>
<App {...appDeps} legacy={legacy} />
</I18nContext>,
element
);
return () => unmountComponentAtNode(element);
};

View file

@ -0,0 +1,228 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { TypeRegistry } from '../../type_registry';
import { registerBuiltInActionTypes } from './index';
import { ActionTypeModel, ActionConnector } from '../../../types';
const ACTION_TYPE_ID = '.email';
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);
expect(actionTypeModel.iconClass).toEqual('email');
});
});
describe('connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
const actionConnector = {
secrets: {
user: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: '.email',
name: 'email',
config: {
from: 'test@test.com',
port: '2323',
host: 'localhost',
test: 'test',
},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
from: [],
service: [],
port: [],
host: [],
user: [],
password: [],
},
});
delete actionConnector.config.test;
actionConnector.config.host = 'elastic.co';
actionConnector.config.port = 8080;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
from: [],
service: [],
port: [],
host: [],
user: [],
password: [],
},
});
delete actionConnector.config.host;
delete actionConnector.config.port;
actionConnector.config.service = 'testService';
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
from: [],
service: [],
port: [],
host: [],
user: [],
password: [],
},
});
});
test('connector validation fails when connector config is not valid', () => {
const actionConnector = {
secrets: {
user: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: '.email',
name: 'email',
config: {
from: 'test@test.com',
},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
from: [],
service: ['Service is required.'],
port: ['Port is required.'],
host: ['Host is required.'],
user: [],
password: [],
},
});
});
});
describe('action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
const actionParams = {
to: [],
cc: ['test1@test.com'],
message: 'message {test}',
subject: 'test',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
to: [],
cc: [],
bcc: [],
message: [],
subject: [],
},
});
});
test('action params validation fails when action params is not valid', () => {
const actionParams = {
to: ['test@test.com'],
subject: 'test',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
to: [],
cc: [],
bcc: [],
message: ['Message is required.'],
subject: [],
},
});
});
});
describe('EmailActionConnectorFields renders', () => {
test('all connector fields is rendered', () => {
expect(actionTypeModel.actionConnectorFields).not.toBeNull();
if (!actionTypeModel.actionConnectorFields) {
return;
}
const ConnectorFields = actionTypeModel.actionConnectorFields;
const actionConnector = {
secrets: {
user: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: '.email',
name: 'email',
config: {
from: 'test@test.com',
},
} as ActionConnector;
const wrapper = mountWithIntl(
<ConnectorFields
action={actionConnector}
errors={{ from: [], service: [], port: [], host: [], user: [], password: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="emailFromInput"]')
.first()
.prop('value')
).toBe('test@test.com');
expect(wrapper.find('[data-test-subj="emailHostInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="emailPortInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="emailUserInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="emailPasswordInput"]').length > 0).toBeTruthy();
});
});
describe('EmailParamsFields renders', () => {
test('all params fields is rendered', () => {
expect(actionTypeModel.actionParamsFields).not.toBeNull();
if (!actionTypeModel.actionParamsFields) {
return;
}
const ParamsFields = actionTypeModel.actionParamsFields;
const actionParams = {
to: ['test@test.com'],
subject: 'test',
message: 'test message',
};
const wrapper = mountWithIntl(
<ParamsFields
action={actionParams}
errors={{}}
editAction={() => {}}
index={0}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="toEmailAddressInput"]')
.first()
.prop('selectedOptions')
).toStrictEqual([{ label: 'test@test.com' }]);
expect(wrapper.find('[data-test-subj="ccEmailAddressInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="bccEmailAddressInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="emailSubjectInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="emailMessageInput"]').length > 0).toBeTruthy();
});
});

View file

@ -0,0 +1,545 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import {
EuiFieldText,
EuiFlexItem,
EuiFlexGroup,
EuiFieldNumber,
EuiFieldPassword,
EuiComboBox,
EuiTextArea,
EuiSwitch,
EuiFormRow,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
ActionTypeModel,
ActionConnectorFieldsProps,
ActionConnector,
ValidationResult,
ActionParamsProps,
} from '../../../types';
export function getActionType(): ActionTypeModel {
const mailformat = /^[^@\s]+@[^@\s]+$/;
return {
id: '.email',
iconClass: 'email',
selectMessage: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText',
{
defaultMessage: 'Send email from your server.',
}
),
validateConnector: (action: ActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
from: new Array<string>(),
service: new Array<string>(),
port: new Array<string>(),
host: new Array<string>(),
user: new Array<string>(),
password: new Array<string>(),
};
validationResult.errors = errors;
if (!action.config.from) {
errors.from.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText',
{
defaultMessage: 'Sender is required.',
}
)
);
}
if (action.config.from && !action.config.from.trim().match(mailformat)) {
errors.from.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText',
{
defaultMessage: 'Sender is not a valid email address.',
}
)
);
}
if (!action.config.port && !action.config.service) {
errors.port.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText',
{
defaultMessage: 'Port is required.',
}
)
);
}
if (!action.config.service && (!action.config.port || !action.config.host)) {
errors.service.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServiceText',
{
defaultMessage: 'Service is required.',
}
)
);
}
if (!action.config.host && !action.config.service) {
errors.host.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText',
{
defaultMessage: 'Host is required.',
}
)
);
}
if (!action.secrets.user) {
errors.user.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText',
{
defaultMessage: 'Username is required.',
}
)
);
}
if (!action.secrets.password) {
errors.password.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText',
{
defaultMessage: 'Password is required.',
}
)
);
}
return validationResult;
},
validateParams: (actionParams: any): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
to: new Array<string>(),
cc: new Array<string>(),
bcc: new Array<string>(),
message: new Array<string>(),
subject: new Array<string>(),
};
validationResult.errors = errors;
if (
(!(actionParams.to instanceof Array) || actionParams.to.length === 0) &&
(!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) &&
(!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0)
) {
const errorText = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText',
{
defaultMessage: 'No [to], [cc], or [bcc] entries. At least one entry is required.',
}
);
errors.to.push(errorText);
errors.cc.push(errorText);
errors.bcc.push(errorText);
}
if (!actionParams.message) {
errors.message.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText',
{
defaultMessage: 'Message is required.',
}
)
);
}
if (!actionParams.subject) {
errors.subject.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText',
{
defaultMessage: 'Subject is required.',
}
)
);
}
return validationResult;
},
actionConnectorFields: EmailActionConnectorFields,
actionParamsFields: EmailParamsFields,
};
}
const EmailActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
action,
editActionConfig,
editActionSecrets,
errors,
}) => {
const { from, host, port, secure } = action.config;
const { user, password } = action.secrets;
return (
<Fragment>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="from"
fullWidth
error={errors.from}
isInvalid={errors.from.length > 0 && from !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel',
{
defaultMessage: 'Sender',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={errors.from.length > 0 && from !== undefined}
name="from"
value={from || ''}
data-test-subj="emailFromInput"
onChange={e => {
editActionConfig('from', e.target.value);
}}
onBlur={() => {
if (!from) {
editActionConfig('from', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFormRow
id="emailHost"
fullWidth
error={errors.host}
isInvalid={errors.host.length > 0 && host !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel',
{
defaultMessage: 'Host',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={errors.host.length > 0 && host !== undefined}
name="host"
value={host || ''}
data-test-subj="emailHostInput"
onChange={e => {
editActionConfig('host', e.target.value);
}}
onBlur={() => {
if (!host) {
editActionConfig('host', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFormRow
id="emailPort"
fullWidth
placeholder="587"
error={errors.port}
isInvalid={errors.port.length > 0 && port !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel',
{
defaultMessage: 'Port',
}
)}
>
<EuiFieldNumber
prepend=":"
isInvalid={errors.port.length > 0 && port !== undefined}
fullWidth
name="port"
value={port || ''}
data-test-subj="emailPortInput"
onChange={e => {
editActionConfig('port', parseInt(e.target.value, 10));
}}
onBlur={() => {
if (!port) {
editActionConfig('port', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem>
<EuiFormRow hasEmptyLabelSpace>
<EuiSwitch
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.secureSwitchLabel',
{
defaultMessage: 'Secure',
}
)}
checked={secure || false}
onChange={e => {
editActionConfig('secure', e.target.checked);
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFormRow
id="emailUser"
fullWidth
error={errors.user}
isInvalid={errors.user.length > 0 && user !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel',
{
defaultMessage: 'Username',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={errors.user.length > 0 && user !== undefined}
name="user"
value={user || ''}
data-test-subj="emailUserInput"
onChange={e => {
editActionSecrets('user', e.target.value);
}}
onBlur={() => {
if (!user) {
editActionSecrets('user', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
id="emailPassword"
fullWidth
error={errors.password}
isInvalid={errors.password.length > 0 && password !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel',
{
defaultMessage: 'Password',
}
)}
>
<EuiFieldPassword
fullWidth
isInvalid={errors.password.length > 0 && password !== undefined}
name="password"
value={password || ''}
data-test-subj="emailPasswordInput"
onChange={e => {
editActionSecrets('password', e.target.value);
}}
onBlur={() => {
if (!password) {
editActionSecrets('password', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
};
const EmailParamsFields: React.FunctionComponent<ActionParamsProps> = ({
action,
editAction,
index,
errors,
hasErrors,
}) => {
const { to, cc, bcc, subject, message } = action;
const toOptions = to ? to.map((label: string) => ({ label })) : [];
const ccOptions = cc ? cc.map((label: string) => ({ label })) : [];
const bccOptions = bcc ? bcc.map((label: string) => ({ label })) : [];
return (
<Fragment>
<EuiFormRow
fullWidth
error={errors.to}
isInvalid={hasErrors && to !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel',
{
defaultMessage: 'To',
}
)}
>
<EuiComboBox
noSuggestions
isInvalid={hasErrors && to !== undefined}
fullWidth
data-test-subj="toEmailAddressInput"
selectedOptions={toOptions}
onCreateOption={(searchValue: string) => {
const newOptions = [...toOptions, { label: searchValue }];
editAction(
'to',
newOptions.map(newOption => newOption.label),
index
);
}}
onChange={(selectedOptions: Array<{ label: string }>) => {
editAction(
'to',
selectedOptions.map(selectedOption => selectedOption.label),
index
);
}}
onBlur={() => {
if (!to) {
editAction('to', [], index);
}
}}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
error={errors.cc}
isInvalid={hasErrors && cc !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel',
{
defaultMessage: 'Cc',
}
)}
>
<EuiComboBox
noSuggestions
isInvalid={hasErrors && cc !== undefined}
fullWidth
data-test-subj="ccEmailAddressInput"
selectedOptions={ccOptions}
onCreateOption={(searchValue: string) => {
const newOptions = [...ccOptions, { label: searchValue }];
editAction(
'cc',
newOptions.map(newOption => newOption.label),
index
);
}}
onChange={(selectedOptions: Array<{ label: string }>) => {
editAction(
'cc',
selectedOptions.map(selectedOption => selectedOption.label),
index
);
}}
onBlur={() => {
if (!cc) {
editAction('cc', [], index);
}
}}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
error={errors.bcc}
isInvalid={hasErrors && bcc !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel',
{
defaultMessage: 'Bcc',
}
)}
>
<EuiComboBox
noSuggestions
isInvalid={hasErrors && bcc !== undefined}
fullWidth
data-test-subj="bccEmailAddressInput"
selectedOptions={bccOptions}
onCreateOption={(searchValue: string) => {
const newOptions = [...bccOptions, { label: searchValue }];
editAction(
'bcc',
newOptions.map(newOption => newOption.label),
index
);
}}
onChange={(selectedOptions: Array<{ label: string }>) => {
editAction(
'bcc',
selectedOptions.map(selectedOption => selectedOption.label),
index
);
}}
onBlur={() => {
if (!bcc) {
editAction('bcc', [], index);
}
}}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
error={errors.subject}
isInvalid={hasErrors && message !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel',
{
defaultMessage: 'Subject',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={hasErrors && message !== undefined}
name="subject"
data-test-subj="emailSubjectInput"
value={subject || ''}
onChange={e => {
editAction('subject', e.target.value, index);
}}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
error={errors.message}
isInvalid={hasErrors && message !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.messageTextAreaFieldLabel',
{
defaultMessage: 'Message',
}
)}
>
<EuiTextArea
fullWidth
isInvalid={hasErrors && message !== undefined}
value={message || ''}
name="message"
data-test-subj="emailMessageInput"
onChange={e => {
editAction('message', e.target.value, index);
}}
/>
</EuiFormRow>
</Fragment>
);
};

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { TypeRegistry } from '../../type_registry';
import { registerBuiltInActionTypes } from './index';
import { ActionTypeModel, ActionConnector } from '../../../types';
const ACTION_TYPE_ID = '.index';
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 .index is registered', () => {
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
expect(actionTypeModel.iconClass).toEqual('indexOpen');
});
});
describe('index connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
const actionConnector = {
secrets: {},
id: 'test',
actionTypeId: '.index',
name: 'es_index',
config: {
index: 'test_es_index',
},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {},
});
delete actionConnector.config.index;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {},
});
});
});
describe('action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
const actionParams = {
index: 'test',
refresh: false,
executionTimeField: '1',
documents: ['test'],
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {},
});
const emptyActionParams = {};
expect(actionTypeModel.validateParams(emptyActionParams)).toEqual({
errors: {},
});
});
});
describe('IndexActionConnectorFields renders', () => {
test('all connector fields is rendered', () => {
expect(actionTypeModel.actionConnectorFields).not.toBeNull();
if (!actionTypeModel.actionConnectorFields) {
return;
}
const ConnectorFields = actionTypeModel.actionConnectorFields;
const actionConnector = {
secrets: {},
id: 'test',
actionTypeId: '.index',
name: 'es_index',
config: {
index: 'test',
},
} as ActionConnector;
const wrapper = mountWithIntl(
<ConnectorFields
action={actionConnector}
errors={{}}
editActionConfig={() => {}}
editActionSecrets={() => {}}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="indexInput"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="indexInput"]')
.first()
.prop('value')
).toBe('test');
});
});
describe('IndexParamsFields renders', () => {
test('all params fields is rendered', () => {
expect(actionTypeModel.actionParamsFields).not.toBeNull();
if (!actionTypeModel.actionParamsFields) {
return;
}
const ParamsFields = actionTypeModel.actionParamsFields;
const actionParams = {
index: 'test_index',
refresh: false,
documents: ['test'],
};
const wrapper = mountWithIntl(
<ParamsFields
action={actionParams}
errors={{}}
editAction={() => {}}
index={0}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="indexInput"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="indexInput"]')
.first()
.prop('value')
).toBe('test_index');
expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').length > 0).toBeTruthy();
});
});

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { EuiFieldText, EuiFormRow, EuiSwitch } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import {
ActionTypeModel,
ActionConnectorFieldsProps,
ValidationResult,
ActionParamsProps,
} from '../../../types';
export function getActionType(): ActionTypeModel {
return {
id: '.index',
iconClass: 'indexOpen',
selectMessage: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.selectMessageText',
{
defaultMessage: 'Index data into Elasticsearch.',
}
),
validateConnector: (): ValidationResult => {
return { errors: {} };
},
actionConnectorFields: IndexActionConnectorFields,
actionParamsFields: IndexParamsFields,
validateParams: (actionParams: any): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
};
}
const IndexActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
action,
editActionConfig,
}) => {
const { index } = action.config;
return (
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.indexTextFieldLabel',
{
defaultMessage: 'Index (optional)',
}
)}
>
<EuiFieldText
fullWidth
name="index"
data-test-subj="indexInput"
value={index || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editActionConfig('index', e.target.value);
}}
onBlur={() => {
if (!index) {
editActionConfig('index', '');
}
}}
/>
</EuiFormRow>
);
};
const IndexParamsFields: React.FunctionComponent<ActionParamsProps> = ({
action,
index,
editAction,
errors,
hasErrors,
}) => {
const { refresh } = action;
return (
<Fragment>
<EuiFormRow
fullWidth
error={errors.index}
isInvalid={hasErrors === true && action.index !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.indexFieldLabel',
{
defaultMessage: 'Index',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={hasErrors === true && action.index !== undefined}
name="index"
data-test-subj="indexInput"
value={action.index || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editAction('index', e.target.value, index);
}}
onBlur={() => {
if (!action.index) {
editAction('index', '', index);
}
}}
/>
</EuiFormRow>
<EuiSwitch
data-test-subj="indexRefreshCheckbox"
checked={refresh}
onChange={(e: any) => {
editAction('refresh', e.target.checked, index);
}}
label={
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.indexAction.refreshLabel"
defaultMessage="Refresh"
/>
}
/>
</Fragment>
);
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getActionType as getServerLogActionType } from './server_log';
import { getActionType as getSlackActionType } from './slack';
import { getActionType as getEmailActionType } from './email';
import { getActionType as getIndexActionType } from './es_index';
import { getActionType as getPagerDutyActionType } from './pagerduty';
import { getActionType as getWebhookActionType } from './webhook';
import { TypeRegistry } from '../../type_registry';
import { ActionTypeModel } from '../../../types';
export function registerBuiltInActionTypes({
actionTypeRegistry,
}: {
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
}) {
actionTypeRegistry.register(getServerLogActionType());
actionTypeRegistry.register(getSlackActionType());
actionTypeRegistry.register(getEmailActionType());
actionTypeRegistry.register(getIndexActionType());
actionTypeRegistry.register(getPagerDutyActionType());
actionTypeRegistry.register(getWebhookActionType());
}

View file

@ -0,0 +1,179 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { TypeRegistry } from '../../type_registry';
import { registerBuiltInActionTypes } from './index';
import { ActionTypeModel, ActionConnector } from '../../../types';
const ACTION_TYPE_ID = '.pagerduty';
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);
expect(actionTypeModel.iconClass).toEqual('apps');
});
});
describe('pagerduty connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
const actionConnector = {
secrets: {
routingKey: 'test',
},
id: 'test',
actionTypeId: '.pagerduty',
name: 'pagerduty',
config: {
apiUrl: 'http:\\test',
},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
routingKey: [],
},
});
delete actionConnector.config.apiUrl;
actionConnector.secrets.routingKey = 'test1';
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
routingKey: [],
},
});
});
test('connector validation fails when connector config is not valid', () => {
const actionConnector = {
secrets: {},
id: 'test',
actionTypeId: '.pagerduty',
name: 'pagerduty',
config: {
apiUrl: 'http:\\test',
},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
routingKey: ['A routing key is required.'],
},
});
});
});
describe('pagerduty action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
const actionParams = {
eventAction: 'trigger',
dedupKey: 'test',
summary: '2323',
source: 'source',
severity: 'critical',
timestamp: '234654564654',
component: 'test',
group: 'group',
class: 'test class',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {},
});
});
});
describe('PagerDutyActionConnectorFields renders', () => {
test('all connector fields is rendered', () => {
expect(actionTypeModel.actionConnectorFields).not.toBeNull();
if (!actionTypeModel.actionConnectorFields) {
return;
}
const ConnectorFields = actionTypeModel.actionConnectorFields;
const actionConnector = {
secrets: {
routingKey: 'test',
},
id: 'test',
actionTypeId: '.pagerduty',
name: 'pagerduty',
config: {
apiUrl: 'http:\\test',
},
} as ActionConnector;
const wrapper = mountWithIntl(
<ConnectorFields
action={actionConnector}
errors={{ routingKey: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="pagerdutyApiUrlInput"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="pagerdutyApiUrlInput"]')
.first()
.prop('value')
).toBe('http:\\test');
expect(wrapper.find('[data-test-subj="pagerdutyRoutingKeyInput"]').length > 0).toBeTruthy();
});
});
describe('PagerDutyParamsFields renders', () => {
test('all params fields is rendered', () => {
expect(actionTypeModel.actionParamsFields).not.toBeNull();
if (!actionTypeModel.actionParamsFields) {
return;
}
const ParamsFields = actionTypeModel.actionParamsFields;
const actionParams = {
eventAction: 'trigger',
dedupKey: 'test',
summary: '2323',
source: 'source',
severity: 'critical',
timestamp: '234654564654',
component: 'test',
group: 'group',
class: 'test class',
};
const wrapper = mountWithIntl(
<ParamsFields
action={actionParams}
errors={{}}
editAction={() => {}}
index={0}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="severitySelect"]')
.first()
.prop('value')
).toStrictEqual('critical');
expect(wrapper.find('[data-test-subj="eventActionSelect"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="dedupKeyInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="timestampInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="componentInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="groupInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="pagerdutyDescriptionInput"]').length > 0).toBeTruthy();
});
});

View file

@ -0,0 +1,361 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSelect,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
ActionTypeModel,
ActionConnectorFieldsProps,
ActionConnector,
ValidationResult,
ActionParamsProps,
} from '../../../types';
export function getActionType(): ActionTypeModel {
return {
id: '.pagerduty',
iconClass: 'apps',
selectMessage: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.selectMessageText',
{
defaultMessage: 'Send an event in PagerDuty.',
}
),
validateConnector: (action: ActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
routingKey: new Array<string>(),
};
validationResult.errors = errors;
if (!action.secrets.routingKey) {
errors.routingKey.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText',
{
defaultMessage: 'A routing key is required.',
}
)
);
}
return validationResult;
},
validateParams: (actionParams: any): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: PagerDutyActionConnectorFields,
actionParamsFields: PagerDutyParamsFields,
};
}
const PagerDutyActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
errors,
action,
editActionConfig,
editActionSecrets,
}) => {
const { apiUrl } = action.config;
const { routingKey } = action.secrets;
return (
<Fragment>
<EuiFormRow
id="apiUrl"
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.apiUrlTextFieldLabel',
{
defaultMessage: 'API URL',
}
)}
>
<EuiFieldText
fullWidth
name="apiUrl"
value={apiUrl || ''}
data-test-subj="pagerdutyApiUrlInput"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editActionConfig('apiUrl', e.target.value);
}}
onBlur={() => {
if (!apiUrl) {
editActionConfig('apiUrl', '');
}
}}
/>
</EuiFormRow>
<EuiFormRow
id="routingKey"
fullWidth
helpText={
<EuiLink
href="https://www.elastic.co/guide/en/elasticsearch/reference/current/actions-pagerduty.html#configuring-pagerduty"
target="_blank"
>
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyNameHelpLabel"
defaultMessage="Learn how to configure PagerDuty Accounts"
/>
</EuiLink>
}
error={errors.routingKey}
isInvalid={errors.routingKey.length > 0 && routingKey !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel',
{
defaultMessage: 'Routing key',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={errors.routingKey.length > 0 && routingKey !== undefined}
name="routingKey"
value={routingKey || ''}
data-test-subj="pagerdutyRoutingKeyInput"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editActionSecrets('routingKey', e.target.value);
}}
onBlur={() => {
if (!routingKey) {
editActionSecrets('routingKey', '');
}
}}
/>
</EuiFormRow>
</Fragment>
);
};
const PagerDutyParamsFields: React.FunctionComponent<ActionParamsProps> = ({
action,
editAction,
index,
errors,
hasErrors,
}) => {
const { eventAction, dedupKey, summary, source, severity, timestamp, component, group } = action;
const severityOptions = [
{ value: 'critical', text: 'Critical' },
{ value: 'info', text: 'Info' },
{ value: 'warning', text: 'Warning' },
{ value: 'error', text: 'Error' },
];
const eventActionOptions = [
{ value: 'trigger', text: 'Trigger' },
{ value: 'resolve', text: 'Resolve' },
{ value: 'acknowledge', text: 'Acknowledge' },
];
return (
<Fragment>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.severitySelectFieldLabel',
{
defaultMessage: 'Severity (optional)',
}
)}
>
<EuiSelect
fullWidth
data-test-subj="severitySelect"
options={severityOptions}
value={severity}
onChange={e => {
editAction('severity', e.target.value, index);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.eventActionSelectFieldLabel',
{
defaultMessage: 'Event action (optional)',
}
)}
>
<EuiSelect
fullWidth
data-test-subj="eventActionSelect"
options={eventActionOptions}
value={eventAction}
onChange={e => {
editAction('eventAction', e.target.value, index);
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.dedupKeyTextFieldLabel',
{
defaultMessage: 'DedupKey (optional)',
}
)}
>
<EuiFieldText
fullWidth
name="dedupKey"
data-test-subj="dedupKeyInput"
value={dedupKey || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editAction('dedupKey', e.target.value, index);
}}
onBlur={() => {
if (!index) {
editAction('dedupKey', '', index);
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.timestampTextFieldLabel',
{
defaultMessage: 'Timestamp (optional)',
}
)}
>
<EuiFieldText
fullWidth
name="timestamp"
data-test-subj="timestampInput"
value={timestamp || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editAction('timestamp', e.target.value, index);
}}
onBlur={() => {
if (!index) {
editAction('timestamp', '', index);
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.componentTextFieldLabel',
{
defaultMessage: 'Component (optional)',
}
)}
>
<EuiFieldText
fullWidth
name="component"
data-test-subj="componentInput"
value={component || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editAction('component', e.target.value, index);
}}
onBlur={() => {
if (!index) {
editAction('component', '', index);
}
}}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.groupTextFieldLabel',
{
defaultMessage: 'Group (optional)',
}
)}
>
<EuiFieldText
fullWidth
name="group"
data-test-subj="groupInput"
value={group || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editAction('group', e.target.value, index);
}}
onBlur={() => {
if (!index) {
editAction('group', '', index);
}
}}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.sourceTextFieldLabel',
{
defaultMessage: 'Source (optional)',
}
)}
>
<EuiFieldText
fullWidth
name="source"
data-test-subj="sourceInput"
value={source || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editAction('source', e.target.value, index);
}}
onBlur={() => {
if (!index) {
editAction('source', '', index);
}
}}
/>
</EuiFormRow>
<EuiFormRow
id="pagerDutySummary"
fullWidth
error={errors.summary}
isInvalid={hasErrors === true && summary !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.summaryFieldLabel',
{
defaultMessage: 'Summary',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={hasErrors === true && summary !== undefined}
name="summary"
value={summary || ''}
data-test-subj="pagerdutyDescriptionInput"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
editAction('summary', e.target.value, index);
}}
onBlur={() => {
if (!summary) {
editAction('summary', '', index);
}
}}
/>
</EuiFormRow>
</Fragment>
);
};

View file

@ -0,0 +1,130 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { TypeRegistry } from '../../type_registry';
import { registerBuiltInActionTypes } from './index';
import { ActionTypeModel, ActionConnector } from '../../../types';
const ACTION_TYPE_ID = '.server-log';
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);
expect(actionTypeModel.iconClass).toEqual('logsApp');
});
});
describe('server-log connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
const actionConnector = {
secrets: {},
id: 'test',
actionTypeId: '.server-log',
name: 'server-log',
config: {},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {},
});
});
});
describe('action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
const actionParams = {
message: 'test message',
level: 'trace',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: { message: [] },
});
});
});
describe('ServerLogParamsFields renders', () => {
test('all params fields is rendered', () => {
expect(actionTypeModel.actionParamsFields).not.toBeNull();
if (!actionTypeModel.actionParamsFields) {
return;
}
const ParamsFields = actionTypeModel.actionParamsFields;
const actionParams = {
message: 'test message',
level: 'trace',
};
const wrapper = mountWithIntl(
<ParamsFields
action={actionParams}
errors={{ message: [] }}
editAction={() => {}}
index={0}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="loggingLevelSelect"]')
.first()
.prop('value')
).toStrictEqual('trace');
expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy();
});
test('level param field is rendered with default value if not selected', () => {
expect(actionTypeModel.actionParamsFields).not.toBeNull();
if (!actionTypeModel.actionParamsFields) {
return;
}
const ParamsFields = actionTypeModel.actionParamsFields;
const actionParams = {
message: 'test message',
level: 'info',
};
const wrapper = mountWithIntl(
<ParamsFields
action={actionParams}
errors={{ message: [] }}
editAction={() => {}}
index={0}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="loggingLevelSelect"]')
.first()
.prop('value')
).toStrictEqual('info');
expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy();
});
test('params validation fails when message is not valid', () => {
const actionParams = {
message: '',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: ['Message is required.'],
},
});
});
});

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSelect, EuiTextArea, EuiFormRow } from '@elastic/eui';
import { ActionTypeModel, ValidationResult, ActionParamsProps } from '../../../types';
export function getActionType(): ActionTypeModel {
return {
id: '.server-log',
iconClass: 'logsApp',
selectMessage: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText',
{
defaultMessage: 'Add a message to a Kibana log.',
}
),
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (actionParams: any): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
message: new Array<string>(),
};
validationResult.errors = errors;
if (!actionParams.message || actionParams.message.length === 0) {
errors.message.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServerLogMessageText',
{
defaultMessage: 'Message is required.',
}
)
);
}
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: ServerLogParamsFields,
};
}
export const ServerLogParamsFields: React.FunctionComponent<ActionParamsProps> = ({
action,
editAction,
index,
errors,
hasErrors,
}) => {
const { message, level } = action;
const levelOptions = [
{ value: 'trace', text: 'Trace' },
{ value: 'debug', text: 'Debug' },
{ value: 'info', text: 'Info' },
{ value: 'warn', text: 'Warning' },
{ value: 'error', text: 'Error' },
{ value: 'fatal', text: 'Fatal' },
];
// Set default value 'info' for level param
editAction('level', 'info', index);
return (
<Fragment>
<EuiFormRow
id="loggingLevel"
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logLevelFieldLabel',
{
defaultMessage: 'Level',
}
)}
>
<EuiSelect
fullWidth
id="loggLevelSelect"
data-test-subj="loggingLevelSelect"
options={levelOptions}
value={level}
defaultValue={'info'}
onChange={e => {
editAction('level', e.target.value, index);
}}
/>
</EuiFormRow>
<EuiFormRow
id="loggingMessage"
fullWidth
error={errors.message}
isInvalid={hasErrors && message !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logMessageFieldLabel',
{
defaultMessage: 'Message',
}
)}
>
<EuiTextArea
fullWidth
isInvalid={hasErrors && message !== undefined}
value={message || ''}
name="message"
data-test-subj="loggingMessageInput"
onChange={e => {
editAction('message', e.target.value, index);
}}
/>
</EuiFormRow>
</Fragment>
);
};

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { TypeRegistry } from '../../type_registry';
import { registerBuiltInActionTypes } from './index';
import { ActionTypeModel, ActionConnector } from '../../../types';
const ACTION_TYPE_ID = '.slack';
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);
expect(actionTypeModel.iconClass).toEqual('logoSlack');
});
});
describe('slack connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
const actionConnector = {
secrets: {
webhookUrl: 'http:\\test',
},
id: 'test',
actionTypeId: '.email',
name: 'email',
config: {},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: [],
},
});
});
test('connector validation fails when connector config is not valid', () => {
const actionConnector = {
secrets: {},
id: 'test',
actionTypeId: '.email',
name: 'email',
config: {},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: ['Webhook URL is required.'],
},
});
});
});
describe('slack action params validation', () => {
test('if action params validation succeeds when action params is valid', () => {
const actionParams = {
message: 'message {test}',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: { message: [] },
});
});
});
describe('SlackActionFields renders', () => {
test('all connector fields is rendered', () => {
expect(actionTypeModel.actionConnectorFields).not.toBeNull();
if (!actionTypeModel.actionConnectorFields) {
return;
}
const ConnectorFields = actionTypeModel.actionConnectorFields;
const actionConnector = {
secrets: {
webhookUrl: 'http:\\test',
},
id: 'test',
actionTypeId: '.email',
name: 'email',
config: {},
} as ActionConnector;
const wrapper = mountWithIntl(
<ConnectorFields
action={actionConnector}
errors={{ webhookUrl: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
/>
);
expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="slackWebhookUrlInput"]')
.first()
.prop('value')
).toBe('http:\\test');
});
});
describe('SlackParamsFields renders', () => {
test('all params fields is rendered', () => {
expect(actionTypeModel.actionParamsFields).not.toBeNull();
if (!actionTypeModel.actionParamsFields) {
return;
}
const ParamsFields = actionTypeModel.actionParamsFields;
const actionParams = {
message: 'test message',
};
const wrapper = mountWithIntl(
<ParamsFields
action={actionParams}
errors={{ message: [] }}
editAction={() => {}}
index={0}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="slackMessageTextarea"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="slackMessageTextarea"]')
.first()
.prop('value')
).toStrictEqual('test message');
});
test('params validation fails when message is not valid', () => {
const actionParams = {
message: '',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: ['Message is required.'],
},
});
});
});

View file

@ -0,0 +1,175 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import {
EuiFieldText,
EuiTextArea,
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiFormRow,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
ActionTypeModel,
ActionConnectorFieldsProps,
ActionConnector,
ValidationResult,
ActionParamsProps,
} from '../../../types';
export function getActionType(): ActionTypeModel {
return {
id: '.slack',
iconClass: 'logoSlack',
selectMessage: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.selectMessageText',
{
defaultMessage: 'Send a message to a Slack channel or user.',
}
),
validateConnector: (action: ActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
webhookUrl: new Array<string>(),
};
validationResult.errors = errors;
if (!action.secrets.webhookUrl) {
errors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText',
{
defaultMessage: 'Webhook URL is required.',
}
)
);
}
return validationResult;
},
validateParams: (actionParams: any): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
message: new Array<string>(),
};
validationResult.errors = errors;
if (!actionParams.message || actionParams.message.length === 0) {
errors.message.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText',
{
defaultMessage: 'Message is required.',
}
)
);
}
return validationResult;
},
actionConnectorFields: SlackActionFields,
actionParamsFields: SlackParamsFields,
};
}
const SlackActionFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
action,
editActionSecrets,
errors,
}) => {
const { webhookUrl } = action.secrets;
return (
<Fragment>
<EuiFormRow
id="webhookUrl"
fullWidth
helpText={
<EuiLink
href="https://www.elastic.co/guide/en/elasticsearch/reference/current/actions-slack.html#configuring-slack"
target="_blank"
>
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlHelpLabel"
defaultMessage="Learn how to create a Slack webhook URL"
/>
</EuiLink>
}
error={errors.webhookUrl}
isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel',
{
defaultMessage: 'Webhook URL',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined}
name="webhookUrl"
placeholder="URL like https://hooks.slack.com/services"
value={webhookUrl || ''}
data-test-subj="slackWebhookUrlInput"
onChange={e => {
editActionSecrets('webhookUrl', e.target.value);
}}
onBlur={() => {
if (!webhookUrl) {
editActionSecrets('webhookUrl', '');
}
}}
/>
</EuiFormRow>
</Fragment>
);
};
const SlackParamsFields: React.FunctionComponent<ActionParamsProps> = ({
action,
editAction,
index,
errors,
hasErrors,
}) => {
const { message } = action;
return (
<Fragment>
<EuiFlexGroup>
<EuiFlexItem>
<EuiButtonIcon
onClick={() => window.alert('Button clicked')}
iconType="indexOpen"
aria-label="Add variable"
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFormRow
id="slackMessage"
fullWidth
error={errors.message}
isInvalid={hasErrors && message !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.messageTextAreaFieldLabel',
{
defaultMessage: 'Message',
}
)}
>
<EuiTextArea
fullWidth
isInvalid={hasErrors && message !== undefined}
name="message"
value={message}
data-test-subj="slackMessageTextarea"
onChange={e => {
editAction('message', e.target.value, index);
}}
/>
</EuiFormRow>
</Fragment>
);
};

View file

@ -0,0 +1,174 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { TypeRegistry } from '../../type_registry';
import { registerBuiltInActionTypes } from './index';
import { ActionTypeModel, ActionConnector } from '../../../types';
const ACTION_TYPE_ID = '.webhook';
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);
expect(actionTypeModel.iconClass).toEqual('logoWebhook');
});
});
describe('webhook connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
const actionConnector = {
secrets: {
user: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: '.webhook',
name: 'webhook',
config: {
method: 'PUT',
url: 'http:\\test',
headers: ['content-type: text'],
},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
url: [],
method: [],
user: [],
password: [],
},
});
});
test('connector validation fails when connector config is not valid', () => {
const actionConnector = {
secrets: {
user: 'user',
},
id: 'test',
actionTypeId: '.webhook',
name: 'webhook',
config: {
method: 'PUT',
},
} as ActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
url: ['URL is required.'],
method: [],
user: [],
password: ['Password is required.'],
},
});
});
});
describe('webhook action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
const actionParams = {
body: 'message {test}',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: { body: [] },
});
});
});
describe('WebhookActionConnectorFields renders', () => {
test('all connector fields is rendered', () => {
expect(actionTypeModel.actionConnectorFields).not.toBeNull();
if (!actionTypeModel.actionConnectorFields) {
return;
}
const ConnectorFields = actionTypeModel.actionConnectorFields;
const actionConnector = {
secrets: {
user: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: '.webhook',
name: 'webhook',
config: {
method: 'PUT',
url: 'http:\\test',
headers: ['content-type: text'],
},
} as ActionConnector;
const wrapper = mountWithIntl(
<ConnectorFields
action={actionConnector}
errors={{ url: [], method: [], user: [], password: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
/>
);
expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy();
wrapper
.find('[data-test-subj="webhookViewHeadersSwitch"]')
.first()
.simulate('click');
expect(wrapper.find('[data-test-subj="webhookMethodSelect"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="webhookUrlText"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="webhookUserInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="webhookPasswordInput"]').length > 0).toBeTruthy();
});
});
describe('WebhookParamsFields renders', () => {
test('all params fields is rendered', () => {
expect(actionTypeModel.actionParamsFields).not.toBeNull();
if (!actionTypeModel.actionParamsFields) {
return;
}
const ParamsFields = actionTypeModel.actionParamsFields;
const actionParams = {
body: 'test message',
};
const wrapper = mountWithIntl(
<ParamsFields
action={actionParams}
errors={{ body: [] }}
editAction={() => {}}
index={0}
hasErrors={false}
/>
);
expect(wrapper.find('[data-test-subj="webhookBodyEditor"]').length > 0).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="webhookBodyEditor"]')
.first()
.prop('value')
).toStrictEqual('test message');
});
test('params validation fails when body is not valid', () => {
const actionParams = {
body: '',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
body: ['Body is required.'],
},
});
});
});

View file

@ -0,0 +1,501 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFieldPassword,
EuiFieldText,
EuiFormRow,
EuiSelect,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiButtonIcon,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiTitle,
EuiCodeEditor,
EuiSwitch,
EuiButtonEmpty,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
ActionTypeModel,
ActionConnectorFieldsProps,
ActionConnector,
ValidationResult,
ActionParamsProps,
} from '../../../types';
const HTTP_VERBS = ['post', 'put'];
export function getActionType(): ActionTypeModel {
return {
id: '.webhook',
iconClass: 'logoWebhook',
selectMessage: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.selectMessageText',
{
defaultMessage: 'Send a request to a web service.',
}
),
validateConnector: (action: ActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
url: new Array<string>(),
method: new Array<string>(),
user: new Array<string>(),
password: new Array<string>(),
};
validationResult.errors = errors;
if (!action.config.url) {
errors.url.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText',
{
defaultMessage: 'URL is required.',
}
)
);
}
if (!action.config.method) {
errors.method.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText',
{
defaultMessage: 'Method is required.',
}
)
);
}
if (!action.secrets.user) {
errors.user.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHostText',
{
defaultMessage: 'Username is required.',
}
)
);
}
if (!action.secrets.password) {
errors.password.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText',
{
defaultMessage: 'Password is required.',
}
)
);
}
return validationResult;
},
validateParams: (actionParams: any): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
body: new Array<string>(),
};
validationResult.errors = errors;
if (!actionParams.body || actionParams.body.length === 0) {
errors.body.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText',
{
defaultMessage: 'Body is required.',
}
)
);
}
return validationResult;
},
actionConnectorFields: WebhookActionConnectorFields,
actionParamsFields: WebhookParamsFields,
};
}
const WebhookActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
action,
editActionConfig,
editActionSecrets,
errors,
}) => {
const [httpHeaderKey, setHttpHeaderKey] = useState<string>('');
const [httpHeaderValue, setHttpHeaderValue] = useState<string>('');
const [hasHeaders, setHasHeaders] = useState<boolean>(false);
const { user, password } = action.secrets;
const { method, url, headers } = action.config;
editActionConfig('method', 'post'); // set method to POST by default
const headerErrors = {
keyHeader: new Array<string>(),
valueHeader: new Array<string>(),
};
if (!httpHeaderKey && httpHeaderValue) {
headerErrors.keyHeader.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHeaderKeyText',
{
defaultMessage: 'Header key is required.',
}
)
);
}
if (httpHeaderKey && !httpHeaderValue) {
headerErrors.valueHeader.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHeaderValueText',
{
defaultMessage: 'Header value is required.',
}
)
);
}
const hasHeaderErrors = headerErrors.keyHeader.length > 0 || headerErrors.valueHeader.length > 0;
function addHeader() {
if (headers && !!Object.keys(headers).find(key => key === httpHeaderKey)) {
return;
}
const updatedHeaders = headers
? { ...headers, [httpHeaderKey]: httpHeaderValue }
: { [httpHeaderKey]: httpHeaderValue };
editActionConfig('headers', updatedHeaders);
setHttpHeaderKey('');
setHttpHeaderValue('');
}
function viewHeaders() {
setHasHeaders(!hasHeaders);
if (!hasHeaders) {
editActionConfig('headers', {});
}
}
function removeHeader(keyToRemove: string) {
const updatedHeaders = Object.keys(headers)
.filter(key => key !== keyToRemove)
.reduce((headerToRemove: Record<string, string>, key: string) => {
headerToRemove[key] = headers[key];
return headerToRemove;
}, {});
editActionConfig('headers', updatedHeaders);
}
let headerControl;
if (hasHeaders) {
headerControl = (
<Fragment>
<EuiTitle size="xxs">
<h5>
<FormattedMessage
defaultMessage="Add a new header"
id="xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addHeader"
/>
</h5>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="s" alignItems="flexStart">
<EuiFlexItem grow={false}>
<EuiFormRow
id="webhookHeaderKey"
fullWidth
error={headerErrors.keyHeader}
isInvalid={hasHeaderErrors && httpHeaderKey !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.keyTextFieldLabel',
{
defaultMessage: 'Key',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={hasHeaderErrors && httpHeaderKey !== undefined}
name="keyHeader"
value={httpHeaderKey}
data-test-subj="webhookHeadersKeyInput"
onChange={e => {
setHttpHeaderKey(e.target.value);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
id="webhookHeaderValue"
fullWidth
error={headerErrors.valueHeader}
isInvalid={hasHeaderErrors && httpHeaderValue !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.valueTextFieldLabel',
{
defaultMessage: 'Value',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={hasHeaderErrors && httpHeaderValue !== undefined}
name="valueHeader"
value={httpHeaderValue}
data-test-subj="webhookHeadersValueInput"
onChange={e => {
setHttpHeaderValue(e.target.value);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow hasEmptyLabelSpace>
<EuiButtonEmpty
isDisabled={hasHeaders && (hasHeaderErrors || !httpHeaderKey || !httpHeaderValue)}
data-test-subj="webhookAddHeaderButton"
onClick={() => addHeader()}
>
<FormattedMessage
defaultMessage="Add"
id="xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addHeaderButton"
/>
</EuiButtonEmpty>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
}
const headersList = Object.keys(headers || {}).map((key: string) => {
return (
<EuiFlexGroup key={key} data-test-subj="webhookHeaderText" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.deleteHeaderButton',
{
defaultMessage: 'Delete',
description: 'Delete HTTP header',
}
)}
iconType="trash"
color="danger"
onClick={() => removeHeader(key)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiDescriptionList compressed>
<EuiDescriptionListTitle>{key}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{headers[key]}</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
);
});
return (
<Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFormRow
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.methodTextFieldLabel',
{
defaultMessage: 'Method',
}
)}
>
<EuiSelect
name="method"
value={method || 'post'}
data-test-subj="webhookMethodSelect"
options={HTTP_VERBS.map(verb => ({ text: verb.toUpperCase(), value: verb }))}
onChange={e => {
editActionConfig('method', e.target.value);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
id="url"
fullWidth
error={errors.url}
isInvalid={errors.url.length > 0 && url !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.urlTextFieldLabel',
{
defaultMessage: 'URL',
}
)}
>
<EuiFieldText
name="url"
isInvalid={errors.url.length > 0 && url !== undefined}
fullWidth
value={url || ''}
data-test-subj="webhookUrlText"
onChange={e => {
editActionConfig('url', e.target.value);
}}
onBlur={() => {
if (!url) {
editActionConfig('url', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFormRow
id="webhookUser"
fullWidth
error={errors.user}
isInvalid={errors.user.length > 0 && user !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.userTextFieldLabel',
{
defaultMessage: 'Username',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={errors.user.length > 0 && user !== undefined}
name="user"
value={user || ''}
data-test-subj="webhookUserInput"
onChange={e => {
editActionSecrets('user', e.target.value);
}}
onBlur={() => {
if (!user) {
editActionSecrets('user', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
id="webhookPassword"
fullWidth
error={errors.password}
isInvalid={errors.password.length > 0 && password !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.passwordTextFieldLabel',
{
defaultMessage: 'Password',
}
)}
>
<EuiFieldPassword
fullWidth
name="password"
isInvalid={errors.password.length > 0 && password !== undefined}
value={password || ''}
data-test-subj="webhookPasswordInput"
onChange={e => {
editActionSecrets('password', e.target.value);
}}
onBlur={() => {
if (!password) {
editActionSecrets('password', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiSwitch
data-test-subj="webhookViewHeadersSwitch"
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.viewHeadersSwitch',
{
defaultMessage: 'Add HTTP header',
}
)}
checked={hasHeaders}
onChange={() => viewHeaders()}
/>
<EuiSpacer size="m" />
<div>
{hasHeaders && Object.keys(headers || {}).length > 0 ? (
<Fragment>
<EuiSpacer size="m" />
<EuiTitle size="xxs">
<h5>
<FormattedMessage
defaultMessage="Headers in use"
id="xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.httpHeadersTitle"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
{headersList}
</Fragment>
) : null}
<EuiSpacer size="m" />
{headerControl}
<EuiSpacer size="m" />
</div>
</Fragment>
);
};
const WebhookParamsFields: React.FunctionComponent<ActionParamsProps> = ({
action,
editAction,
index,
errors,
hasErrors,
}) => {
const { body } = action;
return (
<Fragment>
<EuiFormRow
id="webhookBody"
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.bodyFieldLabel',
{
defaultMessage: 'Body',
}
)}
isInvalid={hasErrors === true}
fullWidth
error={errors.body}
>
<EuiCodeEditor
fullWidth
isInvalid={hasErrors === true}
mode="json"
width="100%"
height="200px"
theme="github"
data-test-subj="webhookBodyEditor"
aria-label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.bodyCodeEditorAriaLabel',
{
defaultMessage: 'Code editor',
}
)}
value={body || ''}
onChange={(json: string) => {
editAction('body', json, index);
}}
/>
</EuiFormRow>
</Fragment>
);
};

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getActionType as getThresholdAlertType } from './threshold/expression';
import { TypeRegistry } from '../../type_registry';
import { AlertTypeModel } from '../../../types';
export function registerBuiltInAlertTypes({
alertTypeRegistry,
}: {
alertTypeRegistry: TypeRegistry<AlertTypeModel>;
}) {
alertTypeRegistry.register(getThresholdAlertType());
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const AGGREGATION_TYPES: { [key: string]: string } = {
COUNT: 'count',
AVERAGE: 'avg',
SUM: 'sum',
MIN: 'min',
MAX: 'max',
};

View 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;
* you may not use this file except in compliance with the Elastic License.
*/
export const COMPARATORS: { [key: string]: string } = {
GREATER_THAN: '>',
GREATER_THAN_OR_EQUALS: '>=',
BETWEEN: 'between',
LESS_THAN: '<',
LESS_THAN_OR_EQUALS: '<=',
};

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
export { COMPARATORS } from './comparators';
export { AGGREGATION_TYPES } from './aggregation_types';

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpSetup } from 'kibana/public';
const WATCHER_API_ROOT = '/api/watcher';
// TODO: replace watcher api with the proper from alerts
export async function getMatchingIndicesForThresholdAlertType({
pattern,
http,
}: {
pattern: string;
http: HttpSetup;
}): Promise<Record<string, any>> {
if (!pattern.startsWith('*')) {
pattern = `*${pattern}`;
}
if (!pattern.endsWith('*')) {
pattern = `${pattern}*`;
}
const { indices } = await http.post(`${WATCHER_API_ROOT}/indices`, {
body: JSON.stringify({ pattern }),
});
return indices;
}
export async function getThresholdAlertTypeFields({
indexes,
http,
}: {
indexes: string[];
http: HttpSetup;
}): Promise<Record<string, any>> {
const { fields } = await http.post(`${WATCHER_API_ROOT}/fields`, {
body: JSON.stringify({ indexes }),
});
return fields;
}
let savedObjectsClient: any;
export const setSavedObjectsClient = (aSavedObjectsClient: any) => {
savedObjectsClient = aSavedObjectsClient;
};
export const getSavedObjectsClient = () => {
return savedObjectsClient;
};
export const loadIndexPatterns = async () => {
const { savedObjects } = await getSavedObjectsClient().find({
type: 'index-pattern',
fields: ['title'],
perPage: 10000,
});
return savedObjects;
};
export async function getThresholdAlertVisualizationData({
model,
visualizeOptions,
http,
}: {
model: any;
visualizeOptions: any;
http: HttpSetup;
}): Promise<Record<string, any>> {
const { visualizeData } = await http.post(`${WATCHER_API_ROOT}/watch/visualize`, {
body: JSON.stringify({
watch: model,
options: visualizeOptions,
}),
});
return visualizeData;
}

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
export interface Comparator {
text: string;
value: string;
requiredValues: number;
}
export interface AggregationType {
text: string;
fieldRequired: boolean;
value: string;
validNormalizedTypes: string[];
}
export interface GroupByType {
text: string;
sizeRequired: boolean;
value: string;
validNormalizedTypes: string[];
}

View file

@ -0,0 +1,303 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useEffect, useState } from 'react';
import { IUiSettingsClient } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import {
AnnotationDomainTypes,
Axis,
getAxisId,
getSpecId,
Chart,
LineAnnotation,
LineSeries,
Position,
ScaleType,
Settings,
} from '@elastic/charts';
import { TimeBuckets } from 'ui/time_buckets';
import dateMath from '@elastic/datemath';
import moment from 'moment-timezone';
import { EuiCallOut, EuiLoadingChart, EuiSpacer, EuiEmptyPrompt, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { npStart } from 'ui/new_platform';
import { getThresholdAlertVisualizationData } from './lib/api';
import { comparators, aggregationTypes } from './expression';
import { useAppDependencies } from '../../../app_context';
import { Alert } from '../../../../types';
const customTheme = () => {
return {
lineSeriesStyle: {
line: {
strokeWidth: 3,
},
point: {
visible: false,
},
},
};
};
const getTimezone = (uiSettings: IUiSettingsClient) => {
const config = uiSettings;
const DATE_FORMAT_CONFIG_KEY = 'dateFormat:tz';
const isCustomTimezone = !config.isDefault(DATE_FORMAT_CONFIG_KEY);
if (isCustomTimezone) {
return config.get(DATE_FORMAT_CONFIG_KEY);
}
const detectedTimezone = moment.tz.guess();
if (detectedTimezone) {
return detectedTimezone;
}
// default to UTC if we can't figure out the timezone
const tzOffset = moment().format('Z');
return tzOffset;
};
const getDomain = (alertParams: any) => {
const VISUALIZE_TIME_WINDOW_MULTIPLIER = 5;
const fromExpression = `now-${alertParams.timeWindowSize * VISUALIZE_TIME_WINDOW_MULTIPLIER}${
alertParams.timeWindowUnit
}`;
const toExpression = 'now';
const fromMoment = dateMath.parse(fromExpression);
const toMoment = dateMath.parse(toExpression);
const visualizeTimeWindowFrom = fromMoment ? fromMoment.valueOf() : 0;
const visualizeTimeWindowTo = toMoment ? toMoment.valueOf() : 0;
return {
min: visualizeTimeWindowFrom,
max: visualizeTimeWindowTo,
};
};
const getThreshold = (alertParams: any) => {
return alertParams.threshold.slice(
0,
comparators[alertParams.thresholdComparator].requiredValues
);
};
const getTimeBuckets = (alertParams: any) => {
const domain = getDomain(alertParams);
const timeBuckets = new TimeBuckets();
timeBuckets.setBounds(domain);
return timeBuckets;
};
interface Props {
alert: Alert;
}
export const ThresholdVisualization: React.FunctionComponent<Props> = ({ alert }) => {
const { http, uiSettings, toastNotifications } = useAppDependencies();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<undefined | any>(undefined);
const [visualizationData, setVisualizationData] = useState<Record<string, any>>([]);
const chartsTheme = npStart.plugins.eui_utils.useChartsTheme();
const {
index,
timeField,
triggerIntervalSize,
triggerIntervalUnit,
aggType,
aggField,
termSize,
termField,
thresholdComparator,
timeWindowSize,
timeWindowUnit,
groupBy,
threshold,
} = alert.params;
const domain = getDomain(alert.params);
const timeBuckets = new TimeBuckets();
timeBuckets.setBounds(domain);
const interval = timeBuckets.getInterval().expression;
const visualizeOptions = {
rangeFrom: domain.min,
rangeTo: domain.max,
interval,
timezone: getTimezone(uiSettings),
};
// Fetching visualization data is independent of alert actions
const alertWithoutActions = { ...alert.params, actions: [], type: 'threshold' };
useEffect(() => {
(async () => {
try {
setIsLoading(true);
setVisualizationData(
await getThresholdAlertVisualizationData({
model: alertWithoutActions,
visualizeOptions,
http,
})
);
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertAdd.unableToLoadVisualizationMessage',
{ defaultMessage: 'Unable to load visualization' }
),
});
setError(e);
} finally {
setIsLoading(false);
}
})();
/* eslint-disable react-hooks/exhaustive-deps */
}, [
index,
timeField,
triggerIntervalSize,
triggerIntervalUnit,
aggType,
aggField,
termSize,
termField,
thresholdComparator,
timeWindowSize,
timeWindowUnit,
groupBy,
threshold,
]);
/* eslint-enable react-hooks/exhaustive-deps */
if (isLoading) {
return (
<EuiEmptyPrompt
title={<EuiLoadingChart size="xl" />}
body={
<EuiText color="subdued">
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.loadingAlertVisualizationDescription"
defaultMessage="Loading alert visualization…"
/>
</EuiText>
}
/>
);
}
if (error) {
return (
<Fragment>
<EuiSpacer size="l" />
<EuiCallOut
title={
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.errorLoadingAlertVisualizationTitle"
defaultMessage="Cannot load alert visualization"
values={{}}
/>
}
color="danger"
iconType="alert"
>
{error}
</EuiCallOut>
<EuiSpacer size="l" />
</Fragment>
);
}
if (visualizationData) {
const alertVisualizationDataKeys = Object.keys(visualizationData);
const timezone = getTimezone(uiSettings);
const actualThreshold = getThreshold(alert.params);
let maxY = actualThreshold[actualThreshold.length - 1];
(Object.values(visualizationData) as number[][][]).forEach(data => {
data.forEach(([, y]) => {
if (y > maxY) {
maxY = y;
}
});
});
const dateFormatter = (d: number) => {
return moment(d)
.tz(timezone)
.format(getTimeBuckets(alert.params).getScaledDateFormat());
};
const aggLabel = aggregationTypes[aggType].text;
return (
<div data-test-subj="alertVisualizationChart">
<EuiSpacer size="l" />
{alertVisualizationDataKeys.length ? (
<Chart size={['100%', 300]} renderer="canvas">
<Settings
theme={[customTheme(), chartsTheme]}
xDomain={domain}
showLegend={!!termField}
legendPosition={Position.Bottom}
/>
<Axis
id={getAxisId('bottom')}
position={Position.Bottom}
showOverlappingTicks={true}
tickFormat={dateFormatter}
/>
<Axis
domain={{ max: maxY }}
id={getAxisId('left')}
title={aggLabel}
position={Position.Left}
/>
{alertVisualizationDataKeys.map((key: string) => {
return (
<LineSeries
key={key}
id={getSpecId(key)}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
data={visualizationData[key]}
xAccessor={0}
yAccessors={[1]}
timeZone={timezone}
/>
);
})}
{actualThreshold.map((_value: any, i: number) => {
const specId = i === 0 ? 'threshold' : `threshold${i}`;
return (
<LineAnnotation
key={specId}
id={specId}
domainType={AnnotationDomainTypes.YDomain}
dataValues={[{ dataValue: threshold[i], details: specId }]}
/>
);
})}
</Chart>
) : (
<EuiCallOut
title={
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.thresholdPreviewChart.noDataTitle"
defaultMessage="No data"
/>
}
color="warning"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.thresholdPreviewChart.dataDoesNotExistTextMessage"
defaultMessage="Your index and condition did not return any data."
/>
</EuiCallOut>
)}
<EuiSpacer size="l" />
</div>
);
}
return null;
};

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useAppDependencies } from '../app_context';
import { deleteActions } from '../lib/action_connector_api';
export const DeleteConnectorsModal = ({
connectorsToDelete,
callback,
}: {
connectorsToDelete: string[];
callback: (deleted?: string[]) => void;
}) => {
const { http, toastNotifications } = useAppDependencies();
const numConnectorsToDelete = connectorsToDelete.length;
if (!numConnectorsToDelete) {
return null;
}
const confirmModalText = i18n.translate(
'xpack.triggersActionsUI.deleteSelectedConnectorsConfirmModal.descriptionText',
{
defaultMessage:
"You can't recover {numConnectorsToDelete, plural, one {a deleted connector} other {deleted connectors}}.",
values: { numConnectorsToDelete },
}
);
const confirmButtonText = i18n.translate(
'xpack.triggersActionsUI.deleteSelectedConnectorsConfirmModal.deleteButtonLabel',
{
defaultMessage:
'Delete {numConnectorsToDelete, plural, one {connector} other {# connectors}} ',
values: { numConnectorsToDelete },
}
);
const cancelButtonText = i18n.translate(
'xpack.triggersActionsUI.deleteSelectedConnectorsConfirmModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
);
return (
<EuiOverlayMask>
<EuiConfirmModal
buttonColor="danger"
data-test-subj="deleteConnectorsConfirmation"
title={confirmButtonText}
onCancel={() => callback()}
onConfirm={async () => {
const { successes, errors } = await deleteActions({ ids: connectorsToDelete, http });
const numSuccesses = successes.length;
const numErrors = errors.length;
callback(successes);
if (numSuccesses > 0) {
toastNotifications.addSuccess(
i18n.translate(
'xpack.triggersActionsUI.sections.connectorsList.deleteSelectedConnectorsSuccessNotification.descriptionText',
{
defaultMessage:
'Deleted {numSuccesses, number} {numSuccesses, plural, one {connector} other {connectors}}',
values: { numSuccesses },
}
)
);
}
if (numErrors > 0) {
toastNotifications.addDanger(
i18n.translate(
'xpack.triggersActionsUI.sections.connectorsList.deleteSelectedConnectorsErrorNotification.descriptionText',
{
defaultMessage:
'Failed to delete {numErrors, number} {numErrors, plural, one {connector} other {connectors}}',
values: { numErrors },
}
)
);
}
}}
cancelButtonText={cancelButtonText}
confirmButtonText={confirmButtonText}
>
{confirmModalText}
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui';
interface Props {
children: React.ReactNode;
}
export const SectionLoading: React.FunctionComponent<Props> = ({ children }) => {
return (
<EuiEmptyPrompt
title={<EuiLoadingSpinner size="xl" />}
body={<EuiText color="subdued">{children}</EuiText>}
data-test-subj="sectionLoading"
/>
);
};

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export enum ACTION_GROUPS {
ALERT = 'alert',
WARNING = 'warning',
UNACKNOWLEDGED = 'unacknowledged',
}

View 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;
* you may not use this file except in compliance with the Elastic License.
*/
export const BASE_PATH = '/management/kibana/triggersActions';
export const BASE_ACTION_API_PATH = '/api/action';
export const BASE_ALERT_API_PATH = '/api/alert';
export type Section = 'connectors' | 'alerts';
export const routeToHome = `${BASE_PATH}`;
export const routeToConnectors = `${BASE_PATH}/connectors`;
export const routeToAlerts = `${BASE_PATH}/alerts`;
export { TIME_UNITS } from './time_units';
export enum SORT_ORDERS {
ASCENDING = 'asc',
DESCENDING = 'desc',
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const PLUGIN = {
ID: 'triggers_actions_ui',
getI18nName: (i18n: any): string => {
return i18n.translate('xpack.triggersActionsUI.appName', {
defaultMessage: 'Alerts and Actions',
});
},
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export enum TIME_UNITS {
SECOND = 's',
MINUTE = 'm',
HOUR = 'h',
DAY = 'd',
}

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { createContext, useContext } from 'react';
import { ActionType } from '../../types';
export interface ActionsConnectorsContextValue {
addFlyoutVisible: boolean;
editFlyoutVisible: boolean;
setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
actionTypesIndex: Record<string, ActionType> | undefined;
reloadConnectors: () => Promise<void>;
}
const ActionsConnectorsContext = createContext<ActionsConnectorsContextValue>(null as any);
export const ActionsConnectorsContextProvider = ({
children,
value,
}: {
value: ActionsConnectorsContextValue;
children: React.ReactNode;
}) => {
return (
<ActionsConnectorsContext.Provider value={value}>{children}</ActionsConnectorsContext.Provider>
);
};
export const useActionsConnectorsContext = () => {
const ctx = useContext(ActionsConnectorsContext);
if (!ctx) {
throw new Error('ActionsConnectorsContext has not been set.');
}
return ctx;
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext, createContext } from 'react';
export interface AlertsContextValue {
alertFlyoutVisible: boolean;
setAlertFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
}
const AlertsContext = createContext<AlertsContextValue>(null as any);
export const AlertsContextProvider = ({
children,
value,
}: {
value: AlertsContextValue;
children: React.ReactNode;
}) => {
return <AlertsContext.Provider value={value}>{children}</AlertsContext.Provider>;
};
export const useAlertsContext = () => {
const ctx = useContext(AlertsContext);
if (!ctx) {
throw new Error('ActionsConnectorsContext has not been set.');
}
return ctx;
};

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect } from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiPageBody,
EuiPageContent,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiSpacer,
EuiTab,
EuiTabs,
EuiTitle,
} from '@elastic/eui';
import { BASE_PATH, Section, routeToConnectors, routeToAlerts } from './constants';
import { getCurrentBreadcrumb } from './lib/breadcrumb';
import { getCurrentDocTitle } from './lib/doc_title';
import { useAppDependencies } from './app_context';
import { hasShowActionsCapability, hasShowAlertsCapability } from './lib/capabilities';
import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list';
import { AlertsList } from './sections/alerts_list/components/alerts_list';
interface MatchParams {
section: Section;
}
export const TriggersActionsUIHome: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { section },
},
history,
}) => {
const {
chrome,
legacy: { MANAGEMENT_BREADCRUMB, capabilities },
} = useAppDependencies();
const canShowActions = hasShowActionsCapability(capabilities.get());
const canShowAlerts = hasShowAlertsCapability(capabilities.get());
const tabs: Array<{
id: Section;
name: React.ReactNode;
}> = [];
if (canShowAlerts) {
tabs.push({
id: 'alerts',
name: (
<FormattedMessage
id="xpack.triggersActionsUI.home.alertsTabTitle"
defaultMessage="Alerts"
/>
),
});
}
if (canShowActions) {
tabs.push({
id: 'connectors',
name: (
<FormattedMessage
id="xpack.triggersActionsUI.home.connectorsTabTitle"
defaultMessage="Connectors"
/>
),
});
}
const onSectionChange = (newSection: Section) => {
history.push(`${BASE_PATH}/${newSection}`);
};
// Set breadcrumb and page title
useEffect(() => {
chrome.setBreadcrumbs([MANAGEMENT_BREADCRUMB, getCurrentBreadcrumb(section || 'home')]);
chrome.docTitle.change(getCurrentDocTitle(section || 'home'));
}, [section, chrome, MANAGEMENT_BREADCRUMB]);
return (
<EuiPageBody>
<EuiPageContent>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle size="m">
<h1 data-test-subj="appTitle">
<FormattedMessage
id="xpack.triggersActionsUI.home.appTitle"
defaultMessage="Alerts and Actions"
/>
</h1>
</EuiTitle>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiTabs>
{tabs.map(tab => (
<EuiTab
onClick={() => onSectionChange(tab.id)}
isSelected={tab.id === section}
key={tab.id}
data-test-subj={`${tab.id}Tab`}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
<EuiSpacer size="s" />
<Switch>
{canShowActions && (
<Route exact path={routeToConnectors} component={ActionsConnectorsList} />
)}
{canShowAlerts && <Route exact path={routeToAlerts} component={AlertsList} />}
</Switch>
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -0,0 +1,135 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ActionConnector, ActionConnectorWithoutId, ActionType } from '../../types';
import { httpServiceMock } from '../../../../../../../../src/core/public/mocks';
import {
createActionConnector,
deleteActions,
loadActionTypes,
loadAllActions,
updateActionConnector,
} from './action_connector_api';
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('loadActionTypes', () => {
test('should call get types API', async () => {
const resolvedValue: ActionType[] = [
{
id: 'test',
name: 'Test',
},
];
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadActionTypes({ http });
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/action/types",
]
`);
});
});
describe('loadAllActions', () => {
test('should call find actions API', async () => {
const resolvedValue = {
page: 1,
perPage: 10000,
total: 0,
data: [],
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAllActions({ http });
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/action/_find",
Object {
"query": Object {
"per_page": 10000,
},
},
]
`);
});
});
describe('createActionConnector', () => {
test('should call create action API', async () => {
const connector: ActionConnectorWithoutId = {
actionTypeId: 'test',
name: 'My test',
config: {},
secrets: {},
};
const resolvedValue: ActionConnector = { ...connector, id: '123' };
http.post.mockResolvedValueOnce(resolvedValue);
const result = await createActionConnector({ http, connector });
expect(result).toEqual(resolvedValue);
expect(http.post.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/action",
Object {
"body": "{\\"actionTypeId\\":\\"test\\",\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}",
},
]
`);
});
});
describe('updateActionConnector', () => {
test('should call the update API', async () => {
const id = '123';
const connector: ActionConnectorWithoutId = {
actionTypeId: 'test',
name: 'My test',
config: {},
secrets: {},
};
const resolvedValue = { ...connector, id };
http.put.mockResolvedValueOnce(resolvedValue);
const result = await updateActionConnector({ http, connector, id });
expect(result).toEqual(resolvedValue);
expect(http.put.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/action/123",
Object {
"body": "{\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}",
},
]
`);
});
});
describe('deleteActions', () => {
test('should call delete API per action', async () => {
const ids = ['1', '2', '3'];
const result = await deleteActions({ ids, http });
expect(result).toEqual({ errors: [], successes: [undefined, undefined, undefined] });
expect(http.delete.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/action/1",
],
Array [
"/api/action/2",
],
Array [
"/api/action/3",
],
]
`);
});
});

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpSetup } from 'kibana/public';
import { BASE_ACTION_API_PATH } from '../constants';
import { ActionConnector, ActionConnectorWithoutId, ActionType } from '../../types';
// We are assuming there won't be many actions. This is why we will load
// all the actions in advance and assume the total count to not go over 100 or so.
// We'll set this max setting assuming it's never reached.
const MAX_ACTIONS_RETURNED = 10000;
export async function loadActionTypes({ http }: { http: HttpSetup }): Promise<ActionType[]> {
return await http.get(`${BASE_ACTION_API_PATH}/types`);
}
export async function loadAllActions({
http,
}: {
http: HttpSetup;
}): Promise<{
page: number;
perPage: number;
total: number;
data: ActionConnector[];
}> {
return await http.get(`${BASE_ACTION_API_PATH}/_find`, {
query: {
per_page: MAX_ACTIONS_RETURNED,
},
});
}
export async function createActionConnector({
http,
connector,
}: {
http: HttpSetup;
connector: Omit<ActionConnectorWithoutId, 'referencedByCount'>;
}): Promise<ActionConnector> {
return await http.post(`${BASE_ACTION_API_PATH}`, {
body: JSON.stringify(connector),
});
}
export async function updateActionConnector({
http,
connector,
id,
}: {
http: HttpSetup;
connector: Pick<ActionConnectorWithoutId, 'name' | 'config' | 'secrets'>;
id: string;
}): Promise<ActionConnector> {
return await http.put(`${BASE_ACTION_API_PATH}/${id}`, {
body: JSON.stringify({
name: connector.name,
config: connector.config,
secrets: connector.secrets,
}),
});
}
export async function deleteActions({
ids,
http,
}: {
ids: string[];
http: HttpSetup;
}): Promise<{ successes: string[]; errors: string[] }> {
const successes: string[] = [];
const errors: string[] = [];
await Promise.all(ids.map(id => http.delete(`${BASE_ACTION_API_PATH}/${id}`))).then(
function(fulfilled) {
successes.push(...fulfilled);
},
function(rejected) {
errors.push(...rejected);
}
);
return { successes, errors };
}

View file

@ -0,0 +1,406 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Alert, AlertType } from '../../types';
import { httpServiceMock } from '../../../../../../../../src/core/public/mocks';
import {
createAlert,
deleteAlerts,
disableAlerts,
enableAlerts,
loadAlerts,
loadAlertTypes,
muteAlerts,
unmuteAlerts,
updateAlert,
} from './alert_api';
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('loadAlertTypes', () => {
test('should call get alert types API', async () => {
const resolvedValue: AlertType[] = [
{
id: 'test',
name: 'Test',
},
];
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlertTypes({ http });
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/types",
]
`);
});
});
describe('loadAlerts', () => {
test('should call find API with base parameters', async () => {
const resolvedValue = {
page: 1,
perPage: 10,
total: 0,
data: [],
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlerts({ http, page: { index: 0, size: 10 } });
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/_find",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": undefined,
"page": 1,
"per_page": 10,
"search": undefined,
"search_fields": undefined,
},
},
]
`);
});
test('should call find API with searchText', async () => {
const resolvedValue = {
page: 1,
perPage: 10,
total: 0,
data: [],
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlerts({ http, searchText: 'apples', page: { index: 0, size: 10 } });
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/_find",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": undefined,
"page": 1,
"per_page": 10,
"search": "apples",
"search_fields": "[\\"name\\",\\"tags\\"]",
},
},
]
`);
});
test('should call find API with actionTypesFilter', async () => {
const resolvedValue = {
page: 1,
perPage: 10,
total: 0,
data: [],
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlerts({
http,
searchText: 'foo',
page: { index: 0, size: 10 },
});
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/_find",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": undefined,
"page": 1,
"per_page": 10,
"search": "foo",
"search_fields": "[\\"name\\",\\"tags\\"]",
},
},
]
`);
});
test('should call find API with typesFilter', async () => {
const resolvedValue = {
page: 1,
perPage: 10,
total: 0,
data: [],
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlerts({
http,
typesFilter: ['foo', 'bar'],
page: { index: 0, size: 10 },
});
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/_find",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": "alert.attributes.alertTypeId:(foo or bar)",
"page": 1,
"per_page": 10,
"search": undefined,
"search_fields": undefined,
},
},
]
`);
});
test('should call find API with actionTypesFilter and typesFilter', async () => {
const resolvedValue = {
page: 1,
perPage: 10,
total: 0,
data: [],
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlerts({
http,
searchText: 'baz',
typesFilter: ['foo', 'bar'],
page: { index: 0, size: 10 },
});
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/_find",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": "alert.attributes.alertTypeId:(foo or bar)",
"page": 1,
"per_page": 10,
"search": "baz",
"search_fields": "[\\"name\\",\\"tags\\"]",
},
},
]
`);
});
test('should call find API with searchText and tagsFilter and typesFilter', async () => {
const resolvedValue = {
page: 1,
perPage: 10,
total: 0,
data: [],
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlerts({
http,
searchText: 'apples, foo, baz',
typesFilter: ['foo', 'bar'],
page: { index: 0, size: 10 },
});
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/_find",
Object {
"query": Object {
"default_search_operator": "AND",
"filter": "alert.attributes.alertTypeId:(foo or bar)",
"page": 1,
"per_page": 10,
"search": "apples, foo, baz",
"search_fields": "[\\"name\\",\\"tags\\"]",
},
},
]
`);
});
});
describe('deleteAlerts', () => {
test('should call delete API for each alert', async () => {
const ids = ['1', '2', '3'];
const result = await deleteAlerts({ http, ids });
expect(result).toEqual(undefined);
expect(http.delete.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/alert/1",
],
Array [
"/api/alert/2",
],
Array [
"/api/alert/3",
],
]
`);
});
});
describe('createAlert', () => {
test('should call create alert API', async () => {
const alertToCreate = {
name: 'test',
tags: ['foo'],
enabled: true,
alertTypeId: 'test',
interval: '1m',
actions: [],
params: {},
throttle: null,
};
const resolvedValue: Alert = {
...alertToCreate,
id: '123',
createdBy: null,
updatedBy: null,
muteAll: false,
mutedInstanceIds: [],
};
http.post.mockResolvedValueOnce(resolvedValue);
const result = await createAlert({ http, alert: alertToCreate });
expect(result).toEqual(resolvedValue);
expect(http.post.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert",
Object {
"body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"interval\\":\\"1m\\",\\"actions\\":[],\\"params\\":{},\\"throttle\\":null}",
},
]
`);
});
});
describe('updateAlert', () => {
test('should call alert update API', async () => {
const alertToUpdate = {
throttle: '1m',
name: 'test',
tags: ['foo'],
interval: '1m',
params: {},
actions: [],
};
const resolvedValue: Alert = {
...alertToUpdate,
id: '123',
enabled: true,
alertTypeId: 'test',
createdBy: null,
updatedBy: null,
muteAll: false,
mutedInstanceIds: [],
};
http.put.mockResolvedValueOnce(resolvedValue);
const result = await updateAlert({ http, id: '123', alert: alertToUpdate });
expect(result).toEqual(resolvedValue);
expect(http.put.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/123",
Object {
"body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"interval\\":\\"1m\\",\\"params\\":{},\\"actions\\":[]}",
},
]
`);
});
});
describe('enableAlerts', () => {
test('should call enable alert API per alert', async () => {
const ids = ['1', '2', '3'];
const result = await enableAlerts({ http, ids });
expect(result).toEqual(undefined);
expect(http.post.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/alert/1/_enable",
],
Array [
"/api/alert/2/_enable",
],
Array [
"/api/alert/3/_enable",
],
]
`);
});
});
describe('disableAlerts', () => {
test('should call disable alert API per alert', async () => {
const ids = ['1', '2', '3'];
const result = await disableAlerts({ http, ids });
expect(result).toEqual(undefined);
expect(http.post.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/alert/1/_disable",
],
Array [
"/api/alert/2/_disable",
],
Array [
"/api/alert/3/_disable",
],
]
`);
});
});
describe('muteAlerts', () => {
test('should call mute alert API per alert', async () => {
const ids = ['1', '2', '3'];
const result = await muteAlerts({ http, ids });
expect(result).toEqual(undefined);
expect(http.post.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/alert/1/_mute_all",
],
Array [
"/api/alert/2/_mute_all",
],
Array [
"/api/alert/3/_mute_all",
],
]
`);
});
});
describe('unmuteAlerts', () => {
test('should call unmute alert API per alert', async () => {
const ids = ['1', '2', '3'];
const result = await unmuteAlerts({ http, ids });
expect(result).toEqual(undefined);
expect(http.post.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/alert/1/_unmute_all",
],
Array [
"/api/alert/2/_unmute_all",
],
Array [
"/api/alert/3/_unmute_all",
],
]
`);
});
});

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpSetup } from 'kibana/public';
import { BASE_ALERT_API_PATH } from '../constants';
import { Alert, AlertType, AlertWithoutId } from '../../types';
export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise<AlertType[]> {
return await http.get(`${BASE_ALERT_API_PATH}/types`);
}
export async function loadAlerts({
http,
page,
searchText,
typesFilter,
actionTypesFilter,
}: {
http: HttpSetup;
page: { index: number; size: number };
searchText?: string;
typesFilter?: string[];
actionTypesFilter?: string[];
}): Promise<{
page: number;
perPage: number;
total: number;
data: Alert[];
}> {
const filters = [];
if (typesFilter && typesFilter.length) {
filters.push(`alert.attributes.alertTypeId:(${typesFilter.join(' or ')})`);
}
if (actionTypesFilter && actionTypesFilter.length) {
filters.push(
[
'(',
actionTypesFilter.map(id => `alert.attributes.actions:{ actionTypeId:${id} }`).join(' OR '),
')',
].join('')
);
}
return await http.get(`${BASE_ALERT_API_PATH}/_find`, {
query: {
page: page.index + 1,
per_page: page.size,
search_fields: searchText ? JSON.stringify(['name', 'tags']) : undefined,
search: searchText,
filter: filters.length ? filters.join(' and ') : undefined,
default_search_operator: 'AND',
},
});
}
export async function deleteAlerts({
ids,
http,
}: {
ids: string[];
http: HttpSetup;
}): Promise<void> {
await Promise.all(ids.map(id => http.delete(`${BASE_ALERT_API_PATH}/${id}`)));
}
export async function createAlert({
http,
alert,
}: {
http: HttpSetup;
alert: Omit<AlertWithoutId, 'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds'>;
}): Promise<Alert> {
return await http.post(`${BASE_ALERT_API_PATH}`, {
body: JSON.stringify(alert),
});
}
export async function updateAlert({
http,
alert,
id,
}: {
http: HttpSetup;
alert: Pick<AlertWithoutId, 'throttle' | 'name' | 'tags' | 'interval' | 'params' | 'actions'>;
id: string;
}): Promise<Alert> {
return await http.put(`${BASE_ALERT_API_PATH}/${id}`, {
body: JSON.stringify(alert),
});
}
export async function enableAlerts({
ids,
http,
}: {
ids: string[];
http: HttpSetup;
}): Promise<void> {
await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_enable`)));
}
export async function disableAlerts({
ids,
http,
}: {
ids: string[];
http: HttpSetup;
}): Promise<void> {
await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_disable`)));
}
export async function muteAlerts({ ids, http }: { ids: string[]; http: HttpSetup }): Promise<void> {
await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_mute_all`)));
}
export async function unmuteAlerts({
ids,
http,
}: {
ids: string[];
http: HttpSetup;
}): Promise<void> {
await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_unmute_all`)));
}

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getCurrentBreadcrumb } from './breadcrumb';
import { i18n } from '@kbn/i18n';
import { routeToConnectors, routeToAlerts, routeToHome } from '../constants';
describe('getCurrentBreadcrumb', () => {
test('if change calls return proper breadcrumb title ', async () => {
expect(getCurrentBreadcrumb('connectors')).toMatchObject({
text: i18n.translate('xpack.triggersActionsUI.connectors.breadcrumbTitle', {
defaultMessage: 'Connectors',
}),
href: `#${routeToConnectors}`,
});
expect(getCurrentBreadcrumb('alerts')).toMatchObject({
text: i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', {
defaultMessage: 'Alerts',
}),
href: `#${routeToAlerts}`,
});
expect(getCurrentBreadcrumb('home')).toMatchObject({
text: i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', {
defaultMessage: 'Alerts and Actions',
}),
href: `#${routeToHome}`,
});
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { routeToHome, routeToConnectors, routeToAlerts } from '../constants';
export const getCurrentBreadcrumb = (type: string): { text: string; href: string } => {
// Home and sections
switch (type) {
case 'connectors':
return {
text: i18n.translate('xpack.triggersActionsUI.connectors.breadcrumbTitle', {
defaultMessage: 'Connectors',
}),
href: `#${routeToConnectors}`,
};
case 'alerts':
return {
text: i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', {
defaultMessage: 'Alerts',
}),
href: `#${routeToAlerts}`,
};
default:
return {
text: i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', {
defaultMessage: 'Alerts and Actions',
}),
href: `#${routeToHome}`,
};
}
};

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
/**
* NOTE: Applications that want to show the alerting UIs will need to add
* check against their features here until we have a better solution. This
* will possibly go away with https://github.com/elastic/kibana/issues/52300.
*/
export function hasShowAlertsCapability(capabilities: any): boolean {
if (capabilities.siem && capabilities.siem['alerting:show']) {
return true;
}
return false;
}
export function hasShowActionsCapability(capabilities: any): boolean {
if (capabilities.siem && capabilities.siem['actions:show']) {
return true;
}
return false;
}
export function hasSaveAlertsCapability(capabilities: any): boolean {
if (capabilities.siem && capabilities.siem['alerting:save']) {
return true;
}
return false;
}
export function hasSaveActionsCapability(capabilities: any): boolean {
if (capabilities.siem && capabilities.siem['actions:save']) {
return true;
}
return false;
}
export function hasDeleteAlertsCapability(capabilities: any): boolean {
if (capabilities.siem && capabilities.siem['alerting:delete']) {
return true;
}
return false;
}
export function hasDeleteActionsCapability(capabilities: any): boolean {
if (capabilities.siem && capabilities.siem['actions:delete']) {
return true;
}
return false;
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getCurrentDocTitle } from './doc_title';
describe('getCurrentDocTitle', () => {
test('if change calls return the proper doc title ', async () => {
expect(getCurrentDocTitle('home') === 'Alerts and Actions').toBeTruthy();
expect(getCurrentDocTitle('connectors') === 'Connectors').toBeTruthy();
expect(getCurrentDocTitle('alerts') === 'Alerts').toBeTruthy();
});
});

View 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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const getCurrentDocTitle = (page: string): string => {
let updatedTitle: string;
switch (page) {
case 'connectors':
updatedTitle = i18n.translate('xpack.triggersActionsUI.connectors.breadcrumbTitle', {
defaultMessage: 'Connectors',
});
break;
case 'alerts':
updatedTitle = i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', {
defaultMessage: 'Alerts',
});
break;
default:
updatedTitle = i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', {
defaultMessage: 'Alerts and Actions',
});
}
return updatedTitle;
};

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getTimeOptions, getTimeFieldOptions } from './get_time_options';
describe('get_time_options', () => {
test('if getTimeOptions return single unit time options', () => {
const timeUnitValue = getTimeOptions('1');
expect(timeUnitValue).toMatchObject([
{ text: 'second', value: 's' },
{ text: 'minute', value: 'm' },
{ text: 'hour', value: 'h' },
{ text: 'day', value: 'd' },
]);
});
test('if getTimeOptions return multiple unit time options', () => {
const timeUnitValue = getTimeOptions('10');
expect(timeUnitValue).toMatchObject([
{ text: 'seconds', value: 's' },
{ text: 'minutes', value: 'm' },
{ text: 'hours', value: 'h' },
{ text: 'days', value: 'd' },
]);
});
test('if getTimeFieldOptions return only date type fields', () => {
const timeOnlyTypeFields = getTimeFieldOptions([
{ type: 'date', name: 'order_date' },
{ type: 'number', name: 'sum' },
]);
expect(timeOnlyTypeFields).toMatchObject([{ text: 'order_date', value: 'order_date' }]);
});
});

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getTimeUnitLabel } from './get_time_unit_label';
import { TIME_UNITS } from '../constants';
export const getTimeOptions = (unitSize: string) =>
Object.entries(TIME_UNITS).map(([_key, value]) => {
return {
text: getTimeUnitLabel(value, unitSize),
value,
};
});
interface TimeFieldOptions {
text: string;
value: string;
}
export const getTimeFieldOptions = (
fields: Array<{ type: string; name: string }>
): TimeFieldOptions[] => {
const options: TimeFieldOptions[] = [];
fields.forEach((field: { type: string; name: string }) => {
if (field.type === 'date') {
options.push({
text: field.name,
value: field.name,
});
}
});
return options;
};

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { TIME_UNITS } from '../constants';
export function getTimeUnitLabel(timeUnit = TIME_UNITS.SECOND, timeValue = '0') {
switch (timeUnit) {
case TIME_UNITS.SECOND:
return i18n.translate('xpack.triggersActionsUI.timeUnits.secondLabel', {
defaultMessage: '{timeValue, plural, one {second} other {seconds}}',
values: { timeValue },
});
case TIME_UNITS.MINUTE:
return i18n.translate('xpack.triggersActionsUI.timeUnits.minuteLabel', {
defaultMessage: '{timeValue, plural, one {minute} other {minutes}}',
values: { timeValue },
});
case TIME_UNITS.HOUR:
return i18n.translate('xpack.triggersActionsUI.timeUnits.hourLabel', {
defaultMessage: '{timeValue, plural, one {hour} other {hours}}',
values: { timeValue },
});
case TIME_UNITS.DAY:
return i18n.translate('xpack.triggersActionsUI.timeUnits.dayLabel', {
defaultMessage: '{timeValue, plural, one {day} other {days}}',
values: { timeValue },
});
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../../../src/core/public/mocks';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult, ActionConnector } from '../../../types';
import { ActionConnectorForm } from './action_connector_form';
import { AppContextProvider } from '../../app_context';
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('action_connector_form', () => {
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
actions: {
delete: true,
save: true,
show: true,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
const actionType = {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
actionTypeRegistry.get.mockReturnValue(actionType);
actionTypeRegistry.has.mockReturnValue(true);
const initialConnector = {
actionTypeId: actionType.id,
config: {},
secrets: {},
} as ActionConnector;
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible: true,
setAddFlyoutVisibility: () => {},
editFlyoutVisible: false,
setEditFlyoutVisibility: () => {},
actionTypesIndex: {
'my-action-type': { id: 'my-action-type', name: 'my-action-type-name' },
},
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ActionConnectorForm
actionTypeName={'my-action-type-name'}
initialConnector={initialConnector}
setFlyoutVisibility={() => {}}
/>
</ActionsConnectorsContextProvider>
</AppContextProvider>
);
});
await waitForRender(wrapper);
});
it('renders action_connector_form', () => {
const connectorNameField = wrapper.find('[data-test-subj="nameInput"]');
expect(connectorNameField.exists()).toBeTruthy();
expect(connectorNameField.first().prop('value')).toBe('');
});
});
async function waitForRender(wrapper: ReactWrapper<any, any>) {
await Promise.resolve();
await Promise.resolve();
wrapper.update();
}

View file

@ -0,0 +1,270 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useState, useReducer } from 'react';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiCallOut,
EuiLink,
EuiText,
EuiSpacer,
EuiButtonEmpty,
EuiFlyoutFooter,
EuiFieldText,
EuiFlyoutBody,
EuiFormRow,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { createActionConnector, updateActionConnector } from '../../lib/action_connector_api';
import { useAppDependencies } from '../../app_context';
import { connectorReducer } from './connector_reducer';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { ActionConnector, IErrorObject } from '../../../types';
import { hasSaveActionsCapability } from '../../lib/capabilities';
interface ActionConnectorProps {
initialConnector: ActionConnector;
actionTypeName: string;
setFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
}
export const ActionConnectorForm = ({
initialConnector,
actionTypeName,
setFlyoutVisibility,
}: ActionConnectorProps) => {
const {
http,
toastNotifications,
legacy: { capabilities },
actionTypeRegistry,
} = useAppDependencies();
const { reloadConnectors } = useActionsConnectorsContext();
const canSave = hasSaveActionsCapability(capabilities.get());
// hooks
const [{ connector }, dispatch] = useReducer(connectorReducer, { connector: initialConnector });
const setActionProperty = (key: string, value: any) => {
dispatch({ command: { type: 'setProperty' }, payload: { key, value } });
};
const setActionConfigProperty = (key: string, value: any) => {
dispatch({ command: { type: 'setConfigProperty' }, payload: { key, value } });
};
const setActionSecretsProperty = (key: string, value: any) => {
dispatch({ command: { type: 'setSecretsProperty' }, payload: { key, value } });
};
const [isSaving, setIsSaving] = useState<boolean>(false);
const [serverError, setServerError] = useState<{
body: { message: string; error: string };
} | null>(null);
const actionTypeRegistered = actionTypeRegistry.get(initialConnector.actionTypeId);
if (!actionTypeRegistered) return null;
function validateBaseProperties(actionObject: ActionConnector) {
const validationResult = { errors: {} };
const errors = {
name: new Array<string>(),
};
validationResult.errors = errors;
if (!actionObject.name) {
errors.name.push(
i18n.translate(
'xpack.triggersActionsUI.sections.actionConnectorForm.error.requiredNameText',
{
defaultMessage: 'Name is required.',
}
)
);
}
return validationResult;
}
const FieldsComponent = actionTypeRegistered.actionConnectorFields;
const errors = {
...actionTypeRegistered.validateConnector(connector).errors,
...validateBaseProperties(connector).errors,
} as IErrorObject;
const hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1);
async function onActionConnectorSave(): Promise<any> {
let message: string;
let savedConnector: ActionConnector | undefined;
let error;
if (connector.id === undefined) {
await createActionConnector({ http, connector })
.then(res => {
savedConnector = res;
})
.catch(errorRes => {
error = errorRes;
});
message = 'Created';
} else {
await updateActionConnector({ http, connector, id: connector.id })
.then(res => {
savedConnector = res;
})
.catch(errorRes => {
error = errorRes;
});
message = 'Updated';
}
if (error) {
return {
error,
};
}
toastNotifications.addSuccess(
i18n.translate(
'xpack.triggersActionsUI.sections.actionConnectorForm.updateSuccessNotificationText',
{
defaultMessage: "{message} '{connectorName}'",
values: {
message,
connectorName: savedConnector ? savedConnector.name : '',
},
}
)
);
return savedConnector;
}
return (
<Fragment>
<EuiFlyoutBody>
<EuiForm isInvalid={serverError !== null} error={serverError?.body.message}>
<EuiFormRow
id="actionName"
fullWidth
label={
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorForm.actionNameLabel"
defaultMessage="Connector name"
/>
}
isInvalid={errors.name.length > 0 && connector.name !== undefined}
error={errors.name}
>
<EuiFieldText
fullWidth
isInvalid={errors.name.length > 0 && connector.name !== undefined}
name="name"
placeholder="Untitled"
data-test-subj="nameInput"
value={connector.name || ''}
onChange={e => {
setActionProperty('name', e.target.value);
}}
onBlur={() => {
if (!connector.name) {
setActionProperty('name', '');
}
}}
/>
</EuiFormRow>
<EuiSpacer size="m" />
{FieldsComponent !== null ? (
<FieldsComponent
action={connector}
errors={errors}
editActionConfig={setActionConfigProperty}
editActionSecrets={setActionSecretsProperty}
hasErrors={hasErrors}
>
{initialConnector.actionTypeId === null ? (
<Fragment>
<EuiCallOut
title={i18n.translate(
'xpack.triggersActionsUI.sections.actionConnectorForm.actions.actionTypeConfigurationWarningTitleText',
{
defaultMessage: 'Action type may not be configured',
}
)}
color="warning"
iconType="help"
>
<EuiText>
<p>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorForm.actions.actionConfigurationWarningDescriptionText"
defaultMessage="To create this connector, you must configure at least one {actionType} account. {docLink}"
values={{
actionType: actionTypeName,
docLink: (
<EuiLink target="_blank">
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorForm.actions.actionConfigurationWarningHelpLinkText"
defaultMessage="Learn more."
/>
</EuiLink>
),
}}
/>
</p>
</EuiText>
</EuiCallOut>
<EuiSpacer />
</Fragment>
) : null}
</FieldsComponent>
) : null}
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => setFlyoutVisibility(false)}>
{i18n.translate(
'xpack.triggersActionsUI.sections.actionConnectorForm.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
{canSave ? (
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveActionButton"
type="submit"
iconType="check"
isDisabled={hasErrors}
isLoading={isSaving}
onClick={async () => {
setIsSaving(true);
const savedAction = await onActionConnectorSave();
setIsSaving(false);
if (savedAction && savedAction.error) {
return setServerError(savedAction.error);
}
setFlyoutVisibility(false);
reloadConnectors();
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlyoutFooter>
</Fragment>
);
};

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../../../src/core/public/mocks';
import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ActionTypeMenu } from './action_type_menu';
import { ValidationResult } from '../../../types';
import { AppContextProvider } from '../../app_context';
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('connector_add_flyout', () => {
let deps: any;
beforeAll(async () => {
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
actions: {
delete: true,
save: true,
show: true,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
});
it('renders action type menu with proper EuiCards for registered action types', () => {
const onActionTypeChange = jest.fn();
const actionType = {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
actionTypeRegistry.get.mockReturnValueOnce(actionType);
const wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible: true,
setAddFlyoutVisibility: state => {},
editFlyoutVisible: false,
setEditFlyoutVisibility: state => {},
actionTypesIndex: {
'first-action-type': { id: 'first-action-type', name: 'first' },
'second-action-type': { id: 'second-action-type', name: 'second' },
},
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ActionTypeMenu onActionTypeChange={onActionTypeChange} />
</ActionsConnectorsContextProvider>
</AppContextProvider>
);
expect(wrapper.find('[data-test-subj="first-action-type-card"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="second-action-type-card"]').exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiCard,
EuiIcon,
EuiFlexGrid,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiButtonEmpty,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ActionType } from '../../../types';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { useAppDependencies } from '../../app_context';
interface Props {
onActionTypeChange: (actionType: ActionType) => void;
}
export const ActionTypeMenu = ({ onActionTypeChange }: Props) => {
const { actionTypeRegistry } = useAppDependencies();
const { actionTypesIndex, setAddFlyoutVisibility } = useActionsConnectorsContext();
if (!actionTypesIndex) {
return null;
}
const actionTypes = Object.entries(actionTypesIndex)
.filter(([index]) => actionTypeRegistry.has(index))
.map(([index, actionType]) => {
const actionTypeModel = actionTypeRegistry.get(index);
return {
iconClass: actionTypeModel ? actionTypeModel.iconClass : '',
selectMessage: actionTypeModel ? actionTypeModel.selectMessage : '',
actionType,
name: actionType.name,
typeName: index.replace('.', ''),
};
});
const cardNodes = actionTypes
.sort((a, b) => a.name.localeCompare(b.name))
.map((item, index): any => {
return (
<EuiFlexItem key={index}>
<EuiCard
data-test-subj={`${item.actionType.id}-card`}
icon={<EuiIcon size="xl" type={item.iconClass} />}
title={item.name}
description={item.selectMessage}
onClick={() => onActionTypeChange(item.actionType)}
/>
</EuiFlexItem>
);
});
return (
<Fragment>
<EuiFlyoutBody>
<EuiFlexGrid columns={2}>{cardNodes}</EuiFlexGrid>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => setAddFlyoutVisibility(false)}>
{i18n.translate(
'xpack.triggersActionsUI.sections.actionConnectorForm.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</Fragment>
);
};

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../../../src/core/public/mocks';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { ConnectorAddFlyout } from './connector_add_flyout';
import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult } from '../../../types';
import { AppContextProvider } from '../../app_context';
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('connector_add_flyout', () => {
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
actions: {
delete: true,
save: true,
show: true,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible: true,
setAddFlyoutVisibility: state => {},
editFlyoutVisible: false,
setEditFlyoutVisibility: state => {},
actionTypesIndex: { 'my-action-type': { id: 'my-action-type', name: 'test' } },
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ConnectorAddFlyout />
</ActionsConnectorsContextProvider>
</AppContextProvider>
);
});
await waitForRender(wrapper);
});
it('renders action type menu on flyout open', () => {
const actionType = {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
actionTypeRegistry.get.mockReturnValueOnce(actionType);
actionTypeRegistry.has.mockReturnValue(true);
expect(wrapper.find('ActionTypeMenu')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy();
});
});
async function waitForRender(wrapper: ReactWrapper<any, any>) {
await Promise.resolve();
await Promise.resolve();
wrapper.update();
}

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useState, Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiTitle,
EuiFlyoutHeader,
EuiFlyout,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiText,
} from '@elastic/eui';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { ActionTypeMenu } from './action_type_menu';
import { ActionConnectorForm } from './action_connector_form';
import { ActionType, ActionConnector } from '../../../types';
import { useAppDependencies } from '../../app_context';
export const ConnectorAddFlyout = () => {
const { actionTypeRegistry } = useAppDependencies();
const { addFlyoutVisible, setAddFlyoutVisibility } = useActionsConnectorsContext();
const [actionType, setActionType] = useState<ActionType | undefined>(undefined);
const closeFlyout = useCallback(() => {
setAddFlyoutVisibility(false);
setActionType(undefined);
}, [setAddFlyoutVisibility, setActionType]);
if (!addFlyoutVisible) {
return null;
}
function onActionTypeChange(newActionType: ActionType) {
setActionType(newActionType);
}
let currentForm;
let actionTypeModel;
if (!actionType) {
currentForm = <ActionTypeMenu onActionTypeChange={onActionTypeChange} />;
} else {
actionTypeModel = actionTypeRegistry.get(actionType.id);
const initialConnector = {
actionTypeId: actionType.id,
config: {},
secrets: {},
} as ActionConnector;
currentForm = (
<ActionConnectorForm
actionTypeName={actionType.name}
initialConnector={initialConnector}
setFlyoutVisibility={closeFlyout}
/>
);
}
return (
<EuiFlyout onClose={closeFlyout} aria-labelledby="flyoutActionAddTitle" size="m">
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup gutterSize="m" alignItems="center">
{actionTypeModel && actionTypeModel.iconClass ? (
<EuiFlexItem grow={false}>
<EuiIcon type={actionTypeModel.iconClass} size="xl" />
</EuiFlexItem>
) : null}
<EuiFlexItem>
{actionTypeModel && actionType ? (
<Fragment>
<EuiTitle size="s">
<h3 id="flyoutTitle">
<FormattedMessage
defaultMessage="{actionTypeName} connector"
id="xpack.triggersActionsUI.sections.addConnectorForm.flyoutTitle"
values={{
actionTypeName: actionType.name,
}}
/>
</h3>
</EuiTitle>
<EuiText size="s" color="subdued">
{actionTypeModel.selectMessage}
</EuiText>
</Fragment>
) : (
<EuiTitle size="s">
<h3 id="selectConnectorFlyoutTitle">
<FormattedMessage
defaultMessage="Select a connector"
id="xpack.triggersActionsUI.sections.addConnectorForm.selectConnectorFlyoutTitle"
/>
</h3>
</EuiTitle>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
{currentForm}
</EuiFlyout>
);
};

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../../../src/core/public/mocks';
import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult } from '../../../types';
import { ConnectorEditFlyout } from './connector_edit_flyout';
import { AppContextProvider } from '../../app_context';
const actionTypeRegistry = actionTypeRegistryMock.create();
let deps: any;
describe('connector_edit_flyout', () => {
beforeAll(async () => {
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
actions: {
delete: true,
save: true,
show: true,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
});
test('if input connector render correct in the edit form', () => {
const connector = {
secrets: {},
id: 'test',
actionTypeId: 'test-action-type-id',
actionType: 'test-action-type-name',
name: 'action-connector',
referencedByCount: 0,
config: {},
};
const actionType = {
id: 'test-action-type-id',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
actionTypeRegistry.get.mockReturnValue(actionType);
actionTypeRegistry.has.mockReturnValue(true);
const wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible: false,
setAddFlyoutVisibility: state => {},
editFlyoutVisible: true,
setEditFlyoutVisibility: state => {},
actionTypesIndex: {
'test-action-type-id': { id: 'test-action-type-id', name: 'test' },
},
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ConnectorEditFlyout connector={connector} />
</ActionsConnectorsContextProvider>
</AppContextProvider>
);
const connectorNameField = wrapper.find('[data-test-subj="nameInput"]');
expect(connectorNameField.exists()).toBeTruthy();
expect(connectorNameField.first().prop('value')).toBe('action-connector');
});
});

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiTitle,
EuiFlyoutHeader,
EuiFlyout,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
} from '@elastic/eui';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { ActionConnectorForm } from './action_connector_form';
import { useAppDependencies } from '../../app_context';
import { ActionConnectorTableItem } from '../../../types';
export interface ConnectorEditProps {
connector: ActionConnectorTableItem;
}
export const ConnectorEditFlyout = ({ connector }: ConnectorEditProps) => {
const { actionTypeRegistry } = useAppDependencies();
const { editFlyoutVisible, setEditFlyoutVisibility } = useActionsConnectorsContext();
const closeFlyout = useCallback(() => setEditFlyoutVisibility(false), [setEditFlyoutVisibility]);
if (!editFlyoutVisible) {
return null;
}
const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId);
return (
<EuiFlyout onClose={closeFlyout} aria-labelledby="flyoutActionAddTitle" size="m">
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup gutterSize="s" alignItems="center">
{actionTypeModel ? (
<EuiFlexItem grow={false}>
<EuiIcon type={actionTypeModel.iconClass} size="m" />
</EuiFlexItem>
) : null}
<EuiFlexItem>
<EuiTitle size="s">
<h3 id="flyoutTitle">
<FormattedMessage
defaultMessage="Edit connector"
id="xpack.triggersActionsUI.sections.editConnectorForm.flyoutTitle"
/>
</h3>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<ActionConnectorForm
key={connector.id}
initialConnector={{
...connector,
secrets: {},
}}
actionTypeName={connector.actionType}
setFlyoutVisibility={setEditFlyoutVisibility}
/>
</EuiFlyout>
);
};

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connectorReducer } from './connector_reducer';
import { ActionConnector } from '../../../types';
describe('connector reducer', () => {
let initialConnector: ActionConnector;
beforeAll(() => {
initialConnector = {
secrets: {},
id: 'test',
actionTypeId: 'test-action-type-id',
name: 'action-connector',
referencedByCount: 0,
config: {},
};
});
test('if property name was changed', () => {
const updatedConnector = connectorReducer(
{ connector: initialConnector },
{
command: { type: 'setProperty' },
payload: {
key: 'name',
value: 'new name',
},
}
);
expect(updatedConnector.connector.name).toBe('new name');
});
test('if config property was added and updated', () => {
const updatedConnector = connectorReducer(
{ connector: initialConnector },
{
command: { type: 'setConfigProperty' },
payload: {
key: 'testConfig',
value: 'new test config property',
},
}
);
expect(updatedConnector.connector.config.testConfig).toBe('new test config property');
const updatedConnectorUpdatedProperty = connectorReducer(
{ connector: updatedConnector.connector },
{
command: { type: 'setConfigProperty' },
payload: {
key: 'testConfig',
value: 'test config property updated',
},
}
);
expect(updatedConnectorUpdatedProperty.connector.config.testConfig).toBe(
'test config property updated'
);
});
test('if secrets property was added', () => {
const updatedConnector = connectorReducer(
{ connector: initialConnector },
{
command: { type: 'setSecretsProperty' },
payload: {
key: 'testSecret',
value: 'new test secret property',
},
}
);
expect(updatedConnector.connector.secrets.testSecret).toBe('new test secret property');
const updatedConnectorUpdatedProperty = connectorReducer(
{ connector: updatedConnector.connector },
{
command: { type: 'setSecretsProperty' },
payload: {
key: 'testSecret',
value: 'test secret property updated',
},
}
);
expect(updatedConnectorUpdatedProperty.connector.secrets.testSecret).toBe(
'test secret property updated'
);
});
});

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isEqual } from 'lodash';
interface CommandType {
type: 'setProperty' | 'setConfigProperty' | 'setSecretsProperty';
}
export interface ActionState {
connector: any;
}
export interface ReducerAction {
command: CommandType;
payload: {
key: string;
value: any;
};
}
export const connectorReducer = (state: ActionState, action: ReducerAction) => {
const { command, payload } = action;
const { connector } = state;
switch (command.type) {
case 'setProperty': {
const { key, value } = payload;
if (isEqual(connector[key], value)) {
return state;
} else {
return {
...state,
connector: {
...connector,
[key]: value,
},
};
}
}
case 'setConfigProperty': {
const { key, value } = payload;
if (isEqual(connector.config[key], value)) {
return state;
} else {
return {
...state,
connector: {
...connector,
config: {
...connector.config,
[key]: value,
},
},
};
}
}
case 'setSecretsProperty': {
const { key, value } = payload;
if (isEqual(connector.secrets[key], value)) {
return state;
} else {
return {
...state,
connector: {
...connector,
secrets: {
...connector.secrets,
[key]: value,
},
},
};
}
}
}
};

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
export { ConnectorAddFlyout } from './connector_add_flyout';
export { ConnectorEditFlyout } from './connector_edit_flyout';

View file

@ -0,0 +1,3 @@
.actConnectorsList__logo + .actConnectorsList__logo {
margin-left: $euiSize;
}

View file

@ -0,0 +1,362 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { ActionsConnectorsList } from './actions_connectors_list';
import { coreMock } from '../../../../../../../../../../src/core/public/mocks';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import { AppContextProvider } from '../../../app_context';
jest.mock('../../../lib/action_connector_api', () => ({
loadAllActions: jest.fn(),
loadActionTypes: jest.fn(),
}));
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('actions_connectors_list component empty', () => {
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
const { loadAllActions, loadActionTypes } = jest.requireMock(
'../../../lib/action_connector_api'
);
loadAllActions.mockResolvedValueOnce({
page: 1,
perPage: 10000,
total: 0,
data: [],
});
loadActionTypes.mockResolvedValueOnce([
{
id: 'test',
name: 'Test',
},
{
id: 'test2',
name: 'Test2',
},
]);
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
siem: {
'actions:show': true,
'actions:save': true,
'actions:delete': true,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
actionTypeRegistry.has.mockReturnValue(true);
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsList />
</AppContextProvider>
);
});
await waitForRender(wrapper);
});
it('renders empty prompt', () => {
expect(wrapper.find('EuiEmptyPrompt')).toHaveLength(1);
expect(
wrapper.find('[data-test-subj="createFirstActionButton"]').find('EuiButton')
).toHaveLength(1);
});
test('if click create button should render ConnectorAddFlyout', () => {
wrapper
.find('[data-test-subj="createFirstActionButton"]')
.first()
.simulate('click');
expect(wrapper.find('ConnectorAddFlyout')).toHaveLength(1);
});
});
describe('actions_connectors_list component with items', () => {
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
const { loadAllActions, loadActionTypes } = jest.requireMock(
'../../../lib/action_connector_api'
);
loadAllActions.mockResolvedValueOnce({
page: 1,
perPage: 10000,
total: 2,
data: [
{
id: '1',
actionTypeId: 'test',
description: 'My test',
referencedByCount: 1,
config: {},
},
{
id: '2',
actionTypeId: 'test2',
description: 'My test 2',
referencedByCount: 1,
config: {},
},
],
});
loadActionTypes.mockResolvedValueOnce([
{
id: 'test',
name: 'Test',
},
{
id: 'test2',
name: 'Test2',
},
]);
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
siem: {
'actions:show': true,
'actions:save': true,
'actions:delete': true,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: {
get() {
return null;
},
} as any,
alertTypeRegistry: {} as any,
};
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsList />
</AppContextProvider>
);
});
await waitForRender(wrapper);
expect(loadAllActions).toHaveBeenCalled();
});
it('renders table of connectors', () => {
expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1);
expect(wrapper.find('EuiTableRow')).toHaveLength(2);
});
test('if select item for edit should render ConnectorEditFlyout', () => {
wrapper
.find('[data-test-subj="edit1"]')
.first()
.simulate('click');
expect(wrapper.find('ConnectorEditFlyout')).toHaveLength(1);
});
});
describe('actions_connectors_list component empty with show only capability', () => {
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
const { loadAllActions, loadActionTypes } = jest.requireMock(
'../../../lib/action_connector_api'
);
loadAllActions.mockResolvedValueOnce({
page: 1,
perPage: 10000,
total: 0,
data: [],
});
loadActionTypes.mockResolvedValueOnce([
{
id: 'test',
name: 'Test',
},
{
id: 'test2',
name: 'Test2',
},
]);
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
siem: {
'actions:show': true,
'actions:save': false,
'actions:delete': false,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: {
get() {
return null;
},
} as any,
alertTypeRegistry: {} as any,
};
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsList />
</AppContextProvider>
);
});
await waitForRender(wrapper);
});
it('renders no permissions to create connector', () => {
expect(wrapper.find('[defaultMessage="No permissions to create connector"]')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="createActionButton"]')).toHaveLength(0);
});
});
describe('actions_connectors_list with show only capability', () => {
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
const { loadAllActions, loadActionTypes } = jest.requireMock(
'../../../lib/action_connector_api'
);
loadAllActions.mockResolvedValueOnce({
page: 1,
perPage: 10000,
total: 2,
data: [
{
id: '1',
actionTypeId: 'test',
description: 'My test',
referencedByCount: 1,
config: {},
},
{
id: '2',
actionTypeId: 'test2',
description: 'My test 2',
referencedByCount: 1,
config: {},
},
],
});
loadActionTypes.mockResolvedValueOnce([
{
id: 'test',
name: 'Test',
},
{
id: 'test2',
name: 'Test2',
},
]);
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
siem: {
'actions:show': true,
'actions:save': false,
'actions:delete': false,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: {
get() {
return null;
},
} as any,
alertTypeRegistry: {} as any,
};
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsList />
</AppContextProvider>
);
});
await waitForRender(wrapper);
});
it('renders table of connectors with delete button disabled', () => {
expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1);
expect(wrapper.find('EuiTableRow')).toHaveLength(2);
wrapper.find('EuiTableRow').forEach(elem => {
const deleteButton = elem.find('[data-test-subj="deleteConnector"]').first();
expect(deleteButton).toBeTruthy();
expect(deleteButton.prop('isDisabled')).toBeTruthy();
});
});
});
async function waitForRender(wrapper: ReactWrapper<any, any>) {
await Promise.resolve();
await Promise.resolve();
wrapper.update();
}

View file

@ -0,0 +1,399 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useState, useEffect } from 'react';
import {
EuiBadge,
EuiInMemoryTable,
EuiSpacer,
EuiButton,
EuiIcon,
EuiEmptyPrompt,
EuiTitle,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context';
import { useAppDependencies } from '../../../app_context';
import { loadAllActions, loadActionTypes } from '../../../lib/action_connector_api';
import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types';
import { ConnectorAddFlyout, ConnectorEditFlyout } from '../../action_connector_form';
import { hasDeleteActionsCapability, hasSaveActionsCapability } from '../../../lib/capabilities';
import { DeleteConnectorsModal } from '../../../components/delete_connectors_modal';
export const ActionsConnectorsList: React.FunctionComponent = () => {
const {
http,
toastNotifications,
legacy: { capabilities },
} = useAppDependencies();
const canDelete = hasDeleteActionsCapability(capabilities.get());
const canSave = hasSaveActionsCapability(capabilities.get());
const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined);
const [actions, setActions] = useState<ActionConnector[]>([]);
const [data, setData] = useState<ActionConnectorTableItem[]>([]);
const [selectedItems, setSelectedItems] = useState<ActionConnectorTableItem[]>([]);
const [isLoadingActionTypes, setIsLoadingActionTypes] = useState<boolean>(false);
const [isLoadingActions, setIsLoadingActions] = useState<boolean>(false);
const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false);
const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false);
const [actionTypesList, setActionTypesList] = useState<Array<{ value: string; name: string }>>(
[]
);
const [editedConnectorItem, setEditedConnectorItem] = useState<
ActionConnectorTableItem | undefined
>(undefined);
const [connectorsToDelete, setConnectorsToDelete] = useState<string[]>([]);
useEffect(() => {
loadActions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
(async () => {
try {
setIsLoadingActionTypes(true);
const actionTypes = await loadActionTypes({ http });
const index: ActionTypeIndex = {};
for (const actionTypeItem of actionTypes) {
index[actionTypeItem.id] = actionTypeItem;
}
setActionTypesIndex(index);
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage',
{ defaultMessage: 'Unable to load action types' }
),
});
} finally {
setIsLoadingActionTypes(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
// Avoid flickering before action types load
if (typeof actionTypesIndex === 'undefined') {
return;
}
// Update the data for the table
const updatedData = actions.map(action => {
return {
...action,
actionType: actionTypesIndex[action.actionTypeId]
? actionTypesIndex[action.actionTypeId].name
: action.actionTypeId,
};
});
setData(updatedData);
// Update the action types list for the filter
const actionTypes = Object.values(actionTypesIndex)
.map(actionType => ({
value: actionType.id,
name: `${actionType.name} (${getActionsCountByActionType(actions, actionType.id)})`,
}))
.sort((a, b) => a.name.localeCompare(b.name));
setActionTypesList(actionTypes);
}, [actions, actionTypesIndex]);
async function loadActions() {
setIsLoadingActions(true);
try {
const actionsResponse = await loadAllActions({ http });
setActions(actionsResponse.data);
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionsMessage',
{
defaultMessage: 'Unable to load actions',
}
),
});
} finally {
setIsLoadingActions(false);
}
}
async function editItem(connectorTableItem: ActionConnectorTableItem) {
setEditedConnectorItem(connectorTableItem);
setEditFlyoutVisibility(true);
}
const actionsTableColumns = [
{
field: 'name',
'data-test-subj': 'connectorsTableCell-name',
name: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.nameTitle',
{
defaultMessage: 'Name',
}
),
sortable: false,
truncateText: true,
render: (value: string, item: ActionConnectorTableItem) => {
return (
<EuiLink data-test-subj={`edit${item.id}`} onClick={() => editItem(item)} key={item.id}>
{value}
</EuiLink>
);
},
},
{
field: 'actionType',
'data-test-subj': 'connectorsTableCell-actionType',
name: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actionTypeTitle',
{
defaultMessage: 'Type',
}
),
sortable: false,
truncateText: true,
},
{
field: 'referencedByCount',
'data-test-subj': 'connectorsTableCell-referencedByCount',
name: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.referencedByCountTitle',
{ defaultMessage: 'Actions' }
),
sortable: false,
truncateText: true,
render: (value: number, item: ActionConnectorTableItem) => {
return (
<EuiBadge color="hollow" key={item.id}>
{value}
</EuiBadge>
);
},
},
{
field: '',
name: '',
actions: [
{
enabled: () => canDelete,
'data-test-subj': 'deleteConnector',
name: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionName',
{ defaultMessage: 'Delete' }
),
description: canDelete
? i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription',
{ defaultMessage: 'Delete this action' }
)
: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDisabledDescription',
{ defaultMessage: 'Unable to delete actions' }
),
type: 'icon',
icon: 'trash',
color: 'danger',
onClick: (item: ActionConnectorTableItem) => setConnectorsToDelete([item.id]),
},
],
},
];
const table = (
<EuiInMemoryTable
loading={isLoadingActions || isLoadingActionTypes}
items={data}
sorting={true}
itemId="id"
columns={actionsTableColumns}
rowProps={() => ({
'data-test-subj': 'connectors-row',
})}
cellProps={() => ({
'data-test-subj': 'cell',
})}
data-test-subj="actionsTable"
pagination={true}
selection={
canDelete
? {
onSelectionChange(updatedSelectedItemsList: ActionConnectorTableItem[]) {
setSelectedItems(updatedSelectedItemsList);
},
}
: undefined
}
search={{
filters: [
{
type: 'field_value_selection',
field: 'actionTypeId',
name: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.filters.actionTypeIdName',
{ defaultMessage: 'Type' }
),
multiSelect: 'or',
options: actionTypesList,
},
],
toolsLeft:
selectedItems.length === 0 || !canDelete
? []
: [
<EuiButton
key="delete"
iconType="trash"
color="danger"
data-test-subj="bulkDelete"
onClick={() => {
setConnectorsToDelete(selectedItems.map((selected: any) => selected.id));
}}
title={
canDelete
? undefined
: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteDisabledTitle',
{ defaultMessage: 'Unable to delete actions' }
)
}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteLabel"
defaultMessage="Delete ({count})"
values={{
count: selectedItems.length,
}}
/>
</EuiButton>,
],
toolsRight: [
<EuiButton
data-test-subj="createActionButton"
key="create-action"
fill
iconType="plusInCircle"
iconSide="left"
onClick={() => setAddFlyoutVisibility(true)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.addActionButtonLabel"
defaultMessage="Create connector"
/>
</EuiButton>,
],
}}
/>
);
const emptyPrompt = (
<EuiEmptyPrompt
data-test-subj="createFirstConnectorEmptyPrompt"
title={
<Fragment>
<EuiIcon type="logoSlack" size="xl" className="actConnectorsList__logo" />
<EuiIcon type="logoGmail" size="xl" className="actConnectorsList__logo" />
<EuiIcon type="logoWebhook" size="xl" className="actConnectorsList__logo" />
<EuiSpacer size="s" />
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.addActionEmptyTitle"
defaultMessage="Create your first connector"
/>
</h2>
</EuiTitle>
</Fragment>
}
body={
<p>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.addActionEmptyBody"
defaultMessage="Configure email, Slack, Elasticsearch, and third-party services that Kibana can trigger."
/>
</p>
}
actions={
<EuiButton
data-test-subj="createFirstActionButton"
key="create-action"
fill
iconType="plusInCircle"
iconSide="left"
onClick={() => setAddFlyoutVisibility(true)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.addActionButtonLabel"
defaultMessage="Create connector"
/>
</EuiButton>
}
/>
);
const noPermissionPrompt = (
<h2>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.noPermissionToCreateTitle"
defaultMessage="No permissions to create connector"
/>
</h2>
);
return (
<section data-test-subj="actionsList">
<DeleteConnectorsModal
callback={(deleted?: string[]) => {
if (deleted) {
if (selectedItems.length === 0 || selectedItems.length === deleted.length) {
const updatedActions = actions.filter(
action => action.id && !connectorsToDelete.includes(action.id)
);
setActions(updatedActions);
setSelectedItems([]);
} else {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.failedToDeleteActionsMessage',
{ defaultMessage: 'Failed to delete action(s)' }
),
});
// Refresh the actions from the server, some actions may have beend deleted
loadActions();
}
}
setConnectorsToDelete([]);
}}
connectorsToDelete={connectorsToDelete}
/>
<EuiSpacer size="m" />
{/* Render the view based on if there's data or if they can save */}
{data.length !== 0 && table}
{data.length === 0 && canSave && emptyPrompt}
{data.length === 0 && !canSave && noPermissionPrompt}
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible,
setAddFlyoutVisibility,
editFlyoutVisible,
setEditFlyoutVisibility,
actionTypesIndex,
reloadConnectors: loadActions,
}}
>
<ConnectorAddFlyout />
{editedConnectorItem ? <ConnectorEditFlyout connector={editedConnectorItem} /> : null}
</ActionsConnectorsContextProvider>
</section>
);
};
function getActionsCountByActionType(actions: ActionConnector[], actionTypeId: string) {
return actions.filter(action => action.actionTypeId === actionTypeId).length;
}

View file

@ -0,0 +1,803 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useState, useCallback, useReducer, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiTitle,
EuiForm,
EuiSpacer,
EuiButtonEmpty,
EuiFlyoutFooter,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiFlyout,
EuiFieldText,
EuiFlexGrid,
EuiFormRow,
EuiComboBox,
EuiKeyPadMenuItem,
EuiTabs,
EuiTab,
EuiLink,
EuiFieldNumber,
EuiSelect,
EuiIconTip,
EuiPortal,
EuiAccordion,
EuiButtonIcon,
} from '@elastic/eui';
import { useAppDependencies } from '../../app_context';
import { createAlert } from '../../lib/alert_api';
import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api';
import { useAlertsContext } from '../../context/alerts_context';
import { alertReducer } from './alert_reducer';
import {
AlertTypeModel,
Alert,
IErrorObject,
ActionTypeModel,
AlertAction,
ActionTypeIndex,
ActionConnector,
} from '../../../types';
import { ACTION_GROUPS } from '../../constants/action_groups';
import { getTimeOptions } from '../../lib/get_time_options';
import { SectionLoading } from '../../components/section_loading';
interface Props {
refreshList: () => Promise<void>;
}
function validateBaseProperties(alertObject: Alert) {
const validationResult = { errors: {} };
const errors = {
name: new Array<string>(),
interval: new Array<string>(),
alertTypeId: new Array<string>(),
actionConnectors: new Array<string>(),
};
validationResult.errors = errors;
if (!alertObject.name) {
errors.name.push(
i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredNameText', {
defaultMessage: 'Name is required.',
})
);
}
if (!alertObject.interval) {
errors.interval.push(
i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredIntervalText', {
defaultMessage: 'Check interval is required.',
})
);
}
if (!alertObject.alertTypeId) {
errors.alertTypeId.push(
i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredAlertTypeIdText', {
defaultMessage: 'Alert trigger is required.',
})
);
}
return validationResult;
}
export const AlertAdd = ({ refreshList }: Props) => {
const { http, toastNotifications, alertTypeRegistry, actionTypeRegistry } = useAppDependencies();
const initialAlert = {
params: {},
alertTypeId: null,
interval: '1m',
actions: [],
tags: [],
};
const { alertFlyoutVisible, setAlertFlyoutVisibility } = useAlertsContext();
// hooks
const [alertType, setAlertType] = useState<AlertTypeModel | undefined>(undefined);
const [{ alert }, dispatch] = useReducer(alertReducer, { alert: initialAlert });
const [isSaving, setIsSaving] = useState<boolean>(false);
const [isLoadingActionTypes, setIsLoadingActionTypes] = useState<boolean>(false);
const [selectedTabId, setSelectedTabId] = useState<string>('alert');
const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined);
const [alertInterval, setAlertInterval] = useState<number | null>(null);
const [alertIntervalUnit, setAlertIntervalUnit] = useState<string>('m');
const [alertThrottle, setAlertThrottle] = useState<number | null>(null);
const [alertThrottleUnit, setAlertThrottleUnit] = useState<string>('');
const [serverError, setServerError] = useState<{
body: { message: string; error: string };
} | null>(null);
const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState<boolean>(true);
const [connectors, setConnectors] = useState<ActionConnector[]>([]);
useEffect(() => {
(async () => {
try {
setIsLoadingActionTypes(true);
const actionTypes = await loadActionTypes({ http });
const index: ActionTypeIndex = {};
for (const actionTypeItem of actionTypes) {
index[actionTypeItem.id] = actionTypeItem;
}
setActionTypesIndex(index);
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertAdd.unableToLoadActionTypesMessage',
{ defaultMessage: 'Unable to load action types' }
),
});
} finally {
setIsLoadingActionTypes(false);
}
})();
}, [toastNotifications, http]);
useEffect(() => {
dispatch({
command: { type: 'setAlert' },
payload: {
key: 'alert',
value: {
params: {},
alertTypeId: null,
interval: '1m',
actions: [],
tags: [],
},
},
});
}, [alertFlyoutVisible]);
useEffect(() => {
loadConnectors();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [alertFlyoutVisible]);
const setAlertProperty = (key: string, value: any) => {
dispatch({ command: { type: 'setProperty' }, payload: { key, value } });
};
const setAlertParams = (key: string, value: any) => {
dispatch({ command: { type: 'setAlertParams' }, payload: { key, value } });
};
const setActionParamsProperty = (key: string, value: any, index: number) => {
dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } });
};
const setActionProperty = (key: string, value: any, index: number) => {
dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } });
};
const closeFlyout = useCallback(() => {
setAlertFlyoutVisibility(false);
setAlertType(undefined);
setIsAddActionPanelOpen(true);
setSelectedTabId('alert');
setServerError(null);
}, [setAlertFlyoutVisibility]);
if (!alertFlyoutVisible) {
return null;
}
const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : [];
async function loadConnectors() {
try {
const actionsResponse = await loadAllActions({ http });
setConnectors(actionsResponse.data);
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertAdd.unableToLoadActionsMessage',
{
defaultMessage: 'Unable to load connectors',
}
),
});
}
}
const AlertParamsExpressionComponent = alertType ? alertType.alertParamsExpression : null;
const errors = {
...(alertType ? alertType.validate(alert).errors : []),
...validateBaseProperties(alert).errors,
} as IErrorObject;
const hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1);
const actionErrors = alert.actions.reduce((acc: any, alertAction: AlertAction) => {
const actionTypeConnectors = connectors.find(field => field.id === alertAction.id);
if (!actionTypeConnectors) {
return [];
}
const actionType = actionTypeRegistry.get(actionTypeConnectors.actionTypeId);
if (!actionType) {
return [];
}
const actionValidationErrors = actionType.validateParams(alertAction.params);
acc[alertAction.id] = actionValidationErrors;
return acc;
}, {});
const hasActionErrors = !!Object.keys(actionErrors).find(actionError => {
return !!Object.keys(actionErrors[actionError]).find((actionErrorKey: string) => {
return actionErrors[actionError][actionErrorKey].length >= 1;
});
});
const tabs = [
{
id: ACTION_GROUPS.ALERT,
name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.alertTabText', {
defaultMessage: 'Alert',
}),
},
{
id: ACTION_GROUPS.WARNING,
name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.warningTabText', {
defaultMessage: 'Warning',
}),
},
{
id: ACTION_GROUPS.UNACKNOWLEDGED,
name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.unacknowledgedTabText', {
defaultMessage: 'If unacknowledged',
}),
disabled: false,
},
];
async function onSaveAlert(): Promise<any> {
try {
const newAlert = await createAlert({ http, alert });
toastNotifications.addSuccess(
i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText', {
defaultMessage: "Saved '{alertName}'",
values: {
alertName: newAlert.id,
},
})
);
return newAlert;
} catch (error) {
return {
error,
};
}
}
function addActionType(actionTypeModel: ActionTypeModel) {
setIsAddActionPanelOpen(false);
const actionTypeConnectors = connectors.filter(
field => field.actionTypeId === actionTypeModel.id
);
if (actionTypeConnectors.length > 0) {
alert.actions.push({ id: actionTypeConnectors[0].id, group: selectedTabId, params: {} });
}
}
const alertTypeNodes = alertTypeRegistry.list().map(function(item, index) {
return (
<EuiKeyPadMenuItem
key={index}
label={item.name}
onClick={() => {
setAlertProperty('alertTypeId', item.id);
setAlertType(item);
}}
>
<EuiIcon size="xl" type={item.iconClass} />
</EuiKeyPadMenuItem>
);
});
const actionTypeNodes = actionTypeRegistry.list().map(function(item, index) {
return (
<EuiKeyPadMenuItem
key={index}
label={actionTypesIndex ? actionTypesIndex[item.id].name : item.id}
onClick={() => addActionType(item)}
>
<EuiIcon size="xl" type={item.iconClass} />
</EuiKeyPadMenuItem>
);
});
const alertTabs = tabs.map(function(tab, index): any {
return (
<EuiTab
onClick={() => {
setSelectedTabId(tab.id);
if (!alert.actions.find((action: AlertAction) => action.group === tab.id)) {
setIsAddActionPanelOpen(true);
} else {
setIsAddActionPanelOpen(false);
}
}}
isSelected={tab.id === selectedTabId}
disabled={tab.disabled}
key={index}
>
{tab.name}
</EuiTab>
);
});
const alertTypeDetails = (
<Fragment>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>
<EuiTitle size="s">
<h5 id="selectedAlertTypeTitle">
<FormattedMessage
defaultMessage="Trigger: {alertType}"
id="xpack.triggersActionsUI.sections.alertAdd.selectedAlertTypeTitle"
values={{ alertType: alertType ? alertType.name : '' }}
/>
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
onClick={() => {
setAlertProperty('alertTypeId', null);
setAlertType(undefined);
}}
>
<FormattedMessage
defaultMessage="Change"
id="xpack.triggersActionsUI.sections.alertAdd.changeAlertTypeLink"
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
{AlertParamsExpressionComponent ? (
<AlertParamsExpressionComponent
alert={alert}
errors={errors}
setAlertParams={setAlertParams}
setAlertProperty={setAlertProperty}
hasErrors={hasErrors}
/>
) : null}
</Fragment>
);
const getSelectedOptions = (actionItemId: string) => {
const val = connectors.find(connector => connector.id === actionItemId);
if (!val) {
return [];
}
return [
{
label: val.name,
value: val.name,
id: actionItemId,
},
];
};
const actionsListForGroup = (
<Fragment>
{alert.actions.map((actionItem: AlertAction, index: number) => {
const actionConnector = connectors.find(field => field.id === actionItem.id);
if (!actionConnector) {
return null;
}
const optionsList = connectors
.filter(field => field.actionTypeId === actionConnector.actionTypeId)
.map(({ name, id }) => ({
label: name,
key: id,
id,
}));
const actionTypeRegisterd = actionTypeRegistry.get(actionConnector.actionTypeId);
if (actionTypeRegisterd === null || actionItem.group !== selectedTabId) return null;
const ParamsFieldsComponent = actionTypeRegisterd.actionParamsFields;
const actionParamsErrors =
Object.keys(actionErrors).length > 0 ? actionErrors[actionItem.id] : [];
const hasActionParamsErrors = !!Object.keys(actionParamsErrors).find(
errorKey => actionParamsErrors[errorKey].length >= 1
);
return (
<EuiAccordion
initialIsOpen={true}
key={index}
id={index.toString()}
className="euiAccordionForm"
buttonContentClassName="euiAccordionForm__button"
data-test-subj="alertActionAccordion"
buttonContent={
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type={actionTypeRegisterd.iconClass} size="m" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="s">
<h5>
<FormattedMessage
defaultMessage="Action: {actionConnectorName}"
id="xpack.triggersActionsUI.sections.alertAdd.selectAlertActionTypeEditTitle"
values={{ actionConnectorName: actionConnector.name }}
/>
</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
}
extraAction={
<EuiButtonIcon
iconType="cross"
color="danger"
className="euiAccordionForm__extraAction"
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.alertAdd.accordion.deleteIconAriaLabel',
{
defaultMessage: 'Delete',
}
)}
onClick={() => {
const updatedActions = alert.actions.filter(
(item: AlertAction) => item.id !== actionItem.id
);
setAlertProperty('actions', updatedActions);
}}
/>
}
paddingSize="l"
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.actionIdLabel"
defaultMessage="{connectorInstance} instance"
values={{
connectorInstance: actionTypesIndex
? actionTypesIndex[actionConnector.actionTypeId].name
: actionConnector.actionTypeId,
}}
/>
}
// errorKey="name"
// isShowingErrors={hasErrors}
// errors={errors}
>
<EuiComboBox
fullWidth
singleSelection={{ asPlainText: true }}
options={optionsList}
selectedOptions={getSelectedOptions(actionItem.id)}
onChange={selectedOptions => {
setActionProperty('id', selectedOptions[0].id, index);
}}
isClearable={false}
/>
</EuiFormRow>
<EuiSpacer size="s" />
{ParamsFieldsComponent ? (
<ParamsFieldsComponent
action={actionItem.params}
index={index}
errors={actionParamsErrors.errors}
editAction={setActionParamsProperty}
hasErrors={hasActionParamsErrors}
/>
) : null}
</EuiAccordion>
);
})}
<EuiSpacer size="m" />
{!isAddActionPanelOpen ? (
<EuiButton
iconType="plusInCircle"
data-test-subj="addAlertActionButton"
onClick={() => setIsAddActionPanelOpen(true)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.addActionButtonLabel"
defaultMessage="Add action"
/>
</EuiButton>
) : null}
</Fragment>
);
let alertTypeArea;
if (alertType) {
alertTypeArea = <Fragment>{alertTypeDetails}</Fragment>;
} else {
alertTypeArea = (
<Fragment>
<EuiTitle size="s">
<h5 id="alertTypeTitle">
<FormattedMessage
defaultMessage="Select a trigger"
id="xpack.triggersActionsUI.sections.alertAdd.selectAlertTypeTitle"
/>
</h5>
</EuiTitle>
<EuiSpacer />
<EuiFlexGroup gutterSize="s" wrap>
{alertTypeNodes}
</EuiFlexGroup>
</Fragment>
);
}
const labelForAlertChecked = (
<>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.checkFieldLabel"
defaultMessage="Check every"
/>{' '}
<EuiIconTip
position="right"
type="questionInCircle"
content={i18n.translate('xpack.triggersActionsUI.sections.alertAdd.checkWithTooltip', {
defaultMessage: 'This is some help text here for check alert.',
})}
/>
</>
);
const labelForAlertRenotify = (
<>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.renotifyFieldLabel"
defaultMessage="Re-notify every"
/>{' '}
<EuiIconTip
position="right"
type="questionInCircle"
content={i18n.translate('xpack.triggersActionsUI.sections.alertAdd.renotifyWithTooltip', {
defaultMessage: 'This is some help text here for re-notify alert.',
})}
/>
</>
);
return (
<EuiPortal>
<EuiFlyout
ownFocus
onClose={closeFlyout}
aria-labelledby="flyoutAlertAddTitle"
size="m"
maxWidth={620}
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h3 id="flyoutTitle">
<FormattedMessage
defaultMessage="Create Alert"
id="xpack.triggersActionsUI.sections.alertAdd.flyoutTitle"
/>
</h3>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiForm isInvalid={serverError !== null} error={serverError?.body.message}>
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiFormRow
fullWidth
id="alertName"
label={
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.alertNameLabel"
defaultMessage="Name"
/>
}
isInvalid={hasErrors && alert.name !== undefined}
error={errors.name}
>
<EuiFieldText
fullWidth
isInvalid={hasErrors && alert.name !== undefined}
compressed
name="name"
data-test-subj="alertNameInput"
value={alert.name || ''}
onChange={e => {
setAlertProperty('name', e.target.value);
}}
onBlur={() => {
if (!alert.name) {
setAlertProperty('name', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.sections.actionAdd.indexAction.indexTextFieldLabel',
{
defaultMessage: 'Tags (optional)',
}
)}
>
<EuiComboBox
noSuggestions
fullWidth
compressed
data-test-subj="tagsComboBox"
selectedOptions={tagsOptions}
onCreateOption={(searchValue: string) => {
const newOptions = [...tagsOptions, { label: searchValue }];
setAlertProperty(
'tags',
newOptions.map(newOption => newOption.label)
);
}}
onChange={(selectedOptions: Array<{ label: string }>) => {
setAlertProperty(
'tags',
selectedOptions.map(selectedOption => selectedOption.label)
);
}}
onBlur={() => {
if (!alert.tags) {
setAlertProperty('tags', []);
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGrid>
<EuiSpacer size="m" />
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiFormRow fullWidth compressed label={labelForAlertChecked}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFieldNumber
fullWidth
min={1}
compressed
value={alertInterval || 1}
name="interval"
data-test-subj="intervalInput"
onChange={e => {
const interval =
e.target.value !== '' ? parseInt(e.target.value, 10) : null;
setAlertInterval(interval);
setAlertProperty('interval', `${e.target.value}${alertIntervalUnit}`);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect
fullWidth
compressed
value={alertIntervalUnit}
options={getTimeOptions((alertInterval ? alertInterval : 1).toString())}
onChange={(e: any) => {
setAlertIntervalUnit(e.target.value);
setAlertProperty('interval', `${alertInterval}${e.target.value}`);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={labelForAlertRenotify}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFieldNumber
fullWidth
min={1}
compressed
value={alertThrottle || ''}
name="throttle"
data-test-subj="throttleInput"
onChange={e => {
const throttle =
e.target.value !== '' ? parseInt(e.target.value, 10) : null;
setAlertThrottle(throttle);
setAlertProperty('throttle', `${e.target.value}${alertThrottleUnit}`);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect
compressed
value={alert.renotifyIntervalUnit}
options={getTimeOptions(alert.renotifyIntervalSize)}
onChange={(e: any) => {
setAlertThrottleUnit(e.target.value);
setAlertProperty('throttle', `${alertThrottle}${e.target.value}`);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGrid>
<EuiSpacer size="m" />
<EuiTabs>{alertTabs}</EuiTabs>
<EuiSpacer size="m" />
{alertTypeArea}
<EuiSpacer size="xl" />
{actionsListForGroup}
{isAddActionPanelOpen ? (
<Fragment>
<EuiTitle size="s">
<h5 id="alertActionTypeTitle">
<FormattedMessage
defaultMessage="Select an action"
id="xpack.triggersActionsUI.sections.alertAdd.selectAlertActionTypeTitle"
/>
</h5>
</EuiTitle>
<EuiSpacer />
<EuiFlexGroup gutterSize="s" wrap>
{isLoadingActionTypes ? (
<SectionLoading>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.loadingActionTypesDescription"
defaultMessage="Loading action types…"
/>
</SectionLoading>
) : (
actionTypeNodes
)}
</EuiFlexGroup>
</Fragment>
) : null}
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={closeFlyout}>
{i18n.translate('xpack.triggersActionsUI.sections.alertAdd.cancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveActionButton"
type="submit"
iconType="check"
isDisabled={hasErrors || hasActionErrors}
isLoading={isSaving}
onClick={async () => {
setIsSaving(true);
const savedAlert = await onSaveAlert();
setIsSaving(false);
if (savedAlert && savedAlert.error) {
return setServerError(savedAlert.error);
}
closeFlyout();
refreshList();
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertAdd.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
);
};

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isEqual } from 'lodash';
interface CommandType {
type:
| 'setAlert'
| 'setProperty'
| 'setAlertParams'
| 'setAlertActionParams'
| 'setAlertActionProperty';
}
export interface AlertState {
alert: any;
}
export interface AlertReducerAction {
command: CommandType;
payload: {
key: string;
value: {};
index?: number;
};
}
export const alertReducer = (state: any, action: AlertReducerAction) => {
const { command, payload } = action;
const { alert } = state;
switch (command.type) {
case 'setAlert': {
const { key, value } = payload;
if (key === 'alert') {
return {
...state,
alert: value,
};
} else {
return state;
}
}
case 'setProperty': {
const { key, value } = payload;
if (isEqual(alert[key], value)) {
return state;
} else {
return {
...state,
alert: {
...alert,
[key]: value,
},
};
}
}
case 'setAlertParams': {
const { key, value } = payload;
if (isEqual(alert.params[key], value)) {
return state;
} else {
return {
...state,
alert: {
...alert,
params: {
...alert.params,
[key]: value,
},
},
};
}
}
case 'setAlertActionParams': {
const { key, value, index } = payload;
if (index === undefined || isEqual(alert.actions[index][key], value)) {
return state;
} else {
const oldAction = alert.actions.splice(index, 1)[0];
const updatedAction = {
...oldAction,
params: {
...oldAction.params,
[key]: value,
},
};
alert.actions.splice(index, 0, updatedAction);
return {
...state,
alert: {
...alert,
actions: [...alert.actions],
},
};
}
}
case 'setAlertActionProperty': {
const { key, value, index } = payload;
if (index === undefined || isEqual(alert.actions[index][key], value)) {
return state;
} else {
const oldAction = alert.actions.splice(index, 1)[0];
const updatedAction = {
...oldAction,
[key]: value,
};
alert.actions.splice(index, 0, updatedAction);
return {
...state,
alert: {
...alert,
actions: [...alert.actions],
},
};
}
}
}
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { AlertAdd } from './alert_add';

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui';
import { ActionType } from '../../../../types';
interface ActionTypeFilterProps {
actionTypes: ActionType[];
onChange?: (selectedActionTypeIds: string[]) => void;
}
export const ActionTypeFilter: React.FunctionComponent<ActionTypeFilterProps> = ({
actionTypes,
onChange,
}: ActionTypeFilterProps) => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
useEffect(() => {
if (onChange) {
onChange(selectedValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedValues]);
return (
<EuiFilterGroup>
<EuiPopover
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
button={
<EuiFilterButton
iconType="arrowDown"
hasActiveFilters={selectedValues.length > 0}
numActiveFilters={selectedValues.length}
numFilters={selectedValues.length}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.actionTypeFilterLabel"
defaultMessage="Action type"
/>
</EuiFilterButton>
}
>
<div className="euiFilterSelect__items">
{actionTypes.map(item => (
<EuiFilterSelectItem
key={item.id}
onClick={() => {
const isPreviouslyChecked = selectedValues.includes(item.id);
if (isPreviouslyChecked) {
setSelectedValues(selectedValues.filter(val => val !== item.id));
} else {
setSelectedValues(selectedValues.concat(item.id));
}
}}
checked={selectedValues.includes(item.id) ? 'on' : undefined}
>
{item.name}
</EuiFilterSelectItem>
))}
</div>
</EuiPopover>
</EuiFilterGroup>
);
};

View file

@ -0,0 +1,453 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../../../../src/core/public/mocks';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import { alertTypeRegistryMock } from '../../../alert_type_registry.mock';
import { AlertsList } from './alerts_list';
import { ValidationResult } from '../../../../types';
import { AppContextProvider } from '../../../app_context';
jest.mock('../../../lib/action_connector_api', () => ({
loadActionTypes: jest.fn(),
loadAllActions: jest.fn(),
}));
jest.mock('../../../lib/alert_api', () => ({
loadAlerts: jest.fn(),
loadAlertTypes: jest.fn(),
}));
const actionTypeRegistry = actionTypeRegistryMock.create();
const alertTypeRegistry = alertTypeRegistryMock.create();
const alertType = {
id: 'test_alert_type',
name: 'some alert type',
iconClass: 'test',
validate: (): ValidationResult => {
return { errors: {} };
},
alertParamsExpression: () => null,
};
alertTypeRegistry.list.mockReturnValue([alertType]);
actionTypeRegistry.list.mockReturnValue([]);
describe('alerts_list component empty', () => {
let wrapper: ReactWrapper<any>;
beforeEach(async () => {
const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api');
const { loadActionTypes, loadAllActions } = jest.requireMock(
'../../../lib/action_connector_api'
);
loadAlerts.mockResolvedValue({
page: 1,
perPage: 10000,
total: 0,
data: [],
});
loadActionTypes.mockResolvedValue([
{
id: 'test',
name: 'Test',
},
{
id: 'test2',
name: 'Test2',
},
]);
loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]);
loadAllActions.mockResolvedValue({
page: 1,
perPage: 10000,
total: 0,
data: [],
});
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: {
getInjectedVar(name: string) {
if (name === 'createAlertUiEnabled') {
return true;
}
},
} as any,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
siem: {
'alerting:show': true,
'alerting:save': true,
'alerting:delete': true,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: alertTypeRegistry as any,
};
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<AlertsList />
</AppContextProvider>
);
});
await waitForRender(wrapper);
});
it('renders empty list', () => {
expect(wrapper.find('[data-test-subj="createAlertButton"]').find('EuiButton')).toHaveLength(1);
});
test('if click create button should render AlertAdd', () => {
wrapper
.find('[data-test-subj="createAlertButton"]')
.first()
.simulate('click');
expect(wrapper.find('AlertAdd')).toHaveLength(1);
});
});
describe('alerts_list component with items', () => {
let wrapper: ReactWrapper<any>;
beforeEach(async () => {
const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api');
const { loadActionTypes, loadAllActions } = jest.requireMock(
'../../../lib/action_connector_api'
);
loadAlerts.mockResolvedValue({
page: 1,
perPage: 10000,
total: 2,
data: [
{
id: '1',
name: 'test alert',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
interval: '5d',
actions: [],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
},
{
id: '2',
name: 'test alert 2',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
interval: '5d',
actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
},
],
});
loadActionTypes.mockResolvedValue([
{
id: 'test',
name: 'Test',
},
{
id: 'test2',
name: 'Test2',
},
]);
loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]);
loadAllActions.mockResolvedValue({
page: 1,
perPage: 10000,
total: 0,
data: [],
});
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: {
getInjectedVar(name: string) {
if (name === 'createAlertUiEnabled') {
return true;
}
},
} as any,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
siem: {
'alerting:show': true,
'alerting:save': true,
'alerting:delete': true,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: alertTypeRegistry as any,
};
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<AlertsList />
</AppContextProvider>
);
});
await waitForRender(wrapper);
expect(loadAlerts).toHaveBeenCalled();
expect(loadActionTypes).toHaveBeenCalled();
});
it('renders table of connectors', () => {
expect(wrapper.find('EuiBasicTable')).toHaveLength(1);
expect(wrapper.find('EuiTableRow')).toHaveLength(2);
});
});
describe('alerts_list component empty with show only capability', () => {
let wrapper: ReactWrapper<any>;
beforeEach(async () => {
const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api');
const { loadActionTypes, loadAllActions } = jest.requireMock(
'../../../lib/action_connector_api'
);
loadAlerts.mockResolvedValue({
page: 1,
perPage: 10000,
total: 0,
data: [],
});
loadActionTypes.mockResolvedValue([
{
id: 'test',
name: 'Test',
},
{
id: 'test2',
name: 'Test2',
},
]);
loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]);
loadAllActions.mockResolvedValue({
page: 1,
perPage: 10000,
total: 0,
data: [],
});
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: {
getInjectedVar(name: string) {
if (name === 'createAlertUiEnabled') {
return true;
}
},
} as any,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
siem: {
'alerting:show': true,
'alerting:save': false,
'alerting:delete': false,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: {
get() {
return null;
},
} as any,
alertTypeRegistry: {} as any,
};
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<AlertsList />
</AppContextProvider>
);
});
await waitForRender(wrapper);
});
it('not renders create alert button', () => {
expect(wrapper.find('[data-test-subj="createAlertButton"]')).toHaveLength(0);
});
});
describe('alerts_list with show only capability', () => {
let wrapper: ReactWrapper<any>;
beforeEach(async () => {
const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api');
const { loadActionTypes, loadAllActions } = jest.requireMock(
'../../../lib/action_connector_api'
);
loadAlerts.mockResolvedValue({
page: 1,
perPage: 10000,
total: 2,
data: [
{
id: '1',
name: 'test alert',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
interval: '5d',
actions: [],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
},
{
id: '2',
name: 'test alert 2',
tags: ['tag1'],
enabled: true,
alertTypeId: 'test_alert_type',
interval: '5d',
actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }],
params: { name: 'test alert type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
},
],
});
loadActionTypes.mockResolvedValue([
{
id: 'test',
name: 'Test',
},
{
id: 'test2',
name: 'Test2',
},
]);
loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]);
loadAllActions.mockResolvedValue({
page: 1,
perPage: 10000,
total: 0,
data: [],
});
const mockes = coreMock.createSetup();
const [{ chrome, docLinks }] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: {
getInjectedVar(name: string) {
if (name === 'createAlertUiEnabled') {
return true;
}
},
} as any,
http: mockes.http,
uiSettings: mockes.uiSettings,
legacy: {
capabilities: {
get() {
return {
siem: {
'alerting:show': true,
'alerting:save': false,
'alerting:delete': false,
},
};
},
} as any,
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: alertTypeRegistry as any,
};
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<AlertsList />
</AppContextProvider>
);
});
await waitForRender(wrapper);
});
it('renders table of alerts with delete button disabled', () => {
expect(wrapper.find('EuiBasicTable')).toHaveLength(1);
expect(wrapper.find('EuiTableRow')).toHaveLength(2);
// TODO: check delete button
});
});
async function waitForRender(wrapper: ReactWrapper<any, any>) {
await Promise.resolve();
await Promise.resolve();
wrapper.update();
}

View file

@ -0,0 +1,330 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { Fragment, useEffect, useState } from 'react';
import {
EuiBasicTable,
EuiButton,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiSpacer,
} from '@elastic/eui';
import { AlertsContextProvider } from '../../../context/alerts_context';
import { useAppDependencies } from '../../../app_context';
import { ActionType, Alert, AlertTableItem, AlertTypeIndex, Pagination } from '../../../../types';
import { AlertAdd } from '../../alert_add';
import { BulkActionPopover } from './bulk_action_popover';
import { CollapsedItemActions } from './collapsed_item_actions';
import { TypeFilter } from './type_filter';
import { ActionTypeFilter } from './action_type_filter';
import { loadAlerts, loadAlertTypes } from '../../../lib/alert_api';
import { loadActionTypes } from '../../../lib/action_connector_api';
import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities';
const ENTER_KEY = 13;
export const AlertsList: React.FunctionComponent = () => {
const {
http,
injectedMetadata,
toastNotifications,
legacy: { capabilities },
} = useAppDependencies();
const canDelete = hasDeleteAlertsCapability(capabilities.get());
const canSave = hasSaveAlertsCapability(capabilities.get());
const createAlertUiEnabled = injectedMetadata.getInjectedVar('createAlertUiEnabled');
const [actionTypes, setActionTypes] = useState<ActionType[]>([]);
const [alertTypesIndex, setAlertTypesIndex] = useState<AlertTypeIndex | undefined>(undefined);
const [alerts, setAlerts] = useState<Alert[]>([]);
const [data, setData] = useState<AlertTableItem[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isLoadingAlertTypes, setIsLoadingAlertTypes] = useState<boolean>(false);
const [isLoadingAlerts, setIsLoadingAlerts] = useState<boolean>(false);
const [isPerformingAction, setIsPerformingAction] = useState<boolean>(false);
const [totalItemCount, setTotalItemCount] = useState<number>(0);
const [page, setPage] = useState<Pagination>({ index: 0, size: 10 });
const [searchText, setSearchText] = useState<string | undefined>();
const [inputText, setInputText] = useState<string | undefined>();
const [typesFilter, setTypesFilter] = useState<string[]>([]);
const [actionTypesFilter, setActionTypesFilter] = useState<string[]>([]);
const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState<boolean>(false);
useEffect(() => {
loadAlertsData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, searchText, typesFilter, actionTypesFilter]);
useEffect(() => {
(async () => {
try {
setIsLoadingAlertTypes(true);
const alertTypes = await loadAlertTypes({ http });
const index: AlertTypeIndex = {};
for (const alertType of alertTypes) {
index[alertType.id] = alertType;
}
setAlertTypesIndex(index);
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertTypesMessage',
{ defaultMessage: 'Unable to load alert types' }
),
});
} finally {
setIsLoadingAlertTypes(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
(async () => {
try {
const result = await loadActionTypes({ http });
setActionTypes(result);
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.unableToLoadActionTypesMessage',
{ defaultMessage: 'Unable to load action types' }
),
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
// Avoid flickering before alert types load
if (typeof alertTypesIndex === 'undefined') {
return;
}
const updatedData = alerts.map(alert => ({
...alert,
tagsText: alert.tags.join(', '),
alertType: alertTypesIndex[alert.alertTypeId]
? alertTypesIndex[alert.alertTypeId].name
: alert.alertTypeId,
}));
setData(updatedData);
}, [alerts, alertTypesIndex]);
async function loadAlertsData() {
setIsLoadingAlerts(true);
try {
const alertsResponse = await loadAlerts({
http,
page,
searchText,
typesFilter,
actionTypesFilter,
});
setAlerts(alertsResponse.data);
setTotalItemCount(alertsResponse.total);
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsMessage',
{
defaultMessage: 'Unable to load alerts',
}
),
});
} finally {
setIsLoadingAlerts(false);
}
}
const alertsTableColumns = [
{
field: 'name',
name: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.nameTitle',
{ defaultMessage: 'Name' }
),
sortable: false,
truncateText: true,
'data-test-subj': 'alertsTableCell-name',
},
{
field: 'tagsText',
name: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.tagsText',
{ defaultMessage: 'Tags' }
),
sortable: false,
'data-test-subj': 'alertsTableCell-tagsText',
},
{
field: 'alertType',
name: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.alertTypeTitle',
{ defaultMessage: 'Type' }
),
sortable: false,
truncateText: true,
'data-test-subj': 'alertsTableCell-alertType',
},
{
field: 'schedule.interval',
name: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.intervalTitle',
{ defaultMessage: 'Runs every' }
),
sortable: false,
truncateText: false,
'data-test-subj': 'alertsTableCell-interval',
},
{
name: '',
width: '40px',
render(item: AlertTableItem) {
return (
<CollapsedItemActions key={item.id} item={item} onAlertChanged={() => loadAlertsData()} />
);
},
},
];
const toolsRight = [
<TypeFilter
key="type-filter"
onChange={(types: string[]) => setTypesFilter(types)}
options={Object.values(alertTypesIndex || {})
.map(alertType => ({
value: alertType.id,
name: alertType.name,
}))
.sort((a, b) => a.name.localeCompare(b.name))}
/>,
<ActionTypeFilter
key="action-type-filter"
actionTypes={actionTypes}
onChange={(ids: string[]) => setActionTypesFilter(ids)}
/>,
];
if (canSave && createAlertUiEnabled) {
toolsRight.push(
<EuiButton
key="create-alert"
data-test-subj="createAlertButton"
fill
iconType="plusInCircle"
iconSide="left"
onClick={() => setAlertFlyoutVisibility(true)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.addActionButtonLabel"
defaultMessage="Create"
/>
</EuiButton>
);
}
return (
<section data-test-subj="alertsList">
<Fragment>
<EuiSpacer size="m" />
<AlertsContextProvider value={{ alertFlyoutVisible, setAlertFlyoutVisibility }}>
<EuiFlexGroup>
{selectedIds.length > 0 && canDelete && (
<EuiFlexItem grow={false}>
<BulkActionPopover
selectedItems={pickFromData(data, selectedIds)}
onPerformingAction={() => setIsPerformingAction(true)}
onActionPerformed={() => {
loadAlertsData();
setIsPerformingAction(false);
}}
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiFieldText
fullWidth
data-test-subj="alertSearchField"
prepend={<EuiIcon type="search" />}
onChange={e => setInputText(e.target.value)}
onKeyUp={e => {
if (e.keyCode === ENTER_KEY) {
setSearchText(inputText);
}
}}
placeholder={i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.searchPlaceholderTitle',
{ defaultMessage: 'Search...' }
)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup>
{toolsRight.map((tool, index: number) => (
<EuiFlexItem key={index} grow={false}>
{tool}
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{/* Large to remain consistent with ActionsList table spacing */}
<EuiSpacer size="l" />
<EuiBasicTable
loading={isLoadingAlerts || isLoadingAlertTypes || isPerformingAction}
items={data}
itemId="id"
columns={alertsTableColumns}
rowProps={() => ({
'data-test-subj': 'alert-row',
})}
cellProps={() => ({
'data-test-subj': 'cell',
})}
data-test-subj="alertsList"
pagination={{
pageIndex: page.index,
pageSize: page.size,
totalItemCount,
}}
selection={
canDelete
? {
onSelectionChange(updatedSelectedItemsList: AlertTableItem[]) {
setSelectedIds(updatedSelectedItemsList.map(item => item.id));
},
}
: undefined
}
onChange={({ page: changedPage }: { page: Pagination }) => {
setPage(changedPage);
}}
/>
<AlertAdd refreshList={loadAlertsData} />
</AlertsContextProvider>
</Fragment>
</section>
);
};
function pickFromData(data: AlertTableItem[], ids: string[]): AlertTableItem[] {
const result: AlertTableItem[] = [];
for (const id of ids) {
const match = data.find(item => item.id === id);
if (match) {
result.push(match);
}
}
return result;
}

View file

@ -0,0 +1,253 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiButton, EuiButtonEmpty, EuiFormRow, EuiPopover } from '@elastic/eui';
import { AlertTableItem } from '../../../../types';
import { useAppDependencies } from '../../../app_context';
import {
deleteAlerts,
disableAlerts,
enableAlerts,
muteAlerts,
unmuteAlerts,
} from '../../../lib/alert_api';
export interface ComponentOpts {
selectedItems: AlertTableItem[];
onPerformingAction: () => void;
onActionPerformed: () => void;
}
export const BulkActionPopover: React.FunctionComponent<ComponentOpts> = ({
selectedItems,
onPerformingAction,
onActionPerformed,
}: ComponentOpts) => {
const { http, toastNotifications } = useAppDependencies();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [isMutingAlerts, setIsMutingAlerts] = useState<boolean>(false);
const [isUnmutingAlerts, setIsUnmutingAlerts] = useState<boolean>(false);
const [isEnablingAlerts, setIsEnablingAlerts] = useState<boolean>(false);
const [isDisablingAlerts, setIsDisablingAlerts] = useState<boolean>(false);
const [isDeletingAlerts, setIsDeletingAlerts] = useState<boolean>(false);
const allAlertsMuted = selectedItems.every(isAlertMuted);
const allAlertsDisabled = selectedItems.every(isAlertDisabled);
const isPerformingAction =
isMutingAlerts || isUnmutingAlerts || isEnablingAlerts || isDisablingAlerts || isDeletingAlerts;
async function onmMuteAllClick() {
onPerformingAction();
setIsMutingAlerts(true);
const ids = selectedItems.filter(item => !isAlertMuted(item)).map(item => item.id);
try {
await muteAlerts({ http, ids });
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToMuteAlertsMessage',
{
defaultMessage: 'Failed to mute alert(s)',
}
),
});
} finally {
setIsMutingAlerts(false);
onActionPerformed();
}
}
async function onUnmuteAllClick() {
onPerformingAction();
setIsUnmutingAlerts(true);
const ids = selectedItems.filter(isAlertMuted).map(item => item.id);
try {
await unmuteAlerts({ http, ids });
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToUnmuteAlertsMessage',
{
defaultMessage: 'Failed to unmute alert(s)',
}
),
});
} finally {
setIsUnmutingAlerts(false);
onActionPerformed();
}
}
async function onEnableAllClick() {
onPerformingAction();
setIsEnablingAlerts(true);
const ids = selectedItems.filter(isAlertDisabled).map(item => item.id);
try {
await enableAlerts({ http, ids });
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToEnableAlertsMessage',
{
defaultMessage: 'Failed to enable alert(s)',
}
),
});
} finally {
setIsEnablingAlerts(false);
onActionPerformed();
}
}
async function onDisableAllClick() {
onPerformingAction();
setIsDisablingAlerts(true);
const ids = selectedItems.filter(item => !isAlertDisabled(item)).map(item => item.id);
try {
await disableAlerts({ http, ids });
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDisableAlertsMessage',
{
defaultMessage: 'Failed to disable alert(s)',
}
),
});
} finally {
setIsDisablingAlerts(false);
onActionPerformed();
}
}
async function deleteSelectedItems() {
onPerformingAction();
setIsDeletingAlerts(true);
const ids = selectedItems.map(item => item.id);
try {
await deleteAlerts({ http, ids });
} catch (e) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDeleteAlertsMessage',
{
defaultMessage: 'Failed to delete alert(s)',
}
),
});
} finally {
setIsDeletingAlerts(false);
onActionPerformed();
}
}
return (
<EuiPopover
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
data-test-subj="bulkAction"
button={
<EuiButton
iconType="arrowDown"
iconSide="right"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.buttonTitle"
defaultMessage="Manage alerts"
/>
</EuiButton>
}
>
{!allAlertsMuted && (
<EuiFormRow>
<EuiButtonEmpty
onClick={onmMuteAllClick}
isLoading={isMutingAlerts}
isDisabled={isPerformingAction}
data-test-subj="muteAll"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.muteAllTitle"
defaultMessage="Mute"
/>
</EuiButtonEmpty>
</EuiFormRow>
)}
{allAlertsMuted && (
<EuiFormRow>
<EuiButtonEmpty
onClick={onUnmuteAllClick}
isLoading={isUnmutingAlerts}
isDisabled={isPerformingAction}
data-test-subj="unmuteAll"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.unmuteAllTitle"
defaultMessage="Unmute"
/>
</EuiButtonEmpty>
</EuiFormRow>
)}
{allAlertsDisabled && (
<EuiFormRow>
<EuiButtonEmpty
onClick={onEnableAllClick}
isLoading={isEnablingAlerts}
isDisabled={isPerformingAction}
data-test-subj="enableAll"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.enableAllTitle"
defaultMessage="Enable"
/>
</EuiButtonEmpty>
</EuiFormRow>
)}
{!allAlertsDisabled && (
<EuiFormRow>
<EuiButtonEmpty
onClick={onDisableAllClick}
isLoading={isDisablingAlerts}
isDisabled={isPerformingAction}
data-test-subj="disableAll"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.disableAllTitle"
defaultMessage="Disable"
/>
</EuiButtonEmpty>
</EuiFormRow>
)}
<EuiFormRow>
<EuiButtonEmpty
onClick={deleteSelectedItems}
isLoading={isDeletingAlerts}
isDisabled={isPerformingAction}
data-test-subj="deleteAll"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.deleteAllTitle"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
</EuiFormRow>
</EuiPopover>
);
};
function isAlertDisabled(alert: AlertTableItem) {
return alert.enabled === false;
}
function isAlertMuted(alert: AlertTableItem) {
return alert.muteAll === true;
}

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiFormRow,
EuiPopover,
EuiPopoverFooter,
EuiSwitch,
} from '@elastic/eui';
import { AlertTableItem } from '../../../../types';
import { useAppDependencies } from '../../../app_context';
import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities';
import {
deleteAlerts,
disableAlerts,
enableAlerts,
muteAlerts,
unmuteAlerts,
} from '../../../lib/alert_api';
export interface ComponentOpts {
item: AlertTableItem;
onAlertChanged: () => void;
}
export const CollapsedItemActions: React.FunctionComponent<ComponentOpts> = ({
item,
onAlertChanged,
}: ComponentOpts) => {
const {
http,
legacy: { capabilities },
} = useAppDependencies();
const canDelete = hasDeleteAlertsCapability(capabilities.get());
const canSave = hasSaveAlertsCapability(capabilities.get());
const [isEnabled, setIsEnabled] = useState<boolean>(item.enabled);
const [isMuted, setIsMuted] = useState<boolean>(item.muteAll);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const button = (
<EuiButtonIcon
disabled={!canDelete && !canSave}
iconType="boxesVertical"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.popoverButtonTitle',
{ defaultMessage: 'Actions' }
)}
/>
);
return (
<EuiPopover
button={button}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
ownFocus
data-test-subj="collapsedItemActions"
>
<EuiFormRow>
<EuiSwitch
name="enable"
disabled={!canSave}
checked={isEnabled}
data-test-subj="enableSwitch"
onChange={async () => {
if (isEnabled) {
setIsEnabled(false);
await disableAlerts({ http, ids: [item.id] });
} else {
setIsEnabled(true);
await enableAlerts({ http, ids: [item.id] });
}
onAlertChanged();
}}
label={
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.enableTitle"
defaultMessage="Enable"
/>
}
/>
</EuiFormRow>
<EuiFormRow>
<EuiSwitch
name="mute"
checked={isMuted}
disabled={!canSave || !isEnabled}
data-test-subj="muteSwitch"
onChange={async () => {
if (isMuted) {
setIsMuted(false);
await unmuteAlerts({ http, ids: [item.id] });
} else {
setIsMuted(true);
await muteAlerts({ http, ids: [item.id] });
}
onAlertChanged();
}}
label={
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.muteTitle"
defaultMessage="Mute"
/>
}
/>
</EuiFormRow>
<EuiPopoverFooter>
<EuiFormRow>
<EuiButtonEmpty
isDisabled={!canDelete}
iconType="trash"
color="text"
data-test-subj="deleteAlert"
onClick={async () => {
await deleteAlerts({ http, ids: [item.id] });
onAlertChanged();
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.deleteTitle"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
</EuiFormRow>
</EuiPopoverFooter>
</EuiPopover>
);
};

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui';
interface TypeFilterProps {
options: Array<{
value: string;
name: string;
}>;
onChange?: (selectedTags: string[]) => void;
}
export const TypeFilter: React.FunctionComponent<TypeFilterProps> = ({
options,
onChange,
}: TypeFilterProps) => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
useEffect(() => {
if (onChange) {
onChange(selectedValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedValues]);
return (
<EuiFilterGroup>
<EuiPopover
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
button={
<EuiFilterButton
iconType="arrowDown"
hasActiveFilters={selectedValues.length > 0}
numActiveFilters={selectedValues.length}
numFilters={selectedValues.length}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.typeFilterLabel"
defaultMessage="Type"
/>
</EuiFilterButton>
}
>
<div className="euiFilterSelect__items">
{options.map((item, index) => (
<EuiFilterSelectItem
key={index}
onClick={() => {
const isPreviouslyChecked = selectedValues.includes(item.value);
if (isPreviouslyChecked) {
setSelectedValues(selectedValues.filter(val => val !== item.value));
} else {
setSelectedValues(selectedValues.concat(item.value));
}
}}
checked={selectedValues.includes(item.value) ? 'on' : undefined}
>
{item.name}
</EuiFilterSelectItem>
))}
</div>
</EuiPopover>
</EuiFilterGroup>
);
};

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { TypeRegistry } from './type_registry';
import { ValidationResult, AlertTypeModel, ActionTypeModel } from '../types';
export const ExpressionComponent: React.FunctionComponent = () => {
return null;
};
const getTestAlertType = (id?: string, name?: string, iconClass?: string) => {
return {
id: id || 'test-alet-type',
name: name || 'Test alert type',
iconClass: iconClass || 'icon',
validate: (): ValidationResult => {
return { errors: {} };
},
alertParamsExpression: ExpressionComponent,
};
};
const getTestActionType = (id?: string, iconClass?: string, selectedMessage?: string) => {
return {
id: id || 'my-action-type',
iconClass: iconClass || 'test',
selectMessage: selectedMessage || 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
};
beforeEach(() => jest.resetAllMocks());
describe('register()', () => {
test('able to register alert types', () => {
const alertTypeRegistry = new TypeRegistry<AlertTypeModel>();
alertTypeRegistry.register(getTestAlertType());
expect(alertTypeRegistry.has('test-alet-type')).toEqual(true);
});
test('throws error if alert type already registered', () => {
const alertTypeRegistry = new TypeRegistry<AlertTypeModel>();
alertTypeRegistry.register(getTestAlertType('my-test-alert-type-1'));
expect(() =>
alertTypeRegistry.register(getTestAlertType('my-test-alert-type-1'))
).toThrowErrorMatchingInlineSnapshot(
`"Object type \\"my-test-alert-type-1\\" is already registered."`
);
});
});
describe('get()', () => {
test('returns action type', () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getTestActionType('my-action-type-snapshot'));
const actionType = actionTypeRegistry.get('my-action-type-snapshot');
expect(actionType).toMatchInlineSnapshot(`
Object {
"actionConnectorFields": null,
"actionParamsFields": null,
"iconClass": "test",
"id": "my-action-type-snapshot",
"selectMessage": "test",
"validateConnector": [Function],
"validateParams": [Function],
}
`);
});
test(`return null when action type doesn't exist`, () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
expect(actionTypeRegistry.get('not-exist-action-type')).toBeNull();
});
});
describe('list()', () => {
test('returns list of action types', () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getTestActionType());
const actionTypes = actionTypeRegistry.list();
expect(actionTypes).toEqual([
{
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
actionConnectorFields: null,
actionParamsFields: null,
validateConnector: actionTypes[0].validateConnector,
validateParams: actionTypes[0].validateParams,
},
]);
});
});
describe('has()', () => {
test('returns false for unregistered alert types', () => {
const alertTypeRegistry = new TypeRegistry<AlertTypeModel>();
expect(alertTypeRegistry.has('my-alert-type')).toEqual(false);
});
test('returns true after registering an alert type', () => {
const alertTypeRegistry = new TypeRegistry<AlertTypeModel>();
alertTypeRegistry.register(getTestAlertType());
expect(alertTypeRegistry.has('test-alet-type'));
});
});

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
interface BaseObjectType {
id: string;
}
export class TypeRegistry<T extends BaseObjectType> {
private readonly objectTypes: Map<string, T> = new Map();
/**
* Returns if the object type registry has the given type registered
*/
public has(id: string) {
return this.objectTypes.has(id);
}
/**
* Registers an object type to the type registry
*/
public register(objectType: T) {
if (this.has(objectType.id)) {
throw new Error(
i18n.translate(
'xpack.triggersActionsUI.typeRegistry.register.duplicateObjectTypeErrorMessage',
{
defaultMessage: 'Object type "{id}" is already registered.',
values: {
id: objectType.id,
},
}
)
);
}
this.objectTypes.set(objectType.id, objectType);
}
/**
* Returns an object type, null if not registered
*/
public get(id: string): T | null {
if (!this.has(id)) {
return null;
}
return this.objectTypes.get(id)!;
}
public list() {
return Array.from(this.objectTypes).map(([id, objectType]) => objectType);
}
}

View 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;
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializerContext } from 'src/core/public';
import { Plugin } from './plugin';
export function plugin(ctx: PluginInitializerContext) {
return new Plugin(ctx);
}
export { Plugin };
export * from './plugin';

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
CoreSetup,
CoreStart,
Plugin as CorePlugin,
PluginInitializerContext,
} from 'src/core/public';
import { i18n } from '@kbn/i18n';
import { registerBuiltInActionTypes } from './application/components/builtin_action_types';
import { registerBuiltInAlertTypes } from './application/components/builtin_alert_types';
import { hasShowActionsCapability, hasShowAlertsCapability } from './application/lib/capabilities';
import { PLUGIN } from './application/constants/plugin';
import { LegacyDependencies, ActionTypeModel, AlertTypeModel } from './types';
import { TypeRegistry } from './application/type_registry';
export type Setup = void;
export type Start = void;
interface LegacyPlugins {
__LEGACY: LegacyDependencies;
}
export class Plugin implements CorePlugin<Setup, Start> {
private actionTypeRegistry: TypeRegistry<ActionTypeModel>;
private alertTypeRegistry: TypeRegistry<AlertTypeModel>;
constructor(initializerContext: PluginInitializerContext) {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
this.actionTypeRegistry = actionTypeRegistry;
const alertTypeRegistry = new TypeRegistry<AlertTypeModel>();
this.alertTypeRegistry = alertTypeRegistry;
}
public setup(
{ application, notifications, http, uiSettings, injectedMetadata }: CoreSetup,
{ __LEGACY }: LegacyPlugins
): Setup {
const canShowActions = hasShowActionsCapability(__LEGACY.capabilities.get());
const canShowAlerts = hasShowAlertsCapability(__LEGACY.capabilities.get());
if (!canShowActions && !canShowAlerts) {
return;
}
registerBuiltInActionTypes({
actionTypeRegistry: this.actionTypeRegistry,
});
registerBuiltInAlertTypes({
alertTypeRegistry: this.alertTypeRegistry,
});
application.register({
id: PLUGIN.ID,
title: PLUGIN.getI18nName(i18n),
mount: async (
{
core: {
docLinks,
chrome,
// Waiting for types to be updated.
// @ts-ignore
savedObjects,
i18n: { Context: I18nContext },
},
},
{ element }
) => {
const { boot } = await import('./application/boot');
return boot({
element,
toastNotifications: notifications.toasts,
injectedMetadata,
http,
uiSettings,
docLinks,
chrome,
savedObjects: savedObjects.client,
I18nContext,
legacy: {
...__LEGACY,
},
actionTypeRegistry: this.actionTypeRegistry,
alertTypeRegistry: this.alertTypeRegistry,
});
},
});
}
public start(core: CoreStart, { __LEGACY }: LegacyPlugins) {
const { capabilities } = __LEGACY;
const canShowActions = hasShowActionsCapability(capabilities.get());
const canShowAlerts = hasShowAlertsCapability(capabilities.get());
// Don't register routes when user doesn't have access to the application
if (!canShowActions && !canShowAlerts) {
return;
}
}
public stop() {}
}

View file

@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { capabilities } from 'ui/capabilities';
import { TypeRegistry } from './application/type_registry';
export type ActionTypeIndex = Record<string, ActionType>;
export type AlertTypeIndex = Record<string, AlertType>;
export type ActionTypeRegistryContract = PublicMethodsOf<TypeRegistry<ActionTypeModel>>;
export type AlertTypeRegistryContract = PublicMethodsOf<TypeRegistry<AlertTypeModel>>;
export interface ActionConnectorFieldsProps {
action: ActionConnector;
editActionConfig: (property: string, value: any) => void;
editActionSecrets: (property: string, value: any) => void;
errors: { [key: string]: string[] };
hasErrors?: boolean;
}
export interface ActionParamsProps {
action: any;
index: number;
editAction: (property: string, value: any, index: number) => void;
errors: { [key: string]: string[] };
hasErrors?: boolean;
}
export interface Pagination {
index: number;
size: number;
}
export interface ActionTypeModel {
id: string;
iconClass: string;
selectMessage: string;
validateConnector: (action: ActionConnector) => ValidationResult;
validateParams: (actionParams: any) => ValidationResult;
actionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> | null;
actionParamsFields: React.FunctionComponent<ActionParamsProps> | null;
}
export interface ValidationResult {
errors: Record<string, any>;
}
export interface ActionType {
id: string;
name: string;
}
export interface ActionConnector {
secrets: Record<string, any>;
id: string;
actionTypeId: string;
name: string;
referencedByCount?: number;
config: Record<string, any>;
}
export type ActionConnectorWithoutId = Omit<ActionConnector, 'id'>;
export interface ActionConnectorTableItem extends ActionConnector {
actionType: ActionType['name'];
}
export interface AlertType {
id: string;
name: string;
}
export interface AlertAction {
group: string;
id: string;
params: Record<string, any>;
}
export interface Alert {
id: string;
name: string;
tags: string[];
enabled: boolean;
alertTypeId: string;
interval: string;
actions: AlertAction[];
params: Record<string, any>;
scheduledTaskId?: string;
createdBy: string | null;
updatedBy: string | null;
apiKeyOwner?: string;
throttle: string | null;
muteAll: boolean;
mutedInstanceIds: string[];
}
export type AlertWithoutId = Omit<Alert, 'id'>;
export interface AlertTableItem extends Alert {
alertType: AlertType['name'];
tagsText: string;
}
export interface AlertTypeModel {
id: string;
name: string;
iconClass: string;
validate: (alert: Alert) => ValidationResult;
alertParamsExpression: React.FunctionComponent<any>;
}
export interface IErrorObject {
[key: string]: string[];
}
export interface LegacyDependencies {
MANAGEMENT_BREADCRUMB: { text: string; href?: string };
capabilities: typeof capabilities;
}

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import {
FeatureCatalogueRegistryProvider,
FeatureCatalogueCategory,
} from 'ui/registry/feature_catalogue';
FeatureCatalogueRegistryProvider.register(() => {
return {
id: 'triggersActions',
title: 'Alerts and Actions', // This is a product name so we don't translate it.
description: i18n.translate('xpack.triggersActionsUI.triggersActionsDescription', {
defaultMessage: 'Data by creating, managing, and monitoring triggers and actions.',
}),
icon: 'triggersActionsApp',
path: '/app/kibana#/management/kibana/triggersActions',
showOnHomePage: true,
category: FeatureCatalogueCategory.ADMIN,
};
});

View file

@ -0,0 +1,5 @@
// Imported EUI
@import 'src/legacy/ui/public/styles/_styling_constants';
// Styling within the app
@import '../np_ready/public/application/sections/actions_connectors_list/components/index';

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup, App, AppUnmount } from 'src/core/public';
import { capabilities } from 'ui/capabilities';
import { i18n } from '@kbn/i18n';
/* Legacy UI imports */
import { npSetup, npStart } from 'ui/new_platform';
import routes from 'ui/routes';
import { management, MANAGEMENT_BREADCRUMB } from 'ui/management';
// @ts-ignore
import { xpackInfo } from 'plugins/xpack_main/services/xpack_info';
/* Legacy UI imports */
import { plugin } from '../np_ready/public';
import { manageAngularLifecycle } from './manage_angular_lifecycle';
import { BASE_PATH } from '../np_ready/public/application/constants';
import {
hasShowActionsCapability,
hasShowAlertsCapability,
} from '../np_ready/public/application/lib/capabilities';
const REACT_ROOT_ID = 'triggersActionsRoot';
const canShowActions = hasShowActionsCapability(capabilities.get());
const canShowAlerts = hasShowAlertsCapability(capabilities.get());
const template = `<kbn-management-app section="kibana/triggersActions">
<div id="triggersActionsRoot"></div>
</kbn-management-app>`;
let elem: HTMLElement;
let mountApp: () => AppUnmount | Promise<AppUnmount>;
let unmountApp: AppUnmount | Promise<AppUnmount>;
routes.when(`${BASE_PATH}:section?/:subsection?/:view?/:id?`, {
template,
controller: (() => {
return ($route: any, $scope: any) => {
const shimCore: CoreSetup = {
...npSetup.core,
application: {
...npSetup.core.application,
register(app: App): void {
mountApp = () =>
app.mount(npStart as any, {
element: elem,
appBasePath: BASE_PATH,
onAppLeave: () => undefined,
});
},
},
};
// clean up previously rendered React app if one exists
// this happens because of React Router redirects
if (elem) {
((unmountApp as unknown) as AppUnmount)();
}
$scope.$$postDigest(() => {
elem = document.getElementById(REACT_ROOT_ID)!;
const instance = plugin({} as any);
instance.setup(shimCore, {
...(npSetup.plugins as typeof npSetup.plugins),
__LEGACY: {
MANAGEMENT_BREADCRUMB,
capabilities,
},
});
instance.start(npStart.core, {
...(npSetup.plugins as typeof npSetup.plugins),
__LEGACY: {
MANAGEMENT_BREADCRUMB,
capabilities,
},
});
(mountApp() as Promise<AppUnmount>).then(fn => (unmountApp = fn));
manageAngularLifecycle($scope, $route, elem);
});
};
})(),
});
if (canShowActions || canShowAlerts) {
management.getSection('kibana').register('triggersActions', {
display: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', {
defaultMessage: 'Alerts and Actions',
}),
order: 7,
url: `#${BASE_PATH}`,
});
}

View 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;
* you may not use this file except in compliance with the Elastic License.
*/
import { unmountComponentAtNode } from 'react-dom';
export const manageAngularLifecycle = ($scope: any, $route: any, elem: HTMLElement) => {
const lastRoute = $route.current;
const deregister = $scope.$on('$locationChangeSuccess', () => {
const currentRoute = $route.current;
if (lastRoute.$$route.template === currentRoute.$$route.template) {
$route.current = lastRoute;
}
});
$scope.$on('$destroy', () => {
if (deregister) {
deregister();
}
if (elem) {
unmountComponentAtNode(elem);
}
});
};

View file

@ -10,6 +10,7 @@ require('@kbn/test').runTestsCli([
require.resolve('../test/reporting/configs/chromium_functional.js'),
require.resolve('../test/reporting/configs/generate_api'),
require.resolve('../test/functional/config.js'),
require.resolve('../test/functional_with_es_ssl/config.ts'),
require.resolve('../test/functional/config_security_basic.js'),
require.resolve('../test/api_integration/config_security_basic.js'),
require.resolve('../test/api_integration/config.js'),

View file

@ -57,6 +57,7 @@ export const services = {
...kibanaFunctionalServices,
...commonServices,
supertest: kibanaApiIntegrationServices.supertest,
esSupertest: kibanaApiIntegrationServices.esSupertest,
monitoringNoData: MonitoringNoDataProvider,
monitoringClusterList: MonitoringClusterListProvider,

View file

@ -0,0 +1,344 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import uuid from 'uuid';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
function generateUniqueKey() {
return uuid.v4().replace(/-/g, '');
}
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const testSubjects = getService('testSubjects');
const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']);
const supertest = getService('supertest');
const retry = getService('retry');
async function createAlert() {
const { body: createdAlert } = await supertest
.post(`/api/alert`)
.set('kbn-xsrf', 'foo')
.send({
enabled: true,
name: generateUniqueKey(),
tags: ['foo', 'bar'],
alertTypeId: 'test.noop',
consumer: 'test',
schedule: { interval: '1m' },
throttle: '1m',
actions: [],
params: {},
})
.expect(200);
return createdAlert;
}
describe('alerts', function() {
before(async () => {
await pageObjects.common.navigateToApp('triggersActions');
const alertsTab = await testSubjects.find('alertsTab');
await alertsTab.click();
});
it('should search for alert', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const searchResults = await pageObjects.triggersActionsUI.getAlertsList();
expect(searchResults).to.eql([
{
name: createdAlert.name,
tagsText: 'foo, bar',
alertType: 'Test: Noop',
interval: '1m',
},
]);
});
it('should search for tags', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(`${createdAlert.name} foo`);
const searchResults = await pageObjects.triggersActionsUI.getAlertsList();
expect(searchResults).to.eql([
{
name: createdAlert.name,
tagsText: 'foo, bar',
alertType: 'Test: Noop',
interval: '1m',
},
]);
});
// Flaky until https://github.com/elastic/eui/issues/2612 fixed
it.skip('should disable single alert', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActions = await testSubjects.find('collapsedItemActions');
await collapsedItemActions.click();
const enableSwitch = await testSubjects.find('enableSwitch');
await enableSwitch.click();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActionsAfterDisable = await testSubjects.find('collapsedItemActions');
await collapsedItemActionsAfterDisable.click();
const enableSwitchAfterDisable = await testSubjects.find('enableSwitch');
const isChecked = await enableSwitchAfterDisable.getAttribute('aria-checked');
expect(isChecked).to.eql('false');
});
// Flaky until https://github.com/elastic/eui/issues/2612 fixed
it.skip('should re-enable single alert', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActions = await testSubjects.find('collapsedItemActions');
await collapsedItemActions.click();
const enableSwitch = await testSubjects.find('enableSwitch');
await enableSwitch.click();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActionsAfterDisable = await testSubjects.find('collapsedItemActions');
await collapsedItemActionsAfterDisable.click();
const enableSwitchAfterDisable = await testSubjects.find('enableSwitch');
await enableSwitchAfterDisable.click();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActionsAfterReEnable = await testSubjects.find('collapsedItemActions');
await collapsedItemActionsAfterReEnable.click();
const enableSwitchAfterReEnable = await testSubjects.find('enableSwitch');
const isChecked = await enableSwitchAfterReEnable.getAttribute('aria-checked');
expect(isChecked).to.eql('true');
});
// Flaky until https://github.com/elastic/eui/issues/2612 fixed
it.skip('should mute single alert', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActions = await testSubjects.find('collapsedItemActions');
await collapsedItemActions.click();
const muteSwitch = await testSubjects.find('muteSwitch');
await muteSwitch.click();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActionsAfterMute = await testSubjects.find('collapsedItemActions');
await collapsedItemActionsAfterMute.click();
const muteSwitchAfterMute = await testSubjects.find('muteSwitch');
const isChecked = await muteSwitchAfterMute.getAttribute('aria-checked');
expect(isChecked).to.eql('true');
});
// Flaky until https://github.com/elastic/eui/issues/2612 fixed
it.skip('should unmute single alert', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActions = await testSubjects.find('collapsedItemActions');
await collapsedItemActions.click();
const muteSwitch = await testSubjects.find('muteSwitch');
await muteSwitch.click();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActionsAfterMute = await testSubjects.find('collapsedItemActions');
await collapsedItemActionsAfterMute.click();
const muteSwitchAfterMute = await testSubjects.find('muteSwitch');
await muteSwitchAfterMute.click();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActionsAfterUnmute = await testSubjects.find('collapsedItemActions');
await collapsedItemActionsAfterUnmute.click();
const muteSwitchAfterUnmute = await testSubjects.find('muteSwitch');
const isChecked = await muteSwitchAfterUnmute.getAttribute('aria-checked');
expect(isChecked).to.eql('false');
});
// Flaky, will be fixed with https://github.com/elastic/kibana/issues/53956
it.skip('should delete single alert', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActions = await testSubjects.find('collapsedItemActions');
await collapsedItemActions.click();
const deleteBtn = await testSubjects.find('deleteAlert');
await deleteBtn.click();
retry.try(async () => {
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const searchResults = await pageObjects.triggersActionsUI.getAlertsList();
expect(searchResults.length).to.eql(0);
});
});
// Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830
it.skip('should mute all selection', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`);
await checkbox.click();
const bulkActionBtn = await testSubjects.find('bulkAction');
await bulkActionBtn.click();
const muteAllBtn = await testSubjects.find('muteAll');
await muteAllBtn.click();
// Unmute all button shows after clicking mute all
await testSubjects.existOrFail('unmuteAll');
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActions = await testSubjects.find('collapsedItemActions');
await collapsedItemActions.click();
const muteSwitch = await testSubjects.find('muteSwitch');
const isChecked = await muteSwitch.getAttribute('aria-checked');
expect(isChecked).to.eql('true');
});
// Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830
it.skip('should unmute all selection', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`);
await checkbox.click();
const bulkActionBtn = await testSubjects.find('bulkAction');
await bulkActionBtn.click();
const muteAllBtn = await testSubjects.find('muteAll');
await muteAllBtn.click();
const unmuteAllBtn = await testSubjects.find('unmuteAll');
await unmuteAllBtn.click();
// Mute all button shows after clicking unmute all
await testSubjects.existOrFail('muteAll');
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActions = await testSubjects.find('collapsedItemActions');
await collapsedItemActions.click();
const muteSwitch = await testSubjects.find('muteSwitch');
const isChecked = await muteSwitch.getAttribute('aria-checked');
expect(isChecked).to.eql('false');
});
// Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830
it.skip('should disable all selection', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`);
await checkbox.click();
const bulkActionBtn = await testSubjects.find('bulkAction');
await bulkActionBtn.click();
const disableAllBtn = await testSubjects.find('disableAll');
await disableAllBtn.click();
// Enable all button shows after clicking disable all
await testSubjects.existOrFail('enableAll');
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActions = await testSubjects.find('collapsedItemActions');
await collapsedItemActions.click();
const enableSwitch = await testSubjects.find('enableSwitch');
const isChecked = await enableSwitch.getAttribute('aria-checked');
expect(isChecked).to.eql('false');
});
// Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830
it.skip('should enable all selection', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`);
await checkbox.click();
const bulkActionBtn = await testSubjects.find('bulkAction');
await bulkActionBtn.click();
const disableAllBtn = await testSubjects.find('disableAll');
await disableAllBtn.click();
const enableAllBtn = await testSubjects.find('enableAll');
await enableAllBtn.click();
// Disable all button shows after clicking enable all
await testSubjects.existOrFail('disableAll');
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const collapsedItemActions = await testSubjects.find('collapsedItemActions');
await collapsedItemActions.click();
const enableSwitch = await testSubjects.find('enableSwitch');
const isChecked = await enableSwitch.getAttribute('aria-checked');
expect(isChecked).to.eql('true');
});
// Flaky, will be fixed with https://github.com/elastic/kibana/issues/53956
it.skip('should delete all selection', async () => {
const createdAlert = await createAlert();
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`);
await checkbox.click();
const bulkActionBtn = await testSubjects.find('bulkAction');
await bulkActionBtn.click();
const deleteAllBtn = await testSubjects.find('deleteAll');
await deleteAllBtn.click();
retry.try(async () => {
await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name);
const searchResults = await pageObjects.triggersActionsUI.getAlertsList();
expect(searchResults.length).to.eql(0);
});
});
});
};

Some files were not shown because too many files have changed in this diff Show more