mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Fixed merge conflicts (#55093)
This commit is contained in:
parent
7301ebdee0
commit
c7cab6c550
110 changed files with 11508 additions and 15 deletions
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
];
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
43
x-pack/legacy/plugins/triggers_actions_ui/index.ts
Normal file
43
x-pack/legacy/plugins/triggers_actions_ui/index.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* 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'),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"id": "triggers_actions_ui",
|
||||
"version": "kibana",
|
||||
"server": false,
|
||||
"ui": true
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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());
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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.'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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.'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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.'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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());
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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: '<=',
|
||||
};
|
|
@ -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';
|
File diff suppressed because it is too large
Load diff
|
@ -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;
|
||||
}
|
|
@ -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[];
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
}
|
|
@ -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',
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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',
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
|
@ -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",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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`)));
|
||||
}
|
|
@ -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}`,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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';
|
|
@ -0,0 +1 @@
|
|||
@import 'actions_connectors_list';
|
|
@ -0,0 +1,3 @@
|
|||
.actConnectorsList__logo + .actConnectorsList__logo {
|
||||
margin-left: $euiSize;
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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'));
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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() {}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
});
|
|
@ -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';
|
98
x-pack/legacy/plugins/triggers_actions_ui/public/legacy.ts
Normal file
98
x-pack/legacy/plugins/triggers_actions_ui/public/legacy.ts
Normal 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}`,
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
};
|
|
@ -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'),
|
||||
|
|
|
@ -57,6 +57,7 @@ export const services = {
|
|||
...kibanaFunctionalServices,
|
||||
...commonServices,
|
||||
|
||||
supertest: kibanaApiIntegrationServices.supertest,
|
||||
esSupertest: kibanaApiIntegrationServices.esSupertest,
|
||||
monitoringNoData: MonitoringNoDataProvider,
|
||||
monitoringClusterList: MonitoringClusterListProvider,
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue