diff --git a/x-pack/legacy/plugins/monitoring/common/constants.js b/x-pack/legacy/plugins/monitoring/common/constants.ts
similarity index 85%
rename from x-pack/legacy/plugins/monitoring/common/constants.js
rename to x-pack/legacy/plugins/monitoring/common/constants.ts
index ff16b0e9c5167..53764f592dc15 100644
--- a/x-pack/legacy/plugins/monitoring/common/constants.js
+++ b/x-pack/legacy/plugins/monitoring/common/constants.ts
@@ -233,3 +233,45 @@ export const REPORTING_SYSTEM_ID = 'reporting';
* @type {Number}
*/
export const TELEMETRY_COLLECTION_INTERVAL = 86400000;
+
+/**
+ * We want to slowly rollout the migration from watcher-based cluster alerts to
+ * kibana alerts and we only want to enable the kibana alerts once all
+ * watcher-based cluster alerts have been migrated so this flag will serve
+ * as the only way to see the new UI and actually run Kibana alerts. It will
+ * be false until all alerts have been migrated, then it will be removed
+ */
+export const KIBANA_ALERTING_ENABLED = false;
+
+/**
+ * The prefix for all alert types used by monitoring
+ */
+export const ALERT_TYPE_PREFIX = 'monitoring_';
+
+/**
+ * This is the alert type id for the license expiration alert
+ */
+export const ALERT_TYPE_LICENSE_EXPIRATION = `${ALERT_TYPE_PREFIX}alert_type_license_expiration`;
+
+/**
+ * A listing of all alert types
+ */
+export const ALERT_TYPES = [ALERT_TYPE_LICENSE_EXPIRATION];
+
+/**
+ * Matches the id for the built-in in email action type
+ * See x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts
+ */
+export const ALERT_ACTION_TYPE_EMAIL = '.email';
+
+/**
+ * The number of alerts that have been migrated
+ */
+export const NUMBER_OF_MIGRATED_ALERTS = 1;
+
+/**
+ * The advanced settings config name for the email address
+ */
+export const MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS = 'monitoring:alertingEmailAddress';
+
+export const ALERT_EMAIL_SERVICES = ['gmail', 'hotmail', 'icloud', 'outlook365', 'ses', 'yahoo'];
diff --git a/x-pack/legacy/plugins/monitoring/deprecations.js b/x-pack/legacy/plugins/monitoring/deprecations.js
index 6e35e86dd9d71..ae8650fd3b26a 100644
--- a/x-pack/legacy/plugins/monitoring/deprecations.js
+++ b/x-pack/legacy/plugins/monitoring/deprecations.js
@@ -5,7 +5,7 @@
*/
import { get } from 'lodash';
-import { CLUSTER_ALERTS_ADDRESS_CONFIG_KEY } from './common/constants';
+import { CLUSTER_ALERTS_ADDRESS_CONFIG_KEY, KIBANA_ALERTING_ENABLED } from './common/constants';
/**
* Re-writes deprecated user-defined config settings and logs warnings as a
@@ -21,10 +21,20 @@ export const deprecations = () => {
const clusterAlertsEnabled = get(settings, 'cluster_alerts.enabled');
const emailNotificationsEnabled =
clusterAlertsEnabled && get(settings, 'cluster_alerts.email_notifications.enabled');
- if (emailNotificationsEnabled && !get(settings, CLUSTER_ALERTS_ADDRESS_CONFIG_KEY)) {
- log(
- `Config key "${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}" will be required for email notifications to work in 7.0."`
- );
+ if (emailNotificationsEnabled) {
+ if (KIBANA_ALERTING_ENABLED) {
+ if (get(settings, CLUSTER_ALERTS_ADDRESS_CONFIG_KEY)) {
+ log(
+ `Config key "${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}" is deprecated. Please configure the email adddress through the Stack Monitoring UI instead."`
+ );
+ }
+ } else {
+ if (!get(settings, CLUSTER_ALERTS_ADDRESS_CONFIG_KEY)) {
+ log(
+ `Config key "${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}" will be required for email notifications to work in 7.0."`
+ );
+ }
+ }
}
},
(settings, log) => {
diff --git a/x-pack/legacy/plugins/monitoring/index.js b/x-pack/legacy/plugins/monitoring/index.js
index 8145d89b2db31..9294907abcc3f 100644
--- a/x-pack/legacy/plugins/monitoring/index.js
+++ b/x-pack/legacy/plugins/monitoring/index.js
@@ -10,15 +10,20 @@ import { deprecations } from './deprecations';
import { getUiExports } from './ui_exports';
import { Plugin } from './server/plugin';
import { initInfraSource } from './server/lib/logs/init_infra_source';
+import { KIBANA_ALERTING_ENABLED } from './common/constants';
/**
* Invokes plugin modules to instantiate the Monitoring plugin for Kibana
* @param kibana {Object} Kibana plugin instance
* @return {Object} Monitoring UI Kibana plugin object
*/
+const deps = ['kibana', 'elasticsearch', 'xpack_main'];
+if (KIBANA_ALERTING_ENABLED) {
+ deps.push(...['alerting', 'actions']);
+}
export const monitoring = kibana =>
new kibana.Plugin({
- require: ['kibana', 'elasticsearch', 'xpack_main'],
+ require: deps,
id: 'monitoring',
configPrefix: 'monitoring',
publicDir: resolve(__dirname, 'public'),
@@ -60,6 +65,7 @@ export const monitoring = kibana =>
}),
injectUiAppVars: server.injectUiAppVars,
log: (...args) => server.log(...args),
+ logger: server.newPlatform.coreContext.logger,
getOSInfo: server.getOSInfo,
events: {
on: (...args) => server.events.on(...args),
@@ -74,11 +80,13 @@ export const monitoring = kibana =>
xpack_main: server.plugins.xpack_main,
elasticsearch: server.plugins.elasticsearch,
infra: server.plugins.infra,
+ alerting: server.plugins.alerting,
usageCollection,
licensing,
};
- new Plugin().setup(serverFacade, plugins);
+ const plugin = new Plugin();
+ plugin.setup(serverFacade, plugins);
},
config,
deprecations,
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/__snapshots__/status.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/__snapshots__/status.test.tsx.snap
new file mode 100644
index 0000000000000..4cf1f4df2eb2e
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/__snapshots__/status.test.tsx.snap
@@ -0,0 +1,70 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Status should render a flyout when clicking the link 1`] = `
+
+
+
+
+ Monitoring alerts
+
+
+
+
+ Configure an email server and email address to receive alerts.
+
+
+
+
+
+
+
+`;
+
+exports[`Status should render a success message if all alerts have been migrated and in setup mode 1`] = `
+
+
+
+ Want to make changes? Click here.
+
+
+
+`;
+
+exports[`Status should render without setup mode 1`] = `
+
+
+
+
+ Migrate cluster alerts to our new alerting platform.
+
+
+
+
+
+`;
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/configuration.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/configuration.test.tsx.snap
new file mode 100644
index 0000000000000..f044e001700c5
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/configuration.test.tsx.snap
@@ -0,0 +1,120 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Configuration shallow view should render step 1 1`] = `
+
+
+ Create new email action...
+ ,
+ "inputDisplay":
+ Create new email action...
+ ,
+ "value": "__new__",
+ },
+ ]
+ }
+ valueOfSelected=""
+ />
+
+`;
+
+exports[`Configuration shallow view should render step 2 1`] = `
+
+
+
+
+
+`;
+
+exports[`Configuration shallow view should render step 3 1`] = `
+
+
+ Save
+
+
+`;
+
+exports[`Configuration should render high level steps 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step1.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step1.test.tsx.snap
new file mode 100644
index 0000000000000..fa03769ea3d09
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step1.test.tsx.snap
@@ -0,0 +1,297 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Step1 creating should render a create form 1`] = `
+
+
+
+
+
+`;
+
+exports[`Step1 editing should allow for editing 1`] = `
+
+
+
+ Edit the action below.
+
+
+
+
+
+`;
+
+exports[`Step1 should render normally 1`] = `
+
+
+ From: , Service:
+ ,
+ "inputDisplay":
+ From: , Service:
+ ,
+ "value": "1",
+ },
+ Object {
+ "dropdownDisplay":
+ Create new email action...
+ ,
+ "inputDisplay":
+ Create new email action...
+ ,
+ "value": "__new__",
+ },
+ ]
+ }
+ valueOfSelected="1"
+ />
+
+
+
+
+ Edit
+
+
+
+
+ Test
+
+
+
+
+ Delete
+
+
+
+
+`;
+
+exports[`Step1 testing should should a tooltip if there is no email address 1`] = `
+
+
+ Test
+
+
+`;
+
+exports[`Step1 testing should show a failed test error 1`] = `
+
+
+ From: , Service:
+ ,
+ "inputDisplay":
+ From: , Service:
+ ,
+ "value": "1",
+ },
+ Object {
+ "dropdownDisplay":
+ Create new email action...
+ ,
+ "inputDisplay":
+ Create new email action...
+ ,
+ "value": "__new__",
+ },
+ ]
+ }
+ valueOfSelected="1"
+ />
+
+
+
+
+ Edit
+
+
+
+
+ Test
+
+
+
+
+ Delete
+
+
+
+
+
+
+ Very detailed error message
+
+
+
+`;
+
+exports[`Step1 testing should show a successful test 1`] = `
+
+
+ From: , Service:
+ ,
+ "inputDisplay":
+ From: , Service:
+ ,
+ "value": "1",
+ },
+ Object {
+ "dropdownDisplay":
+ Create new email action...
+ ,
+ "inputDisplay":
+ Create new email action...
+ ,
+ "value": "__new__",
+ },
+ ]
+ }
+ valueOfSelected="1"
+ />
+
+
+
+
+ Edit
+
+
+
+
+ Test
+
+
+
+
+ Delete
+
+
+
+
+
+
+ Looks good on our end!
+
+
+
+`;
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step2.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step2.test.tsx.snap
new file mode 100644
index 0000000000000..bac183618b491
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step2.test.tsx.snap
@@ -0,0 +1,49 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Step2 should render normally 1`] = `
+
+
+
+
+
+`;
+
+exports[`Step2 should show form errors 1`] = `
+
+
+
+
+
+`;
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step3.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step3.test.tsx.snap
new file mode 100644
index 0000000000000..ed15ae9a9cff7
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step3.test.tsx.snap
@@ -0,0 +1,95 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Step3 should render normally 1`] = `
+
+
+ Save
+
+
+`;
+
+exports[`Step3 should show a disabled state 1`] = `
+
+
+ Save
+
+
+`;
+
+exports[`Step3 should show a saving state 1`] = `
+
+
+ Save
+
+
+`;
+
+exports[`Step3 should show an error 1`] = `
+
+
+
+ Test error
+
+
+
+
+ Save
+
+
+`;
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.test.tsx
new file mode 100644
index 0000000000000..6b7e2391e0301
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.test.tsx
@@ -0,0 +1,147 @@
+/*
+ * 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 { mockUseEffects } from '../../../jest.helpers';
+import { shallow, ShallowWrapper } from 'enzyme';
+import { kfetch } from 'ui/kfetch';
+import { AlertsConfiguration, AlertsConfigurationProps } from './configuration';
+
+jest.mock('ui/kfetch', () => ({
+ kfetch: jest.fn(),
+}));
+
+const defaultProps: AlertsConfigurationProps = {
+ emailAddress: 'test@elastic.co',
+ onDone: jest.fn(),
+};
+
+describe('Configuration', () => {
+ it('should render high level steps', () => {
+ const component = shallow();
+ expect(component.find('EuiSteps').shallow()).toMatchSnapshot();
+ });
+
+ function getStep(component: ShallowWrapper, index: number) {
+ return component
+ .find('EuiSteps')
+ .shallow()
+ .find('EuiStep')
+ .at(index)
+ .children()
+ .shallow();
+ }
+
+ describe('shallow view', () => {
+ it('should render step 1', () => {
+ const component = shallow();
+ const stepOne = getStep(component, 0);
+ expect(stepOne).toMatchSnapshot();
+ });
+
+ it('should render step 2', () => {
+ const component = shallow();
+ const stepTwo = getStep(component, 1);
+ expect(stepTwo).toMatchSnapshot();
+ });
+
+ it('should render step 3', () => {
+ const component = shallow();
+ const stepThree = getStep(component, 2);
+ expect(stepThree).toMatchSnapshot();
+ });
+ });
+
+ describe('selected action', () => {
+ const actionId = 'a123b';
+ let component: ShallowWrapper;
+ beforeEach(async () => {
+ mockUseEffects(2);
+
+ (kfetch as jest.Mock).mockImplementation(() => {
+ return {
+ data: [
+ {
+ actionTypeId: '.email',
+ id: actionId,
+ config: {},
+ },
+ ],
+ };
+ });
+
+ component = shallow();
+ });
+
+ it('reflect in Step1', async () => {
+ const steps = component.find('EuiSteps').dive();
+ expect(
+ steps
+ .find('EuiStep')
+ .at(0)
+ .prop('title')
+ ).toBe('Select email action');
+ expect(steps.find('Step1').prop('selectedEmailActionId')).toBe(actionId);
+ });
+
+ it('should enable Step2', async () => {
+ const steps = component.find('EuiSteps').dive();
+ expect(steps.find('Step2').prop('isDisabled')).toBe(false);
+ });
+
+ it('should enable Step3', async () => {
+ const steps = component.find('EuiSteps').dive();
+ expect(steps.find('Step3').prop('isDisabled')).toBe(false);
+ });
+ });
+
+ describe('edit action', () => {
+ let component: ShallowWrapper;
+ beforeEach(async () => {
+ (kfetch as jest.Mock).mockImplementation(() => {
+ return {
+ data: [],
+ };
+ });
+
+ component = shallow();
+ });
+
+ it('disable Step2', async () => {
+ const steps = component.find('EuiSteps').dive();
+ expect(steps.find('Step2').prop('isDisabled')).toBe(true);
+ });
+
+ it('disable Step3', async () => {
+ const steps = component.find('EuiSteps').dive();
+ expect(steps.find('Step3').prop('isDisabled')).toBe(true);
+ });
+ });
+
+ describe('no email address', () => {
+ let component: ShallowWrapper;
+ beforeEach(async () => {
+ (kfetch as jest.Mock).mockImplementation(() => {
+ return {
+ data: [
+ {
+ actionTypeId: '.email',
+ id: 'actionId',
+ config: {},
+ },
+ ],
+ };
+ });
+
+ component = shallow();
+ });
+
+ it('should disable Step3', async () => {
+ const steps = component.find('EuiSteps').dive();
+ expect(steps.find('Step3').prop('isDisabled')).toBe(true);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.tsx
new file mode 100644
index 0000000000000..0933cd22db7c9
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.tsx
@@ -0,0 +1,193 @@
+/*
+ * 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, { ReactNode } from 'react';
+import { kfetch } from 'ui/kfetch';
+import { EuiSteps } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { ActionResult } from '../../../../../../../plugins/actions/common';
+import { ALERT_ACTION_TYPE_EMAIL } from '../../../../common/constants';
+import { getMissingFieldErrors } from '../../../lib/form_validation';
+import { Step1 } from './step1';
+import { Step2 } from './step2';
+import { Step3 } from './step3';
+
+export interface AlertsConfigurationProps {
+ emailAddress: string;
+ onDone: Function;
+}
+
+export interface StepResult {
+ title: string;
+ children: ReactNode;
+ status: any;
+}
+
+export interface AlertsConfigurationForm {
+ email: string | null;
+}
+
+export const NEW_ACTION_ID = '__new__';
+
+export const AlertsConfiguration: React.FC = (
+ props: AlertsConfigurationProps
+) => {
+ const { onDone } = props;
+
+ const [emailActions, setEmailActions] = React.useState([]);
+ const [selectedEmailActionId, setSelectedEmailActionId] = React.useState('');
+ const [editAction, setEditAction] = React.useState(null);
+ const [emailAddress, setEmailAddress] = React.useState(props.emailAddress);
+ const [formErrors, setFormErrors] = React.useState({ email: null });
+ const [showFormErrors, setShowFormErrors] = React.useState(false);
+ const [isSaving, setIsSaving] = React.useState(false);
+ const [saveError, setSaveError] = React.useState('');
+
+ React.useEffect(() => {
+ async function fetchData() {
+ await fetchEmailActions();
+ }
+
+ fetchData();
+ }, []);
+
+ React.useEffect(() => {
+ setFormErrors(getMissingFieldErrors({ email: emailAddress }, { email: '' }));
+ }, [emailAddress]);
+
+ async function fetchEmailActions() {
+ const kibanaActions = await kfetch({
+ method: 'GET',
+ pathname: `/api/action/_find`,
+ });
+
+ const actions = kibanaActions.data.filter(
+ (action: ActionResult) => action.actionTypeId === ALERT_ACTION_TYPE_EMAIL
+ );
+ if (actions.length > 0) {
+ setSelectedEmailActionId(actions[0].id);
+ } else {
+ setSelectedEmailActionId(NEW_ACTION_ID);
+ }
+ setEmailActions(actions);
+ }
+
+ async function save() {
+ if (emailAddress.length === 0) {
+ setShowFormErrors(true);
+ return;
+ }
+ setIsSaving(true);
+ setShowFormErrors(false);
+
+ try {
+ await kfetch({
+ method: 'POST',
+ pathname: `/api/monitoring/v1/alerts`,
+ body: JSON.stringify({ selectedEmailActionId, emailAddress }),
+ });
+ } catch (err) {
+ setIsSaving(false);
+ setSaveError(
+ err?.body?.message ||
+ i18n.translate('xpack.monitoring.alerts.configuration.unknownError', {
+ defaultMessage: 'Something went wrong. Please consult the server logs.',
+ })
+ );
+ return;
+ }
+
+ onDone();
+ }
+
+ function isStep2Disabled() {
+ return isStep2AndStep3Disabled();
+ }
+
+ function isStep3Disabled() {
+ return isStep2AndStep3Disabled() || !emailAddress || emailAddress.length === 0;
+ }
+
+ function isStep2AndStep3Disabled() {
+ return !!editAction || !selectedEmailActionId || selectedEmailActionId === NEW_ACTION_ID;
+ }
+
+ function getStep2Status() {
+ const isDisabled = isStep2AndStep3Disabled();
+
+ if (isDisabled) {
+ return 'disabled' as const;
+ }
+
+ if (emailAddress && emailAddress.length) {
+ return 'complete' as const;
+ }
+
+ return 'incomplete' as const;
+ }
+
+ function getStep1Status() {
+ if (editAction) {
+ return 'incomplete' as const;
+ }
+
+ return selectedEmailActionId ? ('complete' as const) : ('incomplete' as const);
+ }
+
+ const steps = [
+ {
+ title: emailActions.length
+ ? i18n.translate('xpack.monitoring.alerts.configuration.selectEmailAction', {
+ defaultMessage: 'Select email action',
+ })
+ : i18n.translate('xpack.monitoring.alerts.configuration.createEmailAction', {
+ defaultMessage: 'Create email action',
+ }),
+ children: (
+ await fetchEmailActions()}
+ emailActions={emailActions}
+ selectedEmailActionId={selectedEmailActionId}
+ setSelectedEmailActionId={setSelectedEmailActionId}
+ emailAddress={emailAddress}
+ editAction={editAction}
+ setEditAction={setEditAction}
+ />
+ ),
+ status: getStep1Status(),
+ },
+ {
+ title: i18n.translate('xpack.monitoring.alerts.configuration.setEmailAddress', {
+ defaultMessage: 'Set the email to receive alerts',
+ }),
+ status: getStep2Status(),
+ children: (
+
+ ),
+ },
+ {
+ title: i18n.translate('xpack.monitoring.alerts.configuration.confirm', {
+ defaultMessage: 'Confirm and save',
+ }),
+ status: getStep2Status(),
+ children: (
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
+ );
+};
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/index.ts b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/index.ts
new file mode 100644
index 0000000000000..7a96c6e324ab3
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/index.ts
@@ -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 { AlertsConfiguration } from './configuration';
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.test.tsx
new file mode 100644
index 0000000000000..650294c29e9a5
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.test.tsx
@@ -0,0 +1,338 @@
+/*
+ * 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 { omit, pick } from 'lodash';
+import '../../../jest.helpers';
+import { shallow } from 'enzyme';
+import { GetStep1Props } from './step1';
+import { EmailActionData } from '../manage_email_action';
+import { ALERT_ACTION_TYPE_EMAIL } from '../../../../common/constants';
+
+let Step1: React.FC;
+let NEW_ACTION_ID: string;
+
+function setModules() {
+ Step1 = require('./step1').Step1;
+ NEW_ACTION_ID = require('./configuration').NEW_ACTION_ID;
+}
+
+describe('Step1', () => {
+ const emailActions = [
+ {
+ id: '1',
+ actionTypeId: '1abc',
+ name: 'Testing',
+ config: {},
+ },
+ ];
+ const selectedEmailActionId = emailActions[0].id;
+ const setSelectedEmailActionId = jest.fn();
+ const emailAddress = 'test@test.com';
+ const editAction = null;
+ const setEditAction = jest.fn();
+ const onActionDone = jest.fn();
+
+ const defaultProps: GetStep1Props = {
+ onActionDone,
+ emailActions,
+ selectedEmailActionId,
+ setSelectedEmailActionId,
+ emailAddress,
+ editAction,
+ setEditAction,
+ };
+
+ beforeEach(() => {
+ jest.isolateModules(() => {
+ jest.doMock('ui/kfetch', () => ({
+ kfetch: () => {
+ return {};
+ },
+ }));
+ setModules();
+ });
+ });
+
+ it('should render normally', () => {
+ const component = shallow();
+
+ expect(component).toMatchSnapshot();
+ });
+
+ describe('creating', () => {
+ it('should render a create form', () => {
+ const customProps = {
+ emailActions: [],
+ selectedEmailActionId: NEW_ACTION_ID,
+ };
+
+ const component = shallow();
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should render the select box if at least one action exists', () => {
+ const customProps = {
+ emailActions: [
+ {
+ id: 'foo',
+ actionTypeId: '.email',
+ name: '',
+ config: {},
+ },
+ ],
+ selectedEmailActionId: NEW_ACTION_ID,
+ };
+
+ const component = shallow();
+ expect(component.find('EuiSuperSelect').exists()).toBe(true);
+ });
+
+ it('should send up the create to the server', async () => {
+ const kfetch = jest.fn().mockImplementation(() => {});
+ jest.isolateModules(() => {
+ jest.doMock('ui/kfetch', () => ({
+ kfetch,
+ }));
+ setModules();
+ });
+
+ const customProps = {
+ emailActions: [],
+ selectedEmailActionId: NEW_ACTION_ID,
+ };
+
+ const component = shallow();
+
+ const data: EmailActionData = {
+ service: 'gmail',
+ host: 'smtp.gmail.com',
+ port: 465,
+ secure: true,
+ from: 'test@test.com',
+ user: 'user@user.com',
+ password: 'password',
+ };
+
+ const createEmailAction: (data: EmailActionData) => void = component
+ .find('ManageEmailAction')
+ .prop('createEmailAction');
+ createEmailAction(data);
+
+ expect(kfetch).toHaveBeenCalledWith({
+ method: 'POST',
+ pathname: `/api/action`,
+ body: JSON.stringify({
+ name: 'Email action for Stack Monitoring alerts',
+ actionTypeId: ALERT_ACTION_TYPE_EMAIL,
+ config: omit(data, ['user', 'password']),
+ secrets: pick(data, ['user', 'password']),
+ }),
+ });
+ });
+ });
+
+ describe('editing', () => {
+ it('should allow for editing', () => {
+ const customProps = {
+ editAction: emailActions[0],
+ };
+
+ const component = shallow();
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should send up the edit to the server', async () => {
+ const kfetch = jest.fn().mockImplementation(() => {});
+ jest.isolateModules(() => {
+ jest.doMock('ui/kfetch', () => ({
+ kfetch,
+ }));
+ setModules();
+ });
+
+ const customProps = {
+ editAction: emailActions[0],
+ };
+
+ const component = shallow();
+
+ const data: EmailActionData = {
+ service: 'gmail',
+ host: 'smtp.gmail.com',
+ port: 465,
+ secure: true,
+ from: 'test@test.com',
+ user: 'user@user.com',
+ password: 'password',
+ };
+
+ const createEmailAction: (data: EmailActionData) => void = component
+ .find('ManageEmailAction')
+ .prop('createEmailAction');
+ createEmailAction(data);
+
+ expect(kfetch).toHaveBeenCalledWith({
+ method: 'PUT',
+ pathname: `/api/action/${emailActions[0].id}`,
+ body: JSON.stringify({
+ name: emailActions[0].name,
+ config: omit(data, ['user', 'password']),
+ secrets: pick(data, ['user', 'password']),
+ }),
+ });
+ });
+ });
+
+ describe('testing', () => {
+ it('should allow for testing', async () => {
+ jest.isolateModules(() => {
+ jest.doMock('ui/kfetch', () => ({
+ kfetch: jest.fn().mockImplementation(arg => {
+ if (arg.pathname === '/api/action/1/_execute') {
+ return { status: 'ok' };
+ }
+ return {};
+ }),
+ }));
+ setModules();
+ });
+
+ const component = shallow();
+
+ expect(
+ component
+ .find('EuiButton')
+ .at(1)
+ .prop('isLoading')
+ ).toBe(false);
+ component
+ .find('EuiButton')
+ .at(1)
+ .simulate('click');
+ expect(
+ component
+ .find('EuiButton')
+ .at(1)
+ .prop('isLoading')
+ ).toBe(true);
+ await component.update();
+ expect(
+ component
+ .find('EuiButton')
+ .at(1)
+ .prop('isLoading')
+ ).toBe(false);
+ });
+
+ it('should show a successful test', async () => {
+ jest.isolateModules(() => {
+ jest.doMock('ui/kfetch', () => ({
+ kfetch: (arg: any) => {
+ if (arg.pathname === '/api/action/1/_execute') {
+ return { status: 'ok' };
+ }
+ return {};
+ },
+ }));
+ setModules();
+ });
+
+ const component = shallow();
+
+ component
+ .find('EuiButton')
+ .at(1)
+ .simulate('click');
+ await component.update();
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should show a failed test error', async () => {
+ jest.isolateModules(() => {
+ jest.doMock('ui/kfetch', () => ({
+ kfetch: (arg: any) => {
+ if (arg.pathname === '/api/action/1/_execute') {
+ return { message: 'Very detailed error message' };
+ }
+ return {};
+ },
+ }));
+ setModules();
+ });
+
+ const component = shallow();
+
+ component
+ .find('EuiButton')
+ .at(1)
+ .simulate('click');
+ await component.update();
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should not allow testing if there is no email address', () => {
+ const customProps = {
+ emailAddress: '',
+ };
+ const component = shallow();
+ expect(
+ component
+ .find('EuiButton')
+ .at(1)
+ .prop('isDisabled')
+ ).toBe(true);
+ });
+
+ it('should should a tooltip if there is no email address', () => {
+ const customProps = {
+ emailAddress: '',
+ };
+ const component = shallow();
+ expect(component.find('EuiToolTip')).toMatchSnapshot();
+ });
+ });
+
+ describe('deleting', () => {
+ it('should send up the delete to the server', async () => {
+ const kfetch = jest.fn().mockImplementation(() => {});
+ jest.isolateModules(() => {
+ jest.doMock('ui/kfetch', () => ({
+ kfetch,
+ }));
+ setModules();
+ });
+
+ const customProps = {
+ setSelectedEmailActionId: jest.fn(),
+ onActionDone: jest.fn(),
+ };
+ const component = shallow();
+
+ await component
+ .find('EuiButton')
+ .at(2)
+ .simulate('click');
+ await component.update();
+
+ expect(kfetch).toHaveBeenCalledWith({
+ method: 'DELETE',
+ pathname: `/api/action/${emailActions[0].id}`,
+ });
+
+ expect(customProps.setSelectedEmailActionId).toHaveBeenCalledWith('');
+ expect(customProps.onActionDone).toHaveBeenCalled();
+ expect(
+ component
+ .find('EuiButton')
+ .at(2)
+ .prop('isLoading')
+ ).toBe(false);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.tsx
new file mode 100644
index 0000000000000..fc051a68e29f3
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.tsx
@@ -0,0 +1,334 @@
+/*
+ * 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 {
+ EuiText,
+ EuiSpacer,
+ EuiPanel,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButton,
+ EuiSuperSelect,
+ EuiToolTip,
+ EuiCallOut,
+} from '@elastic/eui';
+import { kfetch } from 'ui/kfetch';
+import { omit, pick } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { ActionResult } from '../../../../../../../plugins/actions/common';
+import { ManageEmailAction, EmailActionData } from '../manage_email_action';
+import { ALERT_ACTION_TYPE_EMAIL } from '../../../../common/constants';
+import { NEW_ACTION_ID } from './configuration';
+
+export interface GetStep1Props {
+ onActionDone: () => Promise;
+ emailActions: ActionResult[];
+ selectedEmailActionId: string;
+ setSelectedEmailActionId: (id: string) => void;
+ emailAddress: string;
+ editAction: ActionResult | null;
+ setEditAction: (action: ActionResult | null) => void;
+}
+
+export const Step1: React.FC = (props: GetStep1Props) => {
+ const [isTesting, setIsTesting] = React.useState(false);
+ const [isDeleting, setIsDeleting] = React.useState(false);
+ const [testingStatus, setTestingStatus] = React.useState(null);
+ const [fullTestingError, setFullTestingError] = React.useState('');
+
+ async function createEmailAction(data: EmailActionData) {
+ if (props.editAction) {
+ await kfetch({
+ method: 'PUT',
+ pathname: `/api/action/${props.editAction.id}`,
+ body: JSON.stringify({
+ name: props.editAction.name,
+ config: omit(data, ['user', 'password']),
+ secrets: pick(data, ['user', 'password']),
+ }),
+ });
+ props.setEditAction(null);
+ } else {
+ await kfetch({
+ method: 'POST',
+ pathname: '/api/action',
+ body: JSON.stringify({
+ name: i18n.translate('xpack.monitoring.alerts.configuration.emailAction.name', {
+ defaultMessage: 'Email action for Stack Monitoring alerts',
+ }),
+ actionTypeId: ALERT_ACTION_TYPE_EMAIL,
+ config: omit(data, ['user', 'password']),
+ secrets: pick(data, ['user', 'password']),
+ }),
+ });
+ }
+
+ await props.onActionDone();
+ }
+
+ async function deleteEmailAction(id: string) {
+ setIsDeleting(true);
+
+ await kfetch({
+ method: 'DELETE',
+ pathname: `/api/action/${id}`,
+ });
+
+ if (props.editAction && props.editAction.id === id) {
+ props.setEditAction(null);
+ }
+ if (props.selectedEmailActionId === id) {
+ props.setSelectedEmailActionId('');
+ }
+ await props.onActionDone();
+ setIsDeleting(false);
+ setTestingStatus(null);
+ }
+
+ async function testEmailAction() {
+ setIsTesting(true);
+ setTestingStatus(null);
+
+ const params = {
+ subject: 'Kibana alerting test configuration',
+ message: `This is a test for the configured email action for Kibana alerting.`,
+ to: [props.emailAddress],
+ };
+
+ const result = await kfetch({
+ method: 'POST',
+ pathname: `/api/action/${props.selectedEmailActionId}/_execute`,
+ body: JSON.stringify({ params }),
+ });
+ if (result.status === 'ok') {
+ setTestingStatus(true);
+ } else {
+ setTestingStatus(false);
+ setFullTestingError(result.message);
+ }
+ setIsTesting(false);
+ }
+
+ function getTestButton() {
+ const isTestingDisabled = !props.emailAddress || props.emailAddress.length === 0;
+ const testBtn = (
+
+ {i18n.translate('xpack.monitoring.alerts.configuration.testConfiguration.buttonText', {
+ defaultMessage: 'Test',
+ })}
+
+ );
+
+ if (isTestingDisabled) {
+ return (
+
+ {testBtn}
+
+ );
+ }
+
+ return testBtn;
+ }
+
+ if (props.editAction) {
+ return (
+
+
+
+ {i18n.translate('xpack.monitoring.alerts.configuration.step1.editAction', {
+ defaultMessage: 'Edit the action below.',
+ })}
+
+
+
+ await createEmailAction(data)}
+ cancel={() => props.setEditAction(null)}
+ isNew={false}
+ action={props.editAction}
+ />
+
+ );
+ }
+
+ const newAction = (
+
+ {i18n.translate('xpack.monitoring.alerts.configuration.newActionDropdownDisplay', {
+ defaultMessage: 'Create new email action...',
+ })}
+
+ );
+
+ const options = [
+ ...props.emailActions.map(action => {
+ const actionLabel = i18n.translate(
+ 'xpack.monitoring.alerts.configuration.selectAction.inputDisplay',
+ {
+ defaultMessage: 'From: {from}, Service: {service}',
+ values: {
+ service: action.config.service,
+ from: action.config.from,
+ },
+ }
+ );
+
+ return {
+ value: action.id,
+ inputDisplay: {actionLabel},
+ dropdownDisplay: {actionLabel},
+ };
+ }),
+ {
+ value: NEW_ACTION_ID,
+ inputDisplay: newAction,
+ dropdownDisplay: newAction,
+ },
+ ];
+
+ let selectBox: React.ReactNode | null = (
+ props.setSelectedEmailActionId(id)}
+ hasDividers
+ />
+ );
+ let createNew = null;
+ if (props.selectedEmailActionId === NEW_ACTION_ID) {
+ createNew = (
+
+ await createEmailAction(data)}
+ isNew={true}
+ />
+
+ );
+
+ // If there are no actions, do not show the select box as there are no choices
+ if (props.emailActions.length === 0) {
+ selectBox = null;
+ } else {
+ // Otherwise, add a spacer
+ selectBox = (
+
+ {selectBox}
+
+
+ );
+ }
+ }
+
+ let manageConfiguration = null;
+ const selectedEmailAction = props.emailActions.find(
+ action => action.id === props.selectedEmailActionId
+ );
+
+ if (
+ props.selectedEmailActionId !== NEW_ACTION_ID &&
+ props.selectedEmailActionId &&
+ selectedEmailAction
+ ) {
+ let testingStatusUi = null;
+ if (testingStatus === true) {
+ testingStatusUi = (
+
+
+
+
+ {i18n.translate('xpack.monitoring.alerts.configuration.testConfiguration.success', {
+ defaultMessage: 'Looks good on our end!',
+ })}
+
+
+
+ );
+ } else if (testingStatus === false) {
+ testingStatusUi = (
+
+
+
+ {fullTestingError}
+
+
+ );
+ }
+
+ manageConfiguration = (
+
+
+
+
+ {
+ const editAction =
+ props.emailActions.find(action => action.id === props.selectedEmailActionId) ||
+ null;
+ props.setEditAction(editAction);
+ }}
+ >
+ {i18n.translate(
+ 'xpack.monitoring.alerts.configuration.editConfiguration.buttonText',
+ {
+ defaultMessage: 'Edit',
+ }
+ )}
+
+
+ {getTestButton()}
+
+ deleteEmailAction(props.selectedEmailActionId)}
+ isLoading={isDeleting}
+ >
+ {i18n.translate(
+ 'xpack.monitoring.alerts.configuration.deleteConfiguration.buttonText',
+ {
+ defaultMessage: 'Delete',
+ }
+ )}
+
+
+
+ {testingStatusUi}
+
+ );
+ }
+
+ return (
+
+ {selectBox}
+ {manageConfiguration}
+ {createNew}
+
+ );
+};
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.test.tsx
new file mode 100644
index 0000000000000..14e3cb078f9cc
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.test.tsx
@@ -0,0 +1,51 @@
+/*
+ * 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 '../../../jest.helpers';
+import { shallow } from 'enzyme';
+import { Step2, GetStep2Props } from './step2';
+
+describe('Step2', () => {
+ const defaultProps: GetStep2Props = {
+ emailAddress: 'test@test.com',
+ setEmailAddress: jest.fn(),
+ showFormErrors: false,
+ formErrors: { email: null },
+ isDisabled: false,
+ };
+
+ it('should render normally', () => {
+ const component = shallow();
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should set the email address properly', () => {
+ const newEmail = 'email@email.com';
+ const component = shallow();
+ component.find('EuiFieldText').simulate('change', { target: { value: newEmail } });
+ expect(defaultProps.setEmailAddress).toHaveBeenCalledWith(newEmail);
+ });
+
+ it('should show form errors', () => {
+ const customProps = {
+ showFormErrors: true,
+ formErrors: {
+ email: 'This is required',
+ },
+ };
+ const component = shallow();
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should disable properly', () => {
+ const customProps = {
+ isDisabled: true,
+ };
+ const component = shallow();
+ expect(component.find('EuiFieldText').prop('disabled')).toBe(true);
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.tsx
new file mode 100644
index 0000000000000..974dd8513d231
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.tsx
@@ -0,0 +1,38 @@
+/*
+ * 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 { EuiForm, EuiFormRow, EuiFieldText } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { AlertsConfigurationForm } from './configuration';
+
+export interface GetStep2Props {
+ emailAddress: string;
+ setEmailAddress: (email: string) => void;
+ showFormErrors: boolean;
+ formErrors: AlertsConfigurationForm;
+ isDisabled: boolean;
+}
+
+export const Step2: React.FC = (props: GetStep2Props) => {
+ return (
+
+
+ props.setEmailAddress(e.target.value)}
+ />
+
+
+ );
+};
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.test.tsx
new file mode 100644
index 0000000000000..9b1304c42a507
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.test.tsx
@@ -0,0 +1,48 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import '../../../jest.helpers';
+import { shallow } from 'enzyme';
+import { Step3 } from './step3';
+
+describe('Step3', () => {
+ const defaultProps = {
+ isSaving: false,
+ isDisabled: false,
+ save: jest.fn(),
+ error: null,
+ };
+
+ it('should render normally', () => {
+ const component = shallow();
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should save properly', () => {
+ const component = shallow();
+ component.find('EuiButton').simulate('click');
+ expect(defaultProps.save).toHaveBeenCalledWith();
+ });
+
+ it('should show a saving state', () => {
+ const customProps = { isSaving: true };
+ const component = shallow();
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should show a disabled state', () => {
+ const customProps = { isDisabled: true };
+ const component = shallow();
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should show an error', () => {
+ const customProps = { error: 'Test error' };
+ const component = shallow();
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.tsx
new file mode 100644
index 0000000000000..80acb8992cbc1
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.tsx
@@ -0,0 +1,47 @@
+/*
+ * 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 { EuiButton, EuiSpacer, EuiCallOut } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+export interface GetStep3Props {
+ isSaving: boolean;
+ isDisabled: boolean;
+ save: () => void;
+ error: string | null;
+}
+
+export const Step3: React.FC = (props: GetStep3Props) => {
+ let errorUi = null;
+ if (props.error) {
+ errorUi = (
+
+
+ {props.error}
+
+
+
+ );
+ }
+
+ return (
+
+ {errorUi}
+
+ {i18n.translate('xpack.monitoring.alerts.configuration.save', {
+ defaultMessage: 'Save',
+ })}
+
+
+ );
+};
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx
new file mode 100644
index 0000000000000..2bd9804795cb5
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx
@@ -0,0 +1,301 @@
+/*
+ * 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 {
+ EuiForm,
+ EuiFormRow,
+ EuiFieldText,
+ EuiLink,
+ EuiSpacer,
+ EuiFieldNumber,
+ EuiFieldPassword,
+ EuiSwitch,
+ EuiButton,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSuperSelect,
+ EuiText,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { ActionResult } from '../../../../../../plugins/actions/common';
+import { getMissingFieldErrors, hasErrors, getRequiredFieldError } from '../../lib/form_validation';
+import { ALERT_EMAIL_SERVICES } from '../../../common/constants';
+
+export interface EmailActionData {
+ service: string;
+ host: string;
+ port?: number;
+ secure: boolean;
+ from: string;
+ user: string;
+ password: string;
+}
+
+interface ManageActionModalProps {
+ createEmailAction: (handler: EmailActionData) => void;
+ cancel?: () => void;
+ isNew: boolean;
+ action?: ActionResult | null;
+}
+
+const DEFAULT_DATA: EmailActionData = {
+ service: '',
+ host: '',
+ port: 0,
+ secure: false,
+ from: '',
+ user: '',
+ password: '',
+};
+
+const CREATE_LABEL = i18n.translate('xpack.monitoring.alerts.migrate.manageAction.createLabel', {
+ defaultMessage: 'Create email action',
+});
+const SAVE_LABEL = i18n.translate('xpack.monitoring.alerts.migrate.manageAction.saveLabel', {
+ defaultMessage: 'Save email action',
+});
+const CANCEL_LABEL = i18n.translate('xpack.monitoring.alerts.migrate.manageAction.cancelLabel', {
+ defaultMessage: 'Cancel',
+});
+
+const NEW_SERVICE_ID = '__new__';
+
+export const ManageEmailAction: React.FC = (
+ props: ManageActionModalProps
+) => {
+ const { createEmailAction, cancel, isNew, action } = props;
+
+ const defaultData = Object.assign({}, DEFAULT_DATA, action ? action.config : {});
+ const [isSaving, setIsSaving] = React.useState(false);
+ const [showErrors, setShowErrors] = React.useState(false);
+ const [errors, setErrors] = React.useState(
+ getMissingFieldErrors(defaultData, DEFAULT_DATA)
+ );
+ const [data, setData] = React.useState(defaultData);
+ const [createNewService, setCreateNewService] = React.useState(false);
+ const [newService, setNewService] = React.useState('');
+
+ React.useEffect(() => {
+ const missingFieldErrors = getMissingFieldErrors(data, DEFAULT_DATA);
+ if (!missingFieldErrors.service) {
+ if (data.service === NEW_SERVICE_ID && !newService) {
+ missingFieldErrors.service = getRequiredFieldError('service');
+ }
+ }
+ setErrors(missingFieldErrors);
+ }, [data, newService]);
+
+ async function saveEmailAction() {
+ setShowErrors(true);
+ if (!hasErrors(errors)) {
+ setShowErrors(false);
+ setIsSaving(true);
+ const mergedData = {
+ ...data,
+ service: data.service === NEW_SERVICE_ID ? newService : data.service,
+ };
+ try {
+ await createEmailAction(mergedData);
+ } catch (err) {
+ setErrors({
+ general: err.body.message,
+ });
+ }
+ }
+ }
+
+ const serviceOptions = ALERT_EMAIL_SERVICES.map(service => ({
+ value: service,
+ inputDisplay: {service},
+ dropdownDisplay: {service},
+ }));
+
+ serviceOptions.push({
+ value: NEW_SERVICE_ID,
+ inputDisplay: (
+
+ {i18n.translate('xpack.monitoring.alerts.migrate.manageAction.addingNewServiceText', {
+ defaultMessage: 'Adding new service...',
+ })}
+
+ ),
+ dropdownDisplay: (
+
+ {i18n.translate('xpack.monitoring.alerts.migrate.manageAction.addNewServiceText', {
+ defaultMessage: 'Add new service...',
+ })}
+
+ ),
+ });
+
+ let addNewServiceUi = null;
+ if (createNewService) {
+ addNewServiceUi = (
+
+
+ setNewService(e.target.value)}
+ isInvalid={showErrors}
+ />
+
+ );
+ }
+
+ return (
+
+
+ {i18n.translate('xpack.monitoring.alerts.migrate.manageAction.serviceHelpText', {
+ defaultMessage: 'Find out more',
+ })}
+
+ }
+ error={errors.service}
+ isInvalid={showErrors && !!errors.service}
+ >
+
+ {
+ if (id === NEW_SERVICE_ID) {
+ setCreateNewService(true);
+ setData({ ...data, service: NEW_SERVICE_ID });
+ } else {
+ setCreateNewService(false);
+ setData({ ...data, service: id });
+ }
+ }}
+ hasDividers
+ isInvalid={showErrors && !!errors.service}
+ />
+ {addNewServiceUi}
+
+
+
+
+ setData({ ...data, host: e.target.value })}
+ isInvalid={showErrors && !!errors.host}
+ />
+
+
+
+ setData({ ...data, port: parseInt(e.target.value, 10) })}
+ isInvalid={showErrors && !!errors.port}
+ />
+
+
+
+ setData({ ...data, secure: e.target.checked })}
+ />
+
+
+
+ setData({ ...data, from: e.target.value })}
+ isInvalid={showErrors && !!errors.from}
+ />
+
+
+
+ setData({ ...data, user: e.target.value })}
+ isInvalid={showErrors && !!errors.user}
+ />
+
+
+
+ setData({ ...data, password: e.target.value })}
+ isInvalid={showErrors && !!errors.password}
+ />
+
+
+
+
+
+
+
+ {isNew ? CREATE_LABEL : SAVE_LABEL}
+
+
+ {!action || isNew ? null : (
+
+ {CANCEL_LABEL}
+
+ )}
+
+
+ );
+};
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx
new file mode 100644
index 0000000000000..258a5b68db372
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx
@@ -0,0 +1,81 @@
+/*
+ * 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 { shallow } from 'enzyme';
+import { kfetch } from 'ui/kfetch';
+import { AlertsStatus, AlertsStatusProps } from './status';
+import { ALERT_TYPE_PREFIX } from '../../../common/constants';
+import { getSetupModeState } from '../../lib/setup_mode';
+import { mockUseEffects } from '../../jest.helpers';
+
+jest.mock('../../lib/setup_mode', () => ({
+ getSetupModeState: jest.fn(),
+ addSetupModeCallback: jest.fn(),
+ toggleSetupMode: jest.fn(),
+}));
+
+jest.mock('ui/kfetch', () => ({
+ kfetch: jest.fn(),
+}));
+
+const defaultProps: AlertsStatusProps = {
+ clusterUuid: '1adsb23',
+ emailAddress: 'test@elastic.co',
+};
+
+describe('Status', () => {
+ beforeEach(() => {
+ mockUseEffects(2);
+
+ (getSetupModeState as jest.Mock).mockReturnValue({
+ enabled: false,
+ });
+
+ (kfetch as jest.Mock).mockImplementation(({ pathname }) => {
+ if (pathname === '/internal/security/api_key/privileges') {
+ return { areApiKeysEnabled: true };
+ }
+ return {
+ data: [],
+ };
+ });
+ });
+
+ it('should render without setup mode', () => {
+ const component = shallow();
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should render a flyout when clicking the link', async () => {
+ (getSetupModeState as jest.Mock).mockReturnValue({
+ enabled: true,
+ });
+
+ const component = shallow();
+ component.find('EuiLink').simulate('click');
+ await component.update();
+ expect(component.find('EuiFlyout')).toMatchSnapshot();
+ });
+
+ it('should render a success message if all alerts have been migrated and in setup mode', async () => {
+ (kfetch as jest.Mock).mockReturnValue({
+ data: [
+ {
+ alertTypeId: ALERT_TYPE_PREFIX,
+ },
+ ],
+ });
+
+ (getSetupModeState as jest.Mock).mockReturnValue({
+ enabled: true,
+ });
+
+ const component = shallow();
+ await component.update();
+ expect(component.find('EuiCallOut')).toMatchSnapshot();
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx
new file mode 100644
index 0000000000000..0ee0015ed39a7
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx
@@ -0,0 +1,203 @@
+/*
+ * 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 { kfetch } from 'ui/kfetch';
+import {
+ EuiSpacer,
+ EuiCallOut,
+ EuiTitle,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiLink,
+ EuiText,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links';
+import { Alert } from '../../../../alerting/server/types';
+import { getSetupModeState, addSetupModeCallback, toggleSetupMode } from '../../lib/setup_mode';
+import { NUMBER_OF_MIGRATED_ALERTS, ALERT_TYPE_PREFIX } from '../../../common/constants';
+import { AlertsConfiguration } from './configuration';
+
+export interface AlertsStatusProps {
+ clusterUuid: string;
+ emailAddress: string;
+}
+
+export const AlertsStatus: React.FC = (props: AlertsStatusProps) => {
+ const { emailAddress } = props;
+
+ const [setupModeEnabled, setSetupModeEnabled] = React.useState(getSetupModeState().enabled);
+ const [kibanaAlerts, setKibanaAlerts] = React.useState([]);
+ const [showMigrationFlyout, setShowMigrationFlyout] = React.useState(false);
+ const [isSecurityConfigured, setIsSecurityConfigured] = React.useState(false);
+
+ React.useEffect(() => {
+ async function fetchAlertsStatus() {
+ const alerts = await kfetch({ method: 'GET', pathname: `/api/alert/_find` });
+ const monitoringAlerts = alerts.data.filter((alert: Alert) =>
+ alert.alertTypeId.startsWith(ALERT_TYPE_PREFIX)
+ );
+ setKibanaAlerts(monitoringAlerts);
+ }
+
+ fetchAlertsStatus();
+ fetchSecurityConfigured();
+ }, [setupModeEnabled, showMigrationFlyout]);
+
+ React.useEffect(() => {
+ if (!setupModeEnabled && showMigrationFlyout) {
+ setShowMigrationFlyout(false);
+ }
+ }, [setupModeEnabled, showMigrationFlyout]);
+
+ async function fetchSecurityConfigured() {
+ const response = await kfetch({ pathname: '/internal/security/api_key/privileges' });
+ setIsSecurityConfigured(response.areApiKeysEnabled);
+ }
+
+ addSetupModeCallback(() => setSetupModeEnabled(getSetupModeState().enabled));
+
+ function enterSetupModeAndOpenFlyout() {
+ toggleSetupMode(true);
+ setShowMigrationFlyout(true);
+ }
+
+ function getSecurityConfigurationErrorUi() {
+ if (isSecurityConfigured) {
+ return null;
+ }
+
+ const link = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/security-settings.html#api-key-service-settings`;
+ return (
+
+
+
+
+
+ {i18n.translate(
+ 'xpack.monitoring.alerts.configuration.securityConfigurationError.docsLinkLabel',
+ {
+ defaultMessage: 'docs',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+
+
+ );
+ }
+
+ function renderContent() {
+ let flyout = null;
+ if (showMigrationFlyout) {
+ flyout = (
+ setShowMigrationFlyout(false)} aria-labelledby="flyoutTitle">
+
+
+
+ {i18n.translate('xpack.monitoring.alerts.status.flyoutTitle', {
+ defaultMessage: 'Monitoring alerts',
+ })}
+
+
+
+
+ {i18n.translate('xpack.monitoring.alerts.status.flyoutSubtitle', {
+ defaultMessage: 'Configure an email server and email address to receive alerts.',
+ })}
+
+
+ {getSecurityConfigurationErrorUi()}
+
+
+ setShowMigrationFlyout(false)}
+ />
+
+
+ );
+ }
+
+ const allMigrated = kibanaAlerts.length === NUMBER_OF_MIGRATED_ALERTS;
+ if (allMigrated) {
+ if (setupModeEnabled) {
+ return (
+
+
+
+
+ {i18n.translate('xpack.monitoring.alerts.status.manage', {
+ defaultMessage: 'Want to make changes? Click here.',
+ })}
+
+
+
+ {flyout}
+
+ );
+ }
+ } else {
+ return (
+
+
+
+
+ {i18n.translate('xpack.monitoring.alerts.status.needToMigrate', {
+ defaultMessage: 'Migrate cluster alerts to our new alerting platform.',
+ })}
+
+
+
+ {flyout}
+
+ );
+ }
+ }
+
+ const content = renderContent();
+ if (content) {
+ return (
+
+ {content}
+
+
+ );
+ }
+
+ return null;
+};
diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js
index e65aec8602f40..6a1e937a5753d 100644
--- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js
+++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js
@@ -4,11 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React from 'react';
+import React, { Fragment } from 'react';
+import moment from 'moment-timezone';
import { FormattedAlert } from 'plugins/monitoring/components/alerts/formatted_alert';
import { mapSeverity } from 'plugins/monitoring/components/alerts/map_severity';
import { formatTimestampToDuration } from '../../../../common/format_timestamp_to_duration';
-import { CALCULATE_DURATION_SINCE } from '../../../../common/constants';
+import {
+ CALCULATE_DURATION_SINCE,
+ KIBANA_ALERTING_ENABLED,
+ ALERT_TYPE_LICENSE_EXPIRATION,
+ CALCULATE_DURATION_UNTIL,
+} from '../../../../common/constants';
import { formatDateTimeLocal } from '../../../../common/formatting';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
@@ -21,6 +27,7 @@ import {
EuiText,
EuiSpacer,
EuiCallOut,
+ EuiLink,
} from '@elastic/eui';
export function AlertsPanel({ alerts, changeUrl }) {
@@ -82,9 +89,52 @@ export function AlertsPanel({ alerts, changeUrl }) {
);
}
- const topAlertItems = alerts.map((item, index) => (
-
- ));
+ const alertsList = KIBANA_ALERTING_ENABLED
+ ? alerts.map((alert, idx) => {
+ const callOutProps = mapSeverity(alert.severity);
+ let message = alert.message
+ // scan message prefix and replace relative times
+ // \w: Matches any alphanumeric character from the basic Latin alphabet, including the underscore. Equivalent to [A-Za-z0-9_].
+ .replace(
+ '#relative',
+ formatTimestampToDuration(alert.expirationTime, CALCULATE_DURATION_UNTIL)
+ )
+ .replace('#absolute', moment.tz(alert.expirationTime, moment.tz.guess()).format('LLL z'));
+
+ if (!alert.isFiring) {
+ callOutProps.title = i18n.translate(
+ 'xpack.monitoring.cluster.overview.alertsPanel.severityIconTitle',
+ {
+ defaultMessage: '{severityIconTitle} (resolved {time} ago)',
+ values: {
+ severityIconTitle: callOutProps.title,
+ time: formatTimestampToDuration(alert.resolvedMS, CALCULATE_DURATION_SINCE),
+ },
+ }
+ );
+ callOutProps.color = 'success';
+ callOutProps.iconType = 'check';
+ } else {
+ if (alert.type === ALERT_TYPE_LICENSE_EXPIRATION) {
+ message = (
+
+ {message}
+
+ Please update your license
+
+ );
+ }
+ }
+
+ return (
+
+ {message}
+
+ );
+ })
+ : alerts.map((item, index) => (
+
+ ));
return (
@@ -109,7 +159,7 @@ export function AlertsPanel({ alerts, changeUrl }) {
- {topAlertItems}
+ {alertsList}
);
diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js
index 3014a74160107..844fa41029458 100644
--- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js
+++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js
@@ -10,17 +10,26 @@ import { KibanaPanel } from './kibana_panel';
import { LogstashPanel } from './logstash_panel';
import { AlertsPanel } from './alerts_panel';
import { BeatsPanel } from './beats_panel';
-
import { EuiPage, EuiPageBody } from '@elastic/eui';
import { ApmPanel } from './apm_panel';
-import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants';
+import { AlertsStatus } from '../../alerts/status';
+import {
+ STANDALONE_CLUSTER_CLUSTER_UUID,
+ KIBANA_ALERTING_ENABLED,
+} from '../../../../common/constants';
export function Overview(props) {
const isFromStandaloneCluster = props.cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID;
+ const kibanaAlerts = KIBANA_ALERTING_ENABLED ? (
+
+ ) : null;
+
return (
+ {kibanaAlerts}
+
{!isFromStandaloneCluster ? (
diff --git a/x-pack/legacy/plugins/monitoring/public/jest.helpers.ts b/x-pack/legacy/plugins/monitoring/public/jest.helpers.ts
new file mode 100644
index 0000000000000..46ba603d30138
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/jest.helpers.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React from 'react';
+
+/**
+ * Suppress React 16.8 act() warnings globally.
+ * The react teams fix won't be out of alpha until 16.9.0.
+ * https://github.com/facebook/react/issues/14769#issuecomment-514589856
+ */
+const consoleError = console.error; // eslint-disable-line no-console
+beforeAll(() => {
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
+ if (!args[0].includes('Warning: An update to %s inside a test was not wrapped in act')) {
+ consoleError(...args);
+ }
+ });
+});
+
+export function mockUseEffects(count = 1) {
+ const spy = jest.spyOn(React, 'useEffect');
+ for (let i = 0; i < count; i++) {
+ spy.mockImplementationOnce(f => f());
+ }
+}
+
+// export function mockUseEffectForDeps(deps, count = 1) {
+// const spy = jest.spyOn(React, 'useEffect');
+// for (let i = 0; i < count; i++) {
+// spy.mockImplementationOnce((f, depList) => {
+
+// });
+// }
+// }
diff --git a/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.js b/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.tsx
similarity index 94%
rename from x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.js
rename to x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.tsx
index 9a51a88596926..22ce32103c208 100644
--- a/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.js
+++ b/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.tsx
@@ -7,12 +7,13 @@
import React from 'react';
import { contains } from 'lodash';
import { toastNotifications } from 'ui/notify';
+// @ts-ignore
import { formatMsg } from 'ui/notify/lib';
import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
-export function formatMonitoringError(err) {
+export function formatMonitoringError(err: any) {
// TODO: We should stop using Boom for errors and instead write a custom handler to return richer error objects
// then we can do better messages, such as highlighting the Cluster UUID instead of requiring it be part of the message
if (err.status && err.status !== -1 && err.data) {
@@ -33,10 +34,10 @@ export function formatMonitoringError(err) {
return formatMsg(err);
}
-export function ajaxErrorHandlersProvider($injector) {
+export function ajaxErrorHandlersProvider($injector: any) {
const kbnUrl = $injector.get('kbnUrl');
- return err => {
+ return (err: any) => {
if (err.status === 403) {
// redirect to error message view
kbnUrl.redirect('access-denied');
diff --git a/x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts b/x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts
new file mode 100644
index 0000000000000..98d56f9790be4
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { isString, isNumber, capitalize } from 'lodash';
+
+export function getRequiredFieldError(field: string): string {
+ return i18n.translate('xpack.monitoring.alerts.migrate.manageAction.requiredFieldError', {
+ defaultMessage: '{field} is a required field.',
+ values: {
+ field: capitalize(field),
+ },
+ });
+}
+
+export function getMissingFieldErrors(data: any, defaultData: any) {
+ const errors: any = {};
+
+ for (const key in data) {
+ if (!data.hasOwnProperty(key)) {
+ continue;
+ }
+
+ if (isString(defaultData[key])) {
+ if (!data[key] || data[key].length === 0) {
+ errors[key] = getRequiredFieldError(key);
+ }
+ } else if (isNumber(defaultData[key])) {
+ if (isNaN(data[key]) || data[key] === 0) {
+ errors[key] = getRequiredFieldError(key);
+ }
+ }
+ }
+
+ return errors;
+}
+
+export function hasErrors(errors: any) {
+ for (const error in errors) {
+ if (error.length) {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js
index aa931368b34c2..4a2b470f04c72 100644
--- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js
+++ b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js
@@ -90,7 +90,7 @@ describe('setup_mode', () => {
} catch (err) {
error = err;
}
- expect(error).toEqual(
+ expect(error.message).toEqual(
'Unable to interact with setup ' +
'mode because the angular injector was not previously set. This needs to be ' +
'set by calling `initSetupModeState`.'
@@ -255,9 +255,9 @@ describe('setup_mode', () => {
await toggleSetupMode(true);
injectorModulesMock.$http.post.mockClear();
await updateSetupModeData(undefined, true);
- expect(
- injectorModulesMock.$http.post
- ).toHaveBeenCalledWith('../api/monitoring/v1/setup/collection/cluster', { ccs: undefined });
+ const url = '../api/monitoring/v1/setup/collection/cluster';
+ const args = { ccs: undefined };
+ expect(injectorModulesMock.$http.post).toHaveBeenCalledWith(url, args);
});
});
});
diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.tsx
similarity index 76%
rename from x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js
rename to x-pack/legacy/plugins/monitoring/public/lib/setup_mode.tsx
index 41aae01307617..d805c10247b2e 100644
--- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js
+++ b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.tsx
@@ -6,31 +6,49 @@
import React from 'react';
import { render } from 'react-dom';
-import { ajaxErrorHandlersProvider } from './ajax_error_handler';
import { get, contains } from 'lodash';
import chrome from 'ui/chrome';
import { toastNotifications } from 'ui/notify';
import { i18n } from '@kbn/i18n';
-import { SetupModeEnterButton } from '../components/setup_mode/enter_button';
import { npSetup } from 'ui/new_platform';
+import { PluginsSetup } from 'ui/new_platform/new_platform';
+import { CloudSetup } from '../../../../../plugins/cloud/public';
+import { ajaxErrorHandlersProvider } from './ajax_error_handler';
+import { SetupModeEnterButton } from '../components/setup_mode/enter_button';
+
+interface PluginsSetupWithCloud extends PluginsSetup {
+ cloud: CloudSetup;
+}
-function isOnPage(hash) {
+function isOnPage(hash: string) {
return contains(window.location.hash, hash);
}
-const angularState = {
+interface IAngularState {
+ injector: any;
+ scope: any;
+}
+
+const angularState: IAngularState = {
injector: null,
scope: null,
};
const checkAngularState = () => {
if (!angularState.injector || !angularState.scope) {
- throw 'Unable to interact with setup mode because the angular injector was not previously set.' +
- ' This needs to be set by calling `initSetupModeState`.';
+ throw new Error(
+ 'Unable to interact with setup mode because the angular injector was not previously set.' +
+ ' This needs to be set by calling `initSetupModeState`.'
+ );
}
};
-const setupModeState = {
+interface ISetupModeState {
+ enabled: boolean;
+ data: any;
+ callbacks: Function[];
+}
+const setupModeState: ISetupModeState = {
enabled: false,
data: null,
callbacks: [],
@@ -38,7 +56,7 @@ const setupModeState = {
export const getSetupModeState = () => setupModeState;
-export const setNewlyDiscoveredClusterUuid = clusterUuid => {
+export const setNewlyDiscoveredClusterUuid = (clusterUuid: string) => {
const globalState = angularState.injector.get('globalState');
const executor = angularState.injector.get('$executor');
angularState.scope.$apply(() => {
@@ -48,7 +66,7 @@ export const setNewlyDiscoveredClusterUuid = clusterUuid => {
executor.run();
};
-export const fetchCollectionData = async (uuid, fetchWithoutClusterUuid = false) => {
+export const fetchCollectionData = async (uuid?: string, fetchWithoutClusterUuid = false) => {
checkAngularState();
const http = angularState.injector.get('$http');
@@ -75,19 +93,19 @@ export const fetchCollectionData = async (uuid, fetchWithoutClusterUuid = false)
}
};
-const notifySetupModeDataChange = oldData => {
- setupModeState.callbacks.forEach(cb => cb(oldData));
+const notifySetupModeDataChange = (oldData?: any) => {
+ setupModeState.callbacks.forEach((cb: Function) => cb(oldData));
};
-export const updateSetupModeData = async (uuid, fetchWithoutClusterUuid = false) => {
+export const updateSetupModeData = async (uuid?: string, fetchWithoutClusterUuid = false) => {
const oldData = setupModeState.data;
const data = await fetchCollectionData(uuid, fetchWithoutClusterUuid);
setupModeState.data = data;
- const { cloud } = npSetup.plugins;
+ const { cloud } = npSetup.plugins as PluginsSetupWithCloud;
const isCloudEnabled = !!(cloud && cloud.isCloudEnabled);
const hasPermissions = get(data, '_meta.hasPermissions', false);
if (isCloudEnabled || !hasPermissions) {
- let text = null;
+ let text: string = '';
if (!hasPermissions) {
text = i18n.translate('xpack.monitoring.setupMode.notAvailablePermissions', {
defaultMessage: 'You do not have the necessary permissions to do this.',
@@ -113,9 +131,9 @@ export const updateSetupModeData = async (uuid, fetchWithoutClusterUuid = false)
const globalState = angularState.injector.get('globalState');
const clusterUuid = globalState.cluster_uuid;
if (!clusterUuid) {
- const liveClusterUuid = get(data, '_meta.liveClusterUuid');
+ const liveClusterUuid: string = get(data, '_meta.liveClusterUuid');
const migratedEsNodes = Object.values(get(data, 'elasticsearch.byUuid', {})).filter(
- node => node.isPartiallyMigrated || node.isFullyMigrated
+ (node: any) => node.isPartiallyMigrated || node.isFullyMigrated
);
if (liveClusterUuid && migratedEsNodes.length > 0) {
setNewlyDiscoveredClusterUuid(liveClusterUuid);
@@ -140,7 +158,7 @@ export const disableElasticsearchInternalCollection = async () => {
}
};
-export const toggleSetupMode = inSetupMode => {
+export const toggleSetupMode = (inSetupMode: boolean) => {
checkAngularState();
const globalState = angularState.injector.get('globalState');
@@ -164,7 +182,7 @@ export const setSetupModeMenuItem = () => {
}
const globalState = angularState.injector.get('globalState');
- const { cloud } = npSetup.plugins;
+ const { cloud } = npSetup.plugins as PluginsSetupWithCloud;
const isCloudEnabled = !!(cloud && cloud.isCloudEnabled);
const enabled = !globalState.inSetupMode && !isCloudEnabled;
@@ -174,10 +192,14 @@ export const setSetupModeMenuItem = () => {
);
};
-export const initSetupModeState = async ($scope, $injector, callback) => {
+export const addSetupModeCallback = (callback: Function) => setupModeState.callbacks.push(callback);
+
+export const initSetupModeState = async ($scope: any, $injector: any, callback?: Function) => {
angularState.scope = $scope;
angularState.injector = $injector;
- callback && setupModeState.callbacks.push(callback);
+ if (callback) {
+ setupModeState.callbacks.push(callback);
+ }
const globalState = $injector.get('globalState');
if (globalState.inSetupMode) {
diff --git a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js
index 57a7850b6fd53..1bfc76b766457 100644
--- a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js
+++ b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js
@@ -24,7 +24,7 @@ function getPageData($injector) {
const globalState = $injector.get('globalState');
const $http = $injector.get('$http');
const Private = $injector.get('Private');
- const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/alerts`;
+ const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/legacy_alerts`;
const timeBounds = timefilter.getBounds();
diff --git a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js
index bec90f3230571..e7107860d61fa 100644
--- a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js
+++ b/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js
@@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
+import { isEmpty } from 'lodash';
+import chrome from 'ui/chrome';
import { i18n } from '@kbn/i18n';
import uiRoutes from 'ui/routes';
import { routeInitProvider } from 'plugins/monitoring/lib/route_init';
@@ -12,7 +14,11 @@ import { MonitoringViewBaseController } from '../../';
import { Overview } from 'plugins/monitoring/components/cluster/overview';
import { I18nContext } from 'ui/i18n';
import { SetupModeRenderer } from '../../../components/renderers';
-import { CODE_PATH_ALL } from '../../../../common/constants';
+import {
+ CODE_PATH_ALL,
+ MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS,
+ KIBANA_ALERTING_ENABLED,
+} from '../../../../common/constants';
const CODE_PATHS = [CODE_PATH_ALL];
@@ -31,6 +37,7 @@ uiRoutes.when('/overview', {
const monitoringClusters = $injector.get('monitoringClusters');
const globalState = $injector.get('globalState');
const showLicenseExpiration = $injector.get('showLicenseExpiration');
+ const config = $injector.get('config');
super({
title: i18n.translate('xpack.monitoring.cluster.overviewTitle', {
@@ -58,7 +65,16 @@ uiRoutes.when('/overview', {
$scope.$watch(
() => this.data,
- data => {
+ async data => {
+ if (isEmpty(data)) {
+ return;
+ }
+
+ let emailAddress = chrome.getInjected('monitoringLegacyEmailAddress') || '';
+ if (KIBANA_ALERTING_ENABLED) {
+ emailAddress = config.get(MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS) || emailAddress;
+ }
+
this.renderReact(
new Promise(resolve => resolve()),
+ alertInstanceFactory: (id: string) => new AlertInstance(),
+ savedObjectsClient: {} as jest.Mocked,
+ },
+ params: {},
+ state: {},
+ spaceId: '',
+ name: '',
+ tags: [],
+ createdBy: null,
+ updatedBy: null,
+};
+
+describe('getLicenseExpiration', () => {
+ const emailAddress = 'foo@foo.com';
+ const server: any = {
+ newPlatform: {
+ __internals: {
+ uiSettings: {
+ asScopedToClient: (): any => ({
+ get: () => new Promise(resolve => resolve(emailAddress)),
+ }),
+ },
+ },
+ },
+ };
+ const getMonitoringCluster: () => void = jest.fn();
+ const logger: Logger = {
+ warn: jest.fn(),
+ log: jest.fn(),
+ debug: jest.fn(),
+ trace: jest.fn(),
+ error: jest.fn(),
+ fatal: jest.fn(),
+ info: jest.fn(),
+ get: jest.fn(),
+ };
+ const getLogger = (): Logger => logger;
+ const ccrEnabled = false;
+
+ afterEach(() => {
+ (logger.warn as jest.Mock).mockClear();
+ });
+
+ it('should have the right id and actionGroups', () => {
+ const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled);
+ expect(alert.id).toBe(ALERT_TYPE_LICENSE_EXPIRATION);
+ expect(alert.actionGroups).toEqual(['default']);
+ });
+
+ it('should return the state if no license is provided', async () => {
+ const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled);
+
+ const services: MockServices | AlertServices = {
+ callCluster: jest.fn(),
+ alertInstanceFactory: jest.fn(),
+ savedObjectsClient: savedObjectsClientMock.create(),
+ };
+ const state = { foo: 1 };
+
+ const result = await alert.executor({
+ ...alertExecutorOptions,
+ services,
+ params,
+ state,
+ });
+
+ expect(result).toEqual(state);
+ });
+
+ it('should log a warning if no email is provided', async () => {
+ const customServer: any = {
+ newPlatform: {
+ __internals: {
+ uiSettings: {
+ asScopedToClient: () => ({
+ get: () => null,
+ }),
+ },
+ },
+ },
+ };
+ const alert = getLicenseExpiration(customServer, getMonitoringCluster, getLogger, ccrEnabled);
+
+ const services = {
+ callCluster: jest.fn(
+ (method: string, { filterPath }): Promise => {
+ return new Promise(resolve => {
+ if (filterPath.includes('hits.hits._source.license.*')) {
+ resolve(
+ fillLicense({
+ status: 'good',
+ type: 'basic',
+ expiry_date_in_millis: moment()
+ .add(7, 'days')
+ .valueOf(),
+ })
+ );
+ }
+ resolve({});
+ });
+ }
+ ),
+ alertInstanceFactory: jest.fn(),
+ savedObjectsClient: savedObjectsClientMock.create(),
+ };
+
+ const state = {};
+
+ await alert.executor({
+ ...alertExecutorOptions,
+ services,
+ params,
+ state,
+ });
+
+ expect((logger.warn as jest.Mock).mock.calls.length).toBe(1);
+ expect(logger.warn).toHaveBeenCalledWith(
+ `Unable to send email for ${ALERT_TYPE_LICENSE_EXPIRATION} because there is no email configured.`
+ );
+ });
+
+ it('should fire actions if going to expire', async () => {
+ const scheduleActions = jest.fn();
+ const alertInstanceFactory = jest.fn(
+ (id: string): AlertInstance => {
+ const instance = new AlertInstance();
+ instance.scheduleActions = scheduleActions;
+ return instance;
+ }
+ );
+
+ const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled);
+
+ const savedObjectsClient = savedObjectsClientMock.create();
+ savedObjectsClient.get.mockReturnValue(
+ new Promise(resolve => {
+ const savedObject: SavedObject = {
+ id: '',
+ type: '',
+ references: [],
+ attributes: {
+ [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
+ },
+ };
+ resolve(savedObject);
+ })
+ );
+ const services = {
+ callCluster: jest.fn(
+ (method: string, { filterPath }): Promise => {
+ return new Promise(resolve => {
+ if (filterPath.includes('hits.hits._source.license.*')) {
+ resolve(
+ fillLicense(
+ {
+ status: 'active',
+ type: 'gold',
+ expiry_date_in_millis: moment()
+ .add(7, 'days')
+ .valueOf(),
+ },
+ clusterUuid
+ )
+ );
+ }
+ resolve({});
+ });
+ }
+ ),
+ alertInstanceFactory,
+ savedObjectsClient,
+ };
+
+ const state = {};
+
+ const result: AlertState = (await alert.executor({
+ ...alertExecutorOptions,
+ services,
+ params,
+ state,
+ })) as AlertState;
+
+ const newState: AlertClusterState = result[clusterUuid] as AlertClusterState;
+
+ expect(newState.expiredCheckDateMS > 0).toBe(true);
+ expect(scheduleActions.mock.calls.length).toBe(1);
+ expect(scheduleActions.mock.calls[0][1].subject).toBe(
+ 'NEW X-Pack Monitoring: License Expiration'
+ );
+ expect(scheduleActions.mock.calls[0][1].to).toBe(emailAddress);
+ });
+
+ it('should fire actions if the user fixed their license', async () => {
+ const scheduleActions = jest.fn();
+ const alertInstanceFactory = jest.fn(
+ (id: string): AlertInstance => {
+ const instance = new AlertInstance();
+ instance.scheduleActions = scheduleActions;
+ return instance;
+ }
+ );
+ const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled);
+
+ const savedObjectsClient = savedObjectsClientMock.create();
+ savedObjectsClient.get.mockReturnValue(
+ new Promise(resolve => {
+ const savedObject: SavedObject = {
+ id: '',
+ type: '',
+ references: [],
+ attributes: {
+ [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
+ },
+ };
+ resolve(savedObject);
+ })
+ );
+ const services = {
+ callCluster: jest.fn(
+ (method: string, { filterPath }): Promise => {
+ return new Promise(resolve => {
+ if (filterPath.includes('hits.hits._source.license.*')) {
+ resolve(
+ fillLicense(
+ {
+ status: 'active',
+ type: 'gold',
+ expiry_date_in_millis: moment()
+ .add(120, 'days')
+ .valueOf(),
+ },
+ clusterUuid
+ )
+ );
+ }
+ resolve({});
+ });
+ }
+ ),
+ alertInstanceFactory,
+ savedObjectsClient,
+ };
+
+ const state: AlertState = {
+ [clusterUuid]: {
+ expiredCheckDateMS: moment()
+ .subtract(1, 'day')
+ .valueOf(),
+ ui: { isFiring: true, severity: 0, message: null, resolvedMS: 0, expirationTime: 0 },
+ },
+ };
+
+ const result: AlertState = (await alert.executor({
+ ...alertExecutorOptions,
+ services,
+ params,
+ state,
+ })) as AlertState;
+
+ const newState: AlertClusterState = result[clusterUuid] as AlertClusterState;
+ expect(newState.expiredCheckDateMS).toBe(0);
+ expect(scheduleActions.mock.calls.length).toBe(1);
+ expect(scheduleActions.mock.calls[0][1].subject).toBe(
+ 'RESOLVED X-Pack Monitoring: License Expiration'
+ );
+ expect(scheduleActions.mock.calls[0][1].to).toBe(emailAddress);
+ });
+
+ it('should not fire actions for trial license that expire in more than 14 days', async () => {
+ const scheduleActions = jest.fn();
+ const alertInstanceFactory = jest.fn(
+ (id: string): AlertInstance => {
+ const instance = new AlertInstance();
+ instance.scheduleActions = scheduleActions;
+ return instance;
+ }
+ );
+ const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled);
+
+ const savedObjectsClient = savedObjectsClientMock.create();
+ savedObjectsClient.get.mockReturnValue(
+ new Promise(resolve => {
+ const savedObject: SavedObject = {
+ id: '',
+ type: '',
+ references: [],
+ attributes: {
+ [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
+ },
+ };
+ resolve(savedObject);
+ })
+ );
+ const services = {
+ callCluster: jest.fn(
+ (method: string, { filterPath }): Promise => {
+ return new Promise(resolve => {
+ if (filterPath.includes('hits.hits._source.license.*')) {
+ resolve(
+ fillLicense(
+ {
+ status: 'active',
+ type: 'trial',
+ expiry_date_in_millis: moment()
+ .add(15, 'days')
+ .valueOf(),
+ },
+ clusterUuid
+ )
+ );
+ }
+ resolve({});
+ });
+ }
+ ),
+ alertInstanceFactory,
+ savedObjectsClient,
+ };
+
+ const state = {};
+ const result: AlertState = (await alert.executor({
+ ...alertExecutorOptions,
+ services,
+ params,
+ state,
+ })) as AlertState;
+
+ const newState: AlertClusterState = result[clusterUuid] as AlertClusterState;
+ expect(newState.expiredCheckDateMS).toBe(undefined);
+ expect(scheduleActions).not.toHaveBeenCalled();
+ });
+
+ it('should fire actions for trial license that in 14 days or less', async () => {
+ const scheduleActions = jest.fn();
+ const alertInstanceFactory = jest.fn(
+ (id: string): AlertInstance => {
+ const instance = new AlertInstance();
+ instance.scheduleActions = scheduleActions;
+ return instance;
+ }
+ );
+ const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled);
+
+ const savedObjectsClient = savedObjectsClientMock.create();
+ savedObjectsClient.get.mockReturnValue(
+ new Promise(resolve => {
+ const savedObject: SavedObject = {
+ id: '',
+ type: '',
+ references: [],
+ attributes: {
+ [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
+ },
+ };
+ resolve(savedObject);
+ })
+ );
+ const services = {
+ callCluster: jest.fn(
+ (method: string, { filterPath }): Promise => {
+ return new Promise(resolve => {
+ if (filterPath.includes('hits.hits._source.license.*')) {
+ resolve(
+ fillLicense(
+ {
+ status: 'active',
+ type: 'trial',
+ expiry_date_in_millis: moment()
+ .add(13, 'days')
+ .valueOf(),
+ },
+ clusterUuid
+ )
+ );
+ }
+ resolve({});
+ });
+ }
+ ),
+ alertInstanceFactory,
+ savedObjectsClient,
+ };
+
+ const state = {};
+ const result: AlertState = (await alert.executor({
+ ...alertExecutorOptions,
+ services,
+ params,
+ state,
+ })) as AlertState;
+
+ const newState: AlertClusterState = result[clusterUuid] as AlertClusterState;
+ expect(newState.expiredCheckDateMS > 0).toBe(true);
+ expect(scheduleActions.mock.calls.length).toBe(1);
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts b/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts
new file mode 100644
index 0000000000000..197c5c9cdcbc7
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts
@@ -0,0 +1,162 @@
+/*
+ * 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 moment from 'moment-timezone';
+import { get } from 'lodash';
+import { Legacy } from 'kibana';
+import { Logger } from 'src/core/server';
+import { ALERT_TYPE_LICENSE_EXPIRATION, INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants';
+import { AlertType } from '../../../alerting';
+import { fetchLicenses } from '../lib/alerts/fetch_licenses';
+import { fetchDefaultEmailAddress } from '../lib/alerts/fetch_default_email_address';
+import { fetchClusters } from '../lib/alerts/fetch_clusters';
+import { fetchAvailableCcs } from '../lib/alerts/fetch_available_ccs';
+import {
+ AlertLicense,
+ AlertState,
+ AlertClusterState,
+ AlertClusterUiState,
+ LicenseExpirationAlertExecutorOptions,
+} from './types';
+import { getCcsIndexPattern } from '../lib/alerts/get_ccs_index_pattern';
+import { executeActions, getUiMessage } from '../lib/alerts/license_expiration.lib';
+
+const EXPIRES_DAYS = [60, 30, 14, 7];
+
+export const getLicenseExpiration = (
+ server: Legacy.Server,
+ getMonitoringCluster: any,
+ getLogger: (contexts: string[]) => Logger,
+ ccsEnabled: boolean
+): AlertType => {
+ async function getCallCluster(services: any): Promise {
+ const monitoringCluster = await getMonitoringCluster();
+ if (!monitoringCluster) {
+ return services.callCluster;
+ }
+
+ return monitoringCluster.callCluster;
+ }
+
+ const logger = getLogger([ALERT_TYPE_LICENSE_EXPIRATION]);
+ return {
+ id: ALERT_TYPE_LICENSE_EXPIRATION,
+ name: 'Monitoring Alert - License Expiration',
+ actionGroups: ['default'],
+ async executor({
+ services,
+ params,
+ state,
+ }: LicenseExpirationAlertExecutorOptions): Promise {
+ logger.debug(
+ `Firing alert with params: ${JSON.stringify(params)} and state: ${JSON.stringify(state)}`
+ );
+
+ const callCluster = await getCallCluster(services);
+
+ // Support CCS use cases by querying to find available remote clusters
+ // and then adding those to the index pattern we are searching against
+ let esIndexPattern = INDEX_PATTERN_ELASTICSEARCH;
+ if (ccsEnabled) {
+ const availableCcs = await fetchAvailableCcs(callCluster);
+ if (availableCcs.length > 0) {
+ esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs);
+ }
+ }
+
+ const clusters = await fetchClusters(callCluster, esIndexPattern);
+
+ // Fetch licensing information from cluster_stats documents
+ const licenses: AlertLicense[] = await fetchLicenses(callCluster, clusters, esIndexPattern);
+ if (licenses.length === 0) {
+ logger.warn(`No license found for ${ALERT_TYPE_LICENSE_EXPIRATION}.`);
+ return state;
+ }
+
+ const uiSettings = server.newPlatform.__internals.uiSettings.asScopedToClient(
+ services.savedObjectsClient
+ );
+ const dateFormat: string = await uiSettings.get('dateFormat');
+ const timezone: string = await uiSettings.get('dateFormat:tz');
+ const emailAddress = await fetchDefaultEmailAddress(uiSettings);
+ if (!emailAddress) {
+ // TODO: we can do more here
+ logger.warn(
+ `Unable to send email for ${ALERT_TYPE_LICENSE_EXPIRATION} because there is no email configured.`
+ );
+ return;
+ }
+
+ const result: AlertState = { ...state };
+
+ for (const license of licenses) {
+ const licenseState: AlertClusterState = state[license.clusterUuid] || {};
+ const $expiry = moment.utc(license.expiryDateMS);
+ let isExpired = false;
+ let severity = 0;
+
+ if (license.status !== 'active') {
+ isExpired = true;
+ severity = 2001;
+ } else if (license.expiryDateMS) {
+ for (let i = EXPIRES_DAYS.length - 1; i >= 0; i--) {
+ if (license.type === 'trial' && i < 2) {
+ break;
+ }
+
+ const $fromNow = moment.utc().add(EXPIRES_DAYS[i], 'days');
+ if ($fromNow.isAfter($expiry)) {
+ isExpired = true;
+ severity = 1000 * i;
+ break;
+ }
+ }
+ }
+
+ const ui: AlertClusterUiState = get(licenseState, 'ui', {
+ isFiring: false,
+ message: null,
+ severity: 0,
+ resolvedMS: 0,
+ expirationTime: 0,
+ });
+ let resolved = ui.resolvedMS;
+ let message = ui.message;
+ let expiredCheckDate = licenseState.expiredCheckDateMS;
+ const instance = services.alertInstanceFactory(ALERT_TYPE_LICENSE_EXPIRATION);
+
+ if (isExpired) {
+ if (!licenseState.expiredCheckDateMS) {
+ logger.debug(`License will expire soon, sending email`);
+ executeActions(instance, license, $expiry, dateFormat, emailAddress);
+ expiredCheckDate = moment().valueOf();
+ }
+ message = getUiMessage(license, timezone);
+ resolved = 0;
+ } else if (!isExpired && licenseState.expiredCheckDateMS) {
+ logger.debug(`License expiration has been resolved, sending email`);
+ executeActions(instance, license, $expiry, dateFormat, emailAddress, true);
+ expiredCheckDate = 0;
+ message = getUiMessage(license, timezone, true);
+ resolved = moment().valueOf();
+ }
+
+ result[license.clusterUuid] = {
+ expiredCheckDateMS: expiredCheckDate,
+ ui: {
+ message,
+ expirationTime: license.expiryDateMS,
+ isFiring: expiredCheckDate > 0,
+ severity,
+ resolvedMS: resolved,
+ },
+ };
+ }
+
+ return result;
+ },
+ };
+};
diff --git a/x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts b/x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts
new file mode 100644
index 0000000000000..6346ca00dabbd
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 { Moment } from 'moment';
+import { AlertExecutorOptions } from '../../../alerting';
+
+export interface AlertLicense {
+ status: string;
+ type: string;
+ expiryDateMS: number;
+ clusterUuid: string;
+ clusterName: string;
+}
+
+export interface AlertState {
+ [clusterUuid: string]: AlertClusterState;
+}
+
+export interface AlertClusterState {
+ expiredCheckDateMS: number | Moment;
+ ui: AlertClusterUiState;
+}
+
+export interface AlertClusterUiState {
+ isFiring: boolean;
+ severity: number;
+ message: string | null;
+ resolvedMS: number;
+ expirationTime: number;
+}
+
+export interface AlertCluster {
+ clusterUuid: string;
+}
+
+export interface LicenseExpirationAlertExecutorOptions extends AlertExecutorOptions {
+ state: AlertState;
+}
+
+export interface AlertParams {
+ dateFormat: string;
+ timezone: string;
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts
new file mode 100644
index 0000000000000..4398b2dd675ec
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { fetchAvailableCcs } from './fetch_available_ccs';
+
+describe('fetchAvailableCcs', () => {
+ it('should call the `cluster.remoteInfo` api', async () => {
+ const callCluster = jest.fn();
+ await fetchAvailableCcs(callCluster);
+ expect(callCluster).toHaveBeenCalledWith('cluster.remoteInfo');
+ });
+
+ it('should return clusters that are connected', async () => {
+ const connectedRemote = 'myRemote';
+ const callCluster = jest.fn().mockImplementation(() => ({
+ [connectedRemote]: {
+ connected: true,
+ },
+ }));
+ const result = await fetchAvailableCcs(callCluster);
+ expect(result).toEqual([connectedRemote]);
+ });
+
+ it('should not return clusters that are connected', async () => {
+ const disconnectedRemote = 'myRemote';
+ const callCluster = jest.fn().mockImplementation(() => ({
+ [disconnectedRemote]: {
+ connected: false,
+ },
+ }));
+ const result = await fetchAvailableCcs(callCluster);
+ expect(result.length).toBe(0);
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts
new file mode 100644
index 0000000000000..34efaff93f34c
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts
@@ -0,0 +1,19 @@
+/*
+ * 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 async function fetchAvailableCcs(callCluster: any): Promise {
+ const availableCcs = [];
+ const response = await callCluster('cluster.remoteInfo');
+ for (const remoteName in response) {
+ if (!response.hasOwnProperty(remoteName)) {
+ continue;
+ }
+ const remoteInfo = response[remoteName];
+ if (remoteInfo.connected) {
+ availableCcs.push(remoteName);
+ }
+ }
+ return availableCcs;
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts
new file mode 100644
index 0000000000000..78eb9773df15f
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts
@@ -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 { fetchClusters } from './fetch_clusters';
+
+describe('fetchClusters', () => {
+ it('return a list of clusters', async () => {
+ const callCluster = jest.fn().mockImplementation(() => ({
+ aggregations: {
+ clusters: {
+ buckets: [
+ {
+ key: 'clusterA',
+ },
+ ],
+ },
+ },
+ }));
+ const index = '.monitoring-es-*';
+ const result = await fetchClusters(callCluster, index);
+ expect(result).toEqual([{ clusterUuid: 'clusterA' }]);
+ });
+
+ it('should limit the time period in the query', async () => {
+ const callCluster = jest.fn();
+ const index = '.monitoring-es-*';
+ await fetchClusters(callCluster, index);
+ const params = callCluster.mock.calls[0][1];
+ expect(params.body.query.bool.filter[1].range.timestamp.gte).toBe('now-2m');
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.ts
new file mode 100644
index 0000000000000..8ef7339618a2c
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 { get } from 'lodash';
+import { AlertCluster } from '../../alerts/types';
+
+interface AggregationResult {
+ key: string;
+}
+
+export async function fetchClusters(callCluster: any, index: string): Promise {
+ const params = {
+ index,
+ filterPath: 'aggregations.clusters.buckets',
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ {
+ term: {
+ type: 'cluster_stats',
+ },
+ },
+ {
+ range: {
+ timestamp: {
+ gte: 'now-2m',
+ },
+ },
+ },
+ ],
+ },
+ },
+ aggs: {
+ clusters: {
+ terms: {
+ field: 'cluster_uuid',
+ size: 1000,
+ },
+ },
+ },
+ },
+ };
+
+ const response = await callCluster('search', params);
+ return get(response, 'aggregations.clusters.buckets', []).map((bucket: AggregationResult) => ({
+ clusterUuid: bucket.key,
+ }));
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.test.ts
new file mode 100644
index 0000000000000..25b09b956038a
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.test.ts
@@ -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 { fetchDefaultEmailAddress } from './fetch_default_email_address';
+import { uiSettingsServiceMock } from '../../../../../../../src/core/server/mocks';
+
+describe('fetchDefaultEmailAddress', () => {
+ it('get the email address', async () => {
+ const email = 'test@test.com';
+ const uiSettingsClient = uiSettingsServiceMock.createClient();
+ uiSettingsClient.get.mockResolvedValue(email);
+ const result = await fetchDefaultEmailAddress(uiSettingsClient);
+ expect(result).toBe(email);
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.ts
new file mode 100644
index 0000000000000..88e4199a88256
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { IUiSettingsClient } from 'src/core/server';
+import { MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS } from '../../../common/constants';
+
+export async function fetchDefaultEmailAddress(
+ uiSettingsClient: IUiSettingsClient
+): Promise {
+ return await uiSettingsClient.get(MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS);
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts
new file mode 100644
index 0000000000000..dd6c074e68b1f
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts
@@ -0,0 +1,105 @@
+/*
+ * 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 { fetchLicenses } from './fetch_licenses';
+
+describe('fetchLicenses', () => {
+ it('return a list of licenses', async () => {
+ const clusterName = 'MyCluster';
+ const clusterUuid = 'clusterA';
+ const license = {
+ status: 'active',
+ expiry_date_in_millis: 1579532493876,
+ type: 'basic',
+ };
+ const callCluster = jest.fn().mockImplementation(() => ({
+ hits: {
+ hits: [
+ {
+ _source: {
+ license,
+ cluster_name: clusterName,
+ cluster_uuid: clusterUuid,
+ },
+ },
+ ],
+ },
+ }));
+ const clusters = [{ clusterUuid }];
+ const index = '.monitoring-es-*';
+ const result = await fetchLicenses(callCluster, clusters, index);
+ expect(result).toEqual([
+ {
+ status: license.status,
+ type: license.type,
+ expiryDateMS: license.expiry_date_in_millis,
+ clusterUuid,
+ clusterName,
+ },
+ ]);
+ });
+
+ it('should only search for the clusters provided', async () => {
+ const clusterUuid = 'clusterA';
+ const callCluster = jest.fn();
+ const clusters = [{ clusterUuid }];
+ const index = '.monitoring-es-*';
+ await fetchLicenses(callCluster, clusters, index);
+ const params = callCluster.mock.calls[0][1];
+ expect(params.body.query.bool.filter[0].terms.cluster_uuid).toEqual([clusterUuid]);
+ });
+
+ it('should limit the time period in the query', async () => {
+ const clusterUuid = 'clusterA';
+ const callCluster = jest.fn();
+ const clusters = [{ clusterUuid }];
+ const index = '.monitoring-es-*';
+ await fetchLicenses(callCluster, clusters, index);
+ const params = callCluster.mock.calls[0][1];
+ expect(params.body.query.bool.filter[2].range.timestamp.gte).toBe('now-2m');
+ });
+
+ it('should give priority to the metadata name', async () => {
+ const clusterName = 'MyCluster';
+ const clusterUuid = 'clusterA';
+ const license = {
+ status: 'active',
+ expiry_date_in_millis: 1579532493876,
+ type: 'basic',
+ };
+ const callCluster = jest.fn().mockImplementation(() => ({
+ hits: {
+ hits: [
+ {
+ _source: {
+ license,
+ cluster_name: 'fakeName',
+ cluster_uuid: clusterUuid,
+ cluster_settings: {
+ cluster: {
+ metadata: {
+ display_name: clusterName,
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ }));
+ const clusters = [{ clusterUuid }];
+ const index = '.monitoring-es-*';
+ const result = await fetchLicenses(callCluster, clusters, index);
+ expect(result).toEqual([
+ {
+ status: license.status,
+ type: license.type,
+ expiryDateMS: license.expiry_date_in_millis,
+ clusterUuid,
+ clusterName,
+ },
+ ]);
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.ts
new file mode 100644
index 0000000000000..31a68e8aa9c3e
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { get } from 'lodash';
+import { AlertLicense, AlertCluster } from '../../alerts/types';
+
+export async function fetchLicenses(
+ callCluster: any,
+ clusters: AlertCluster[],
+ index: string
+): Promise {
+ const params = {
+ index,
+ filterPath: [
+ 'hits.hits._source.license.*',
+ 'hits.hits._source.cluster_settings.cluster.metadata.display_name',
+ 'hits.hits._source.cluster_uuid',
+ 'hits.hits._source.cluster_name',
+ ],
+ body: {
+ size: 1,
+ sort: [{ timestamp: { order: 'desc' } }],
+ query: {
+ bool: {
+ filter: [
+ {
+ terms: {
+ cluster_uuid: clusters.map(cluster => cluster.clusterUuid),
+ },
+ },
+ {
+ term: {
+ type: 'cluster_stats',
+ },
+ },
+ {
+ range: {
+ timestamp: {
+ gte: 'now-2m',
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ };
+
+ const response = await callCluster('search', params);
+ return get(response, 'hits.hits', []).map((hit: any) => {
+ const clusterName: string =
+ get(hit, '_source.cluster_settings.cluster.metadata.display_name') ||
+ get(hit, '_source.cluster_name') ||
+ get(hit, '_source.cluster_uuid');
+ const rawLicense: any = get(hit, '_source.license', {});
+ const license: AlertLicense = {
+ status: rawLicense.status,
+ type: rawLicense.type,
+ expiryDateMS: rawLicense.expiry_date_in_millis,
+ clusterUuid: get(hit, '_source.cluster_uuid'),
+ clusterName,
+ };
+ return license;
+ });
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_status.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_status.ts
new file mode 100644
index 0000000000000..9f7c1d5a994d2
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_status.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import moment from 'moment';
+import { get } from 'lodash';
+import { AlertClusterState } from '../../alerts/types';
+import { ALERT_TYPES, LOGGING_TAG } from '../../../common/constants';
+
+export async function fetchStatus(
+ callCluster: any,
+ start: number,
+ end: number,
+ clusterUuid: string,
+ server: any
+): Promise {
+ // TODO: this shouldn't query task manager directly but rather
+ // use an api exposed by the alerting/actions plugin
+ // See https://github.com/elastic/kibana/issues/48442
+ const statuses = await Promise.all(
+ ALERT_TYPES.map(
+ type =>
+ new Promise(async (resolve, reject) => {
+ try {
+ const params = {
+ index: '.kibana_task_manager',
+ filterPath: ['hits.hits._source.task.state'],
+ body: {
+ size: 1,
+ sort: [{ updated_at: { order: 'desc' } }],
+ query: {
+ bool: {
+ filter: [
+ {
+ term: {
+ 'task.taskType': `alerting:${type}`,
+ },
+ },
+ ],
+ },
+ },
+ },
+ };
+
+ const response = await callCluster('search', params);
+ const state = get(response, 'hits.hits[0]._source.task.state', '{}');
+ const clusterState: AlertClusterState = get(
+ JSON.parse(state),
+ `alertTypeState.${clusterUuid}`,
+ {
+ expiredCheckDateMS: 0,
+ ui: {
+ isFiring: false,
+ message: null,
+ severity: 0,
+ resolvedMS: 0,
+ expirationTime: 0,
+ },
+ }
+ );
+ const isInBetween = moment(clusterState.ui.resolvedMS).isBetween(start, end);
+ if (clusterState.ui.isFiring || isInBetween) {
+ return resolve({
+ type,
+ ...clusterState.ui,
+ });
+ }
+ return resolve(false);
+ } catch (err) {
+ const reason = get(err, 'body.error.type');
+ if (reason === 'index_not_found_exception') {
+ server.log(
+ ['error', LOGGING_TAG],
+ `Unable to fetch alerts. Alerts depends on task manager, which has not been started yet.`
+ );
+ } else {
+ server.log(['error', LOGGING_TAG], err.message);
+ }
+ return resolve(false);
+ }
+ })
+ )
+ );
+
+ return statuses.filter(Boolean);
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.test.ts
new file mode 100644
index 0000000000000..a5eb104986161
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.test.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { getCcsIndexPattern } from './get_ccs_index_pattern';
+
+describe('getCcsIndexPattern', () => {
+ it('should return an index pattern including remotes', () => {
+ const remotes = ['Remote1', 'Remote2'];
+ const index = '.monitoring-es-*';
+ const result = getCcsIndexPattern(index, remotes);
+ expect(result).toBe('.monitoring-es-*,Remote1:.monitoring-es-*,Remote2:.monitoring-es-*');
+ });
+
+ it('should return an index pattern from multiple index patterns including remotes', () => {
+ const remotes = ['Remote1', 'Remote2'];
+ const index = '.monitoring-es-*,.monitoring-kibana-*';
+ const result = getCcsIndexPattern(index, remotes);
+ expect(result).toBe(
+ '.monitoring-es-*,.monitoring-kibana-*,Remote1:.monitoring-es-*,Remote2:.monitoring-es-*,Remote1:.monitoring-kibana-*,Remote2:.monitoring-kibana-*'
+ );
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts
new file mode 100644
index 0000000000000..b562fde2a0810
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+export function getCcsIndexPattern(indexPattern: string, remotes: string[]): string {
+ return `${indexPattern},${indexPattern
+ .split(',')
+ .map(pattern => {
+ return remotes.map(remoteName => `${remoteName}:${pattern}`).join(',');
+ })
+ .join(',')}`;
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts
new file mode 100644
index 0000000000000..1a2eb1e44be84
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 moment from 'moment-timezone';
+import { executeActions, getUiMessage } from './license_expiration.lib';
+
+describe('licenseExpiration lib', () => {
+ describe('executeActions', () => {
+ const clusterName = 'clusterA';
+ const instance: any = { scheduleActions: jest.fn() };
+ const license: any = { clusterName };
+ const $expiry = moment('2020-01-20');
+ const dateFormat = 'dddd, MMMM Do YYYY, h:mm:ss a';
+ const emailAddress = 'test@test.com';
+
+ beforeEach(() => {
+ instance.scheduleActions.mockClear();
+ });
+
+ it('should schedule actions when firing', () => {
+ executeActions(instance, license, $expiry, dateFormat, emailAddress, false);
+ expect(instance.scheduleActions).toHaveBeenCalledWith('default', {
+ subject: 'NEW X-Pack Monitoring: License Expiration',
+ message: `Cluster '${clusterName}' license is going to expire on Monday, January 20th 2020, 12:00:00 am. Please update your license.`,
+ to: emailAddress,
+ });
+ });
+
+ it('should schedule actions when resolved', () => {
+ executeActions(instance, license, $expiry, dateFormat, emailAddress, true);
+ expect(instance.scheduleActions).toHaveBeenCalledWith('default', {
+ subject: 'RESOLVED X-Pack Monitoring: License Expiration',
+ message: `This cluster alert has been resolved: Cluster '${clusterName}' license was going to expire on Monday, January 20th 2020, 12:00:00 am.`,
+ to: emailAddress,
+ });
+ });
+ });
+
+ describe('getUiMessage', () => {
+ const timezone = 'Europe/London';
+ const license: any = { expiryDateMS: moment.tz('2020-01-20 08:00:00', timezone).utc() };
+
+ it('should return a message when firing', () => {
+ const message = getUiMessage(license, timezone, false);
+ expect(message).toBe(`This cluster's license is going to expire in #relative at #absolute.`);
+ });
+
+ it('should return a message when resolved', () => {
+ const message = getUiMessage(license, timezone, true);
+ expect(message).toBe(`This cluster's license is active.`);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts
new file mode 100644
index 0000000000000..8a75fc1fbbd82
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { Moment } from 'moment-timezone';
+import { i18n } from '@kbn/i18n';
+import { AlertInstance } from '../../../../alerting/server/alert_instance';
+import { AlertLicense } from '../../alerts/types';
+
+const RESOLVED_SUBJECT = i18n.translate(
+ 'xpack.monitoring.alerts.licenseExpiration.resolvedSubject',
+ {
+ defaultMessage: 'RESOLVED X-Pack Monitoring: License Expiration',
+ }
+);
+
+const NEW_SUBJECT = i18n.translate('xpack.monitoring.alerts.licenseExpiration.newSubject', {
+ defaultMessage: 'NEW X-Pack Monitoring: License Expiration',
+});
+
+export function executeActions(
+ instance: AlertInstance,
+ license: AlertLicense,
+ $expiry: Moment,
+ dateFormat: string,
+ emailAddress: string,
+ resolved: boolean = false
+) {
+ if (resolved) {
+ instance.scheduleActions('default', {
+ subject: RESOLVED_SUBJECT,
+ message: `This cluster alert has been resolved: Cluster '${
+ license.clusterName
+ }' license was going to expire on ${$expiry.format(dateFormat)}.`,
+ to: emailAddress,
+ });
+ } else {
+ instance.scheduleActions('default', {
+ subject: NEW_SUBJECT,
+ message: `Cluster '${license.clusterName}' license is going to expire on ${$expiry.format(
+ dateFormat
+ )}. Please update your license.`,
+ to: emailAddress,
+ });
+ }
+}
+
+export function getUiMessage(license: AlertLicense, timezone: string, resolved: boolean = false) {
+ if (resolved) {
+ return i18n.translate('xpack.monitoring.alerts.licenseExpiration.ui.resolvedMessage', {
+ defaultMessage: `This cluster's license is active.`,
+ });
+ }
+ return i18n.translate('xpack.monitoring.alerts.licenseExpiration.ui.firingMessage', {
+ defaultMessage: `This cluster's license is going to expire in #relative at #absolute.`,
+ });
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js
index 2b080a5c333fc..a5426dc04545e 100644
--- a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js
+++ b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js
@@ -16,6 +16,7 @@ import { getBeatsForClusters } from '../beats';
import { alertsClustersAggregation } from '../../cluster_alerts/alerts_clusters_aggregation';
import { alertsClusterSearch } from '../../cluster_alerts/alerts_cluster_search';
import { checkLicense as checkLicenseForAlerts } from '../../cluster_alerts/check_license';
+import { fetchStatus } from '../alerts/fetch_status';
import { getClustersSummary } from './get_clusters_summary';
import {
CLUSTER_ALERTS_SEARCH_SIZE,
@@ -27,6 +28,7 @@ import {
CODE_PATH_LOGSTASH,
CODE_PATH_BEATS,
CODE_PATH_APM,
+ KIBANA_ALERTING_ENABLED,
} from '../../../common/constants';
import { getApmsForClusters } from '../apm/get_apms_for_clusters';
import { i18n } from '@kbn/i18n';
@@ -99,15 +101,31 @@ export async function getClustersFromRequest(
if (mlJobs !== null) {
cluster.ml = { jobs: mlJobs };
}
- const alerts = isInCodePath(codePaths, [CODE_PATH_ALERTS])
- ? await alertsClusterSearch(req, alertsIndex, cluster, checkLicenseForAlerts, {
+
+ if (isInCodePath(codePaths, [CODE_PATH_ALERTS])) {
+ if (KIBANA_ALERTING_ENABLED) {
+ const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring');
+ const callCluster = (...args) => callWithRequest(req, ...args);
+ cluster.alerts = await fetchStatus(
+ callCluster,
start,
end,
- size: CLUSTER_ALERTS_SEARCH_SIZE,
- })
- : null;
- if (alerts) {
- cluster.alerts = alerts;
+ cluster.cluster_uuid,
+ req.server
+ );
+ } else {
+ cluster.alerts = await alertsClusterSearch(
+ req,
+ alertsIndex,
+ cluster,
+ checkLicenseForAlerts,
+ {
+ start,
+ end,
+ size: CLUSTER_ALERTS_SEARCH_SIZE,
+ }
+ );
+ }
}
cluster.logs = isInCodePath(codePaths, [CODE_PATH_LOGS])
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/get_date_format.js b/x-pack/legacy/plugins/monitoring/server/lib/get_date_format.js
new file mode 100644
index 0000000000000..89cbf20d9b56f
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/get_date_format.js
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export async function getDateFormat(req) {
+ return await req.getUiSettingsService().get('dateFormat');
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js
index 5f52e0c6a983b..a12b48510a6ff 100644
--- a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js
+++ b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js
@@ -348,7 +348,6 @@ export const getCollectionStatus = async (
},
};
}
-
const liveClusterUuid = skipLiveData ? null : await getLiveElasticsearchClusterUuid(req);
const isLiveCluster = !clusterUuid || liveClusterUuid === clusterUuid;
diff --git a/x-pack/legacy/plugins/monitoring/server/plugin.js b/x-pack/legacy/plugins/monitoring/server/plugin.js
index ef346e95ad075..50e5319a0f526 100644
--- a/x-pack/legacy/plugins/monitoring/server/plugin.js
+++ b/x-pack/legacy/plugins/monitoring/server/plugin.js
@@ -5,12 +5,17 @@
*/
import { i18n } from '@kbn/i18n';
-import { LOGGING_TAG, KIBANA_MONITORING_LOGGING_TAG } from '../common/constants';
+import {
+ LOGGING_TAG,
+ KIBANA_MONITORING_LOGGING_TAG,
+ KIBANA_ALERTING_ENABLED,
+} from '../common/constants';
import { requireUIRoutes } from './routes';
import { instantiateClient } from './es_client/instantiate_client';
import { initMonitoringXpackInfo } from './init_monitoring_xpack_info';
import { initBulkUploader, registerCollectors } from './kibana_monitoring';
import { registerMonitoringCollection } from './telemetry_collection';
+import { getLicenseExpiration } from './alerts/license_expiration';
import { parseElasticsearchConfig } from './es_client/parse_elasticsearch_config';
export class Plugin {
@@ -133,5 +138,37 @@ export class Plugin {
showCgroupMetricsLogstash: config.get('monitoring.ui.container.logstash.enabled'), // Note, not currently used, but see https://github.com/elastic/x-pack-kibana/issues/1559 part 2
};
});
+
+ if (KIBANA_ALERTING_ENABLED && plugins.alerting) {
+ // this is not ready right away but we need to register alerts right away
+ async function getMonitoringCluster() {
+ const configs = config.get('xpack.monitoring.elasticsearch');
+ if (configs.hosts) {
+ const monitoringCluster = plugins.elasticsearch.getCluster('monitoring');
+ const { username, password } = configs;
+ const fakeRequest = {
+ headers: {
+ authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
+ },
+ };
+ return {
+ callCluster: (...args) => monitoringCluster.callWithRequest(fakeRequest, ...args),
+ };
+ }
+ return null;
+ }
+
+ function getLogger(contexts) {
+ return core.logger.get('plugins', LOGGING_TAG, ...contexts);
+ }
+ plugins.alerting.setup.registerType(
+ getLicenseExpiration(
+ core._hapi,
+ getMonitoringCluster,
+ getLogger,
+ config.get('xpack.monitoring.ccs.enabled')
+ )
+ );
+ }
}
}
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js
new file mode 100644
index 0000000000000..f87683effe437
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js
@@ -0,0 +1,89 @@
+/*
+ * 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 Joi from 'joi';
+import { isFunction } from 'lodash';
+import {
+ ALERT_TYPE_LICENSE_EXPIRATION,
+ MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS,
+} from '../../../../../common/constants';
+
+async function createAlerts(req, alertsClient, { selectedEmailActionId }) {
+ const createdAlerts = [];
+
+ // Create alerts
+ const ALERT_TYPES = {
+ [ALERT_TYPE_LICENSE_EXPIRATION]: {
+ schedule: { interval: '10s' },
+ actions: [
+ {
+ group: 'default',
+ id: selectedEmailActionId,
+ params: {
+ subject: '{{context.subject}}',
+ message: `{{context.message}}`,
+ to: ['{{context.to}}'],
+ },
+ },
+ ],
+ },
+ };
+
+ for (const alertTypeId of Object.keys(ALERT_TYPES)) {
+ const existingAlert = await alertsClient.find({
+ options: {
+ search: alertTypeId,
+ },
+ });
+ if (existingAlert.total === 1) {
+ await alertsClient.delete({ id: existingAlert.data[0].id });
+ }
+
+ const result = await alertsClient.create({
+ data: {
+ enabled: true,
+ alertTypeId,
+ ...ALERT_TYPES[alertTypeId],
+ },
+ });
+ createdAlerts.push(result);
+ }
+
+ return createdAlerts;
+}
+
+async function saveEmailAddress(emailAddress, uiSettingsService) {
+ await uiSettingsService.set(MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS, emailAddress);
+}
+
+export function createKibanaAlertsRoute(server) {
+ server.route({
+ method: 'POST',
+ path: '/api/monitoring/v1/alerts',
+ config: {
+ validate: {
+ payload: Joi.object({
+ selectedEmailActionId: Joi.string().required(),
+ emailAddress: Joi.string().required(),
+ }),
+ },
+ },
+ async handler(req, headers) {
+ const { emailAddress, selectedEmailActionId } = req.payload;
+ const alertsClient = isFunction(req.getAlertsClient) ? req.getAlertsClient() : null;
+ if (!alertsClient) {
+ return headers.response().code(404);
+ }
+
+ const [alerts, emailResponse] = await Promise.all([
+ createAlerts(req, alertsClient, { ...req.params, selectedEmailActionId }),
+ saveEmailAddress(emailAddress, req.getUiSettingsService()),
+ ]);
+
+ return { alerts, emailResponse };
+ },
+ });
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js
index cdcd776b349fc..246cdfde97cff 100644
--- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js
@@ -4,54 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import Joi from 'joi';
-import { alertsClusterSearch } from '../../../../cluster_alerts/alerts_cluster_search';
-import { checkLicense } from '../../../../cluster_alerts/check_license';
-import { getClusterLicense } from '../../../../lib/cluster/get_cluster_license';
-import { prefixIndexPattern } from '../../../../lib/ccs_utils';
-import { INDEX_PATTERN_ELASTICSEARCH, INDEX_ALERTS } from '../../../../../common/constants';
-
-/*
- * Cluster Alerts route.
- */
-export function clusterAlertsRoute(server) {
- server.route({
- method: 'POST',
- path: '/api/monitoring/v1/clusters/{clusterUuid}/alerts',
- config: {
- validate: {
- params: Joi.object({
- clusterUuid: Joi.string().required(),
- }),
- payload: Joi.object({
- ccs: Joi.string().optional(),
- timeRange: Joi.object({
- min: Joi.date().required(),
- max: Joi.date().required(),
- }).required(),
- }),
- },
- },
- handler(req) {
- const config = server.config();
- const ccs = req.payload.ccs;
- const clusterUuid = req.params.clusterUuid;
- const esIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_ELASTICSEARCH, ccs);
- const alertsIndex = prefixIndexPattern(config, INDEX_ALERTS, ccs);
- const options = {
- start: req.payload.timeRange.min,
- end: req.payload.timeRange.max,
- };
-
- return getClusterLicense(req, esIndexPattern, clusterUuid).then(license =>
- alertsClusterSearch(
- req,
- alertsIndex,
- { cluster_uuid: clusterUuid, license },
- checkLicense,
- options
- )
- );
- },
- });
-}
+export * from './legacy_alerts';
+export * from './alerts';
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js
new file mode 100644
index 0000000000000..a3049f0f3e2d2
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js
@@ -0,0 +1,57 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import Joi from 'joi';
+import { alertsClusterSearch } from '../../../../cluster_alerts/alerts_cluster_search';
+import { checkLicense } from '../../../../cluster_alerts/check_license';
+import { getClusterLicense } from '../../../../lib/cluster/get_cluster_license';
+import { prefixIndexPattern } from '../../../../lib/ccs_utils';
+import { INDEX_PATTERN_ELASTICSEARCH, INDEX_ALERTS } from '../../../../../common/constants';
+
+/*
+ * Cluster Alerts route.
+ */
+export function legacyClusterAlertsRoute(server) {
+ server.route({
+ method: 'POST',
+ path: '/api/monitoring/v1/clusters/{clusterUuid}/legacy_alerts',
+ config: {
+ validate: {
+ params: Joi.object({
+ clusterUuid: Joi.string().required(),
+ }),
+ payload: Joi.object({
+ ccs: Joi.string().optional(),
+ timeRange: Joi.object({
+ min: Joi.date().required(),
+ max: Joi.date().required(),
+ }).required(),
+ }),
+ },
+ },
+ handler(req) {
+ const config = server.config();
+ const ccs = req.payload.ccs;
+ const clusterUuid = req.params.clusterUuid;
+ const esIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_ELASTICSEARCH, ccs);
+ const alertsIndex = prefixIndexPattern(config, INDEX_ALERTS, ccs);
+ const options = {
+ start: req.payload.timeRange.min,
+ end: req.payload.timeRange.max,
+ };
+
+ return getClusterLicense(req, esIndexPattern, clusterUuid).then(license =>
+ alertsClusterSearch(
+ req,
+ alertsIndex,
+ { cluster_uuid: clusterUuid, license },
+ checkLicense,
+ options
+ )
+ );
+ },
+ });
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js
index baffbfd5f3f6f..de0213ec84689 100644
--- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js
@@ -6,7 +6,7 @@
// all routes for the app
export { checkAccessRoute } from './check_access';
-export { clusterAlertsRoute } from './alerts/';
+export * from './alerts/';
export { beatsDetailRoute, beatsListingRoute, beatsOverviewRoute } from './beats';
export { clusterRoute, clustersRoute } from './cluster';
export {
diff --git a/x-pack/legacy/plugins/monitoring/ui_exports.js b/x-pack/legacy/plugins/monitoring/ui_exports.js
index 9251deb673bd1..49f167b0f1b10 100644
--- a/x-pack/legacy/plugins/monitoring/ui_exports.js
+++ b/x-pack/legacy/plugins/monitoring/ui_exports.js
@@ -6,6 +6,10 @@
import { i18n } from '@kbn/i18n';
import { resolve } from 'path';
+import {
+ MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS,
+ KIBANA_ALERTING_ENABLED,
+} from './common/constants';
import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils';
/**
@@ -14,28 +18,48 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils';
* app (injectDefaultVars and hacks)
* @return {Object} data per Kibana plugin uiExport schema
*/
-export const getUiExports = () => ({
- app: {
- title: i18n.translate('xpack.monitoring.stackMonitoringTitle', {
- defaultMessage: 'Stack Monitoring',
- }),
- order: 9002,
- description: i18n.translate('xpack.monitoring.uiExportsDescription', {
- defaultMessage: 'Monitoring for Elastic Stack',
- }),
- icon: 'plugins/monitoring/icons/monitoring.svg',
- euiIconType: 'monitoringApp',
- linkToLastSubUrl: false,
- main: 'plugins/monitoring/monitoring',
- category: DEFAULT_APP_CATEGORIES.management,
- },
- injectDefaultVars(server) {
- const config = server.config();
- return {
- monitoringUiEnabled: config.get('monitoring.ui.enabled'),
+export const getUiExports = () => {
+ const uiSettingDefaults = {};
+ if (KIBANA_ALERTING_ENABLED) {
+ uiSettingDefaults[MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS] = {
+ name: i18n.translate('xpack.monitoring.alertingEmailAddress.name', {
+ defaultMessage: 'Alerting email address',
+ }),
+ value: '',
+ description: i18n.translate('xpack.monitoring.alertingEmailAddress.description', {
+ defaultMessage: `The default email address to receive alerts from Stack Monitoring`,
+ }),
+ category: ['monitoring'],
};
- },
- hacks: ['plugins/monitoring/hacks/toggle_app_link_in_nav'],
- home: ['plugins/monitoring/register_feature'],
- styleSheetPaths: resolve(__dirname, 'public/index.scss'),
-});
+ }
+
+ return {
+ app: {
+ title: i18n.translate('xpack.monitoring.stackMonitoringTitle', {
+ defaultMessage: 'Stack Monitoring',
+ }),
+ order: 9002,
+ description: i18n.translate('xpack.monitoring.uiExportsDescription', {
+ defaultMessage: 'Monitoring for Elastic Stack',
+ }),
+ icon: 'plugins/monitoring/icons/monitoring.svg',
+ euiIconType: 'monitoringApp',
+ linkToLastSubUrl: false,
+ main: 'plugins/monitoring/monitoring',
+ category: DEFAULT_APP_CATEGORIES.management,
+ },
+ injectDefaultVars(server) {
+ const config = server.config();
+ return {
+ monitoringUiEnabled: config.get('monitoring.ui.enabled'),
+ monitoringLegacyEmailAddress: config.get(
+ 'monitoring.cluster_alerts.email_notifications.email_address'
+ ),
+ };
+ },
+ uiSettingDefaults,
+ hacks: ['plugins/monitoring/hacks/toggle_app_link_in_nav'],
+ home: ['plugins/monitoring/register_feature'],
+ styleSheetPaths: resolve(__dirname, 'public/index.scss'),
+ };
+};
diff --git a/x-pack/plugins/actions/common/types.ts b/x-pack/plugins/actions/common/types.ts
index 784125b83859d..fbd7404a2f15e 100644
--- a/x-pack/plugins/actions/common/types.ts
+++ b/x-pack/plugins/actions/common/types.ts
@@ -9,3 +9,10 @@ export interface ActionType {
name: string;
enabled: boolean;
}
+
+export interface ActionResult {
+ id: string;
+ actionTypeId: string;
+ name: string;
+ config: Record;
+}