[Monitoring] Migrate license expiration alert to Kibana alerting (#54306)

* License expiration

* Flip off

* Only require alerting and actions if enabled

* Support date formating and timezones in the alert UI messages, support ccs better

* Fix status tests

* Fix up front end tests

* Fix linting, and switch this back

* Add this back in so legacy alerts continue to work

* Fix type issues

* Handle CCS better

* Code cleanup

* Fix type issues

* Flip this off, and fix test

* Moved the email address config to advanced settings, but need help with test failures and typescript

* Fix issue with task manager

* Deprecate email_address

* Use any until we can figure out this TS issue

* Fix type issue

* More tests

* Fix mocha tests

* Use mock instead of any

* I'm not sure why these changed...

* Provide timezone in moment usage in tests for consistency

* Fix type issue

* Change how we get dateFormat and timezone

* Change where we calculate the dates to show in the alerts UI

* Show deprecation warning based on the feature toggle

* Ensure we are using UTC

* PR feedback

* Only add this if the feature flag is enabled

* Fix tests

* Ensure we only attempt to look this up if the feature flag is enabled

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Chris Roberson 2020-02-03 15:55:50 -05:00 committed by GitHub
parent e28e149b46
commit 6398a9911d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 4223 additions and 130 deletions

View file

@ -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'];

View file

@ -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) => {

View file

@ -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'),
@ -59,6 +64,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),
@ -73,11 +79,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,

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Status should render a flyout when clicking the link 1`] = `
<EuiFlyout
aria-labelledby="flyoutTitle"
closeButtonAriaLabel="Closes this dialog"
hideCloseButton={false}
maxWidth={false}
onClose={[Function]}
ownFocus={false}
size="m"
>
<EuiFlyoutHeader
hasBorder={true}
>
<EuiTitle
size="m"
>
<h2>
Monitoring alerts
</h2>
</EuiTitle>
<EuiText>
<p>
Configure an email server and email address to receive alerts.
</p>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<AlertsConfiguration
emailAddress="test@elastic.co"
onDone={[Function]}
/>
</EuiFlyoutBody>
</EuiFlyout>
`;
exports[`Status should render a success message if all alerts have been migrated and in setup mode 1`] = `
<EuiCallOut
color="success"
iconType="flag"
title="Kibana alerting is up to date!"
>
<p>
<EuiLink
onClick={[Function]}
>
Want to make changes? Click here.
</EuiLink>
</p>
</EuiCallOut>
`;
exports[`Status should render without setup mode 1`] = `
<Fragment>
<EuiCallOut
color="warning"
title="Hey! We made alerting better!"
>
<p>
<EuiLink
onClick={[Function]}
>
Migrate cluster alerts to our new alerting platform.
</EuiLink>
</p>
</EuiCallOut>
<EuiSpacer />
</Fragment>
`;

View file

@ -0,0 +1,120 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Configuration shallow view should render step 1 1`] = `
<Fragment>
<EuiSuperSelect
compressed={false}
fullWidth={false}
hasDividers={true}
isInvalid={false}
onChange={[Function]}
options={
Array [
Object {
"dropdownDisplay": <EuiText>
Create new email action...
</EuiText>,
"inputDisplay": <EuiText>
Create new email action...
</EuiText>,
"value": "__new__",
},
]
}
valueOfSelected=""
/>
</Fragment>
`;
exports[`Configuration shallow view should render step 2 1`] = `
<EuiForm
isInvalid={false}
>
<EuiFormRow
describedByIds={Array []}
display="row"
error={null}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Email address"
labelType="label"
>
<EuiFieldText
disabled={true}
onChange={[Function]}
value="test@elastic.co"
/>
</EuiFormRow>
</EuiForm>
`;
exports[`Configuration shallow view should render step 3 1`] = `
<Fragment>
<EuiButton
isDisabled={true}
isLoading={false}
onClick={[Function]}
>
Save
</EuiButton>
</Fragment>
`;
exports[`Configuration should render high level steps 1`] = `
<div
className="euiSteps"
>
<EuiStep
headingElement="p"
key="0"
status="incomplete"
step={1}
title="Create email action"
>
<Step1
editAction={null}
emailActions={Array []}
emailAddress="test@elastic.co"
onActionDone={[Function]}
selectedEmailActionId=""
setEditAction={[Function]}
setSelectedEmailActionId={[Function]}
/>
</EuiStep>
<EuiStep
headingElement="p"
key="1"
status="disabled"
step={2}
title="Set the email to receive alerts"
>
<Step2
emailAddress="test@elastic.co"
formErrors={
Object {
"email": null,
}
}
isDisabled={true}
setEmailAddress={[Function]}
showFormErrors={false}
/>
</EuiStep>
<EuiStep
headingElement="p"
key="2"
status="disabled"
step={3}
title="Confirm and save"
>
<Step3
error=""
isDisabled={true}
isSaving={false}
save={[Function]}
/>
</EuiStep>
</div>
`;

View file

@ -0,0 +1,297 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Step1 creating should render a create form 1`] = `
<Fragment>
<EuiPanel>
<ManageEmailAction
createEmailAction={[Function]}
isNew={true}
/>
</EuiPanel>
</Fragment>
`;
exports[`Step1 editing should allow for editing 1`] = `
<Fragment>
<EuiText>
<p>
Edit the action below.
</p>
</EuiText>
<EuiSpacer />
<ManageEmailAction
action={
Object {
"actionTypeId": "1abc",
"config": Object {},
"id": "1",
"name": "Testing",
}
}
cancel={[Function]}
createEmailAction={[Function]}
isNew={false}
/>
</Fragment>
`;
exports[`Step1 should render normally 1`] = `
<Fragment>
<EuiSuperSelect
compressed={false}
fullWidth={false}
hasDividers={true}
isInvalid={false}
onChange={[Function]}
options={
Array [
Object {
"dropdownDisplay": <EuiText>
From: , Service:
</EuiText>,
"inputDisplay": <EuiText>
From: , Service:
</EuiText>,
"value": "1",
},
Object {
"dropdownDisplay": <EuiText>
Create new email action...
</EuiText>,
"inputDisplay": <EuiText>
Create new email action...
</EuiText>,
"value": "__new__",
},
]
}
valueOfSelected="1"
/>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem
grow={false}
>
<EuiButton
iconType="pencil"
onClick={[Function]}
size="s"
>
Edit
</EuiButton>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
iconType="play"
isDisabled={false}
isLoading={false}
onClick={[Function]}
size="s"
>
Test
</EuiButton>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="danger"
iconType="trash"
isLoading={false}
onClick={[Function]}
size="s"
>
Delete
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
`;
exports[`Step1 testing should should a tooltip if there is no email address 1`] = `
<EuiToolTip
content="Please configure an email address below to test this action."
delay="regular"
position="top"
>
<EuiButton
iconType="play"
isDisabled={true}
isLoading={false}
onClick={[Function]}
size="s"
>
Test
</EuiButton>
</EuiToolTip>
`;
exports[`Step1 testing should show a failed test error 1`] = `
<Fragment>
<EuiSuperSelect
compressed={false}
fullWidth={false}
hasDividers={true}
isInvalid={false}
onChange={[Function]}
options={
Array [
Object {
"dropdownDisplay": <EuiText>
From: , Service:
</EuiText>,
"inputDisplay": <EuiText>
From: , Service:
</EuiText>,
"value": "1",
},
Object {
"dropdownDisplay": <EuiText>
Create new email action...
</EuiText>,
"inputDisplay": <EuiText>
Create new email action...
</EuiText>,
"value": "__new__",
},
]
}
valueOfSelected="1"
/>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem
grow={false}
>
<EuiButton
iconType="pencil"
onClick={[Function]}
size="s"
>
Edit
</EuiButton>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
iconType="play"
isDisabled={false}
isLoading={false}
onClick={[Function]}
size="s"
>
Test
</EuiButton>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="danger"
iconType="trash"
isLoading={false}
onClick={[Function]}
size="s"
>
Delete
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiCallOut
color="danger"
iconType="alert"
title="Unable to send test email. Please double check your email configuration."
>
<p>
Very detailed error message
</p>
</EuiCallOut>
</Fragment>
`;
exports[`Step1 testing should show a successful test 1`] = `
<Fragment>
<EuiSuperSelect
compressed={false}
fullWidth={false}
hasDividers={true}
isInvalid={false}
onChange={[Function]}
options={
Array [
Object {
"dropdownDisplay": <EuiText>
From: , Service:
</EuiText>,
"inputDisplay": <EuiText>
From: , Service:
</EuiText>,
"value": "1",
},
Object {
"dropdownDisplay": <EuiText>
Create new email action...
</EuiText>,
"inputDisplay": <EuiText>
Create new email action...
</EuiText>,
"value": "__new__",
},
]
}
valueOfSelected="1"
/>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem
grow={false}
>
<EuiButton
iconType="pencil"
onClick={[Function]}
size="s"
>
Edit
</EuiButton>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
iconType="play"
isDisabled={false}
isLoading={false}
onClick={[Function]}
size="s"
>
Test
</EuiButton>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="danger"
iconType="trash"
isLoading={false}
onClick={[Function]}
size="s"
>
Delete
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiText
color="secondary"
>
<p>
Looks good on our end!
</p>
</EuiText>
</Fragment>
`;

View file

@ -0,0 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Step2 should render normally 1`] = `
<EuiForm
isInvalid={false}
>
<EuiFormRow
describedByIds={Array []}
display="row"
error={null}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Email address"
labelType="label"
>
<EuiFieldText
disabled={false}
onChange={[Function]}
value="test@test.com"
/>
</EuiFormRow>
</EuiForm>
`;
exports[`Step2 should show form errors 1`] = `
<EuiForm
isInvalid={true}
>
<EuiFormRow
describedByIds={Array []}
display="row"
error="This is required"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={true}
label="Email address"
labelType="label"
>
<EuiFieldText
disabled={false}
onChange={[Function]}
value="test@test.com"
/>
</EuiFormRow>
</EuiForm>
`;

View file

@ -0,0 +1,95 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Step3 should render normally 1`] = `
<Fragment>
<EuiButton
isDisabled={false}
isLoading={false}
onClick={[MockFunction]}
>
Save
</EuiButton>
</Fragment>
`;
exports[`Step3 should show a disabled state 1`] = `
<Fragment>
<EuiButton
isDisabled={true}
isLoading={false}
onClick={
[MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
}
>
Save
</EuiButton>
</Fragment>
`;
exports[`Step3 should show a saving state 1`] = `
<Fragment>
<EuiButton
isDisabled={false}
isLoading={true}
onClick={
[MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
}
>
Save
</EuiButton>
</Fragment>
`;
exports[`Step3 should show an error 1`] = `
<Fragment>
<EuiCallOut
color="danger"
iconType="alert"
title="Unable to save"
>
<p>
Test error
</p>
</EuiCallOut>
<EuiSpacer />
<EuiButton
isDisabled={false}
isLoading={false}
onClick={
[MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
}
>
Save
</EuiButton>
</Fragment>
`;

View file

@ -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(<AlertsConfiguration {...defaultProps} />);
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(<AlertsConfiguration {...defaultProps} />);
const stepOne = getStep(component, 0);
expect(stepOne).toMatchSnapshot();
});
it('should render step 2', () => {
const component = shallow(<AlertsConfiguration {...defaultProps} />);
const stepTwo = getStep(component, 1);
expect(stepTwo).toMatchSnapshot();
});
it('should render step 3', () => {
const component = shallow(<AlertsConfiguration {...defaultProps} />);
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(<AlertsConfiguration {...defaultProps} />);
});
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(<AlertsConfiguration {...defaultProps} />);
});
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(<AlertsConfiguration {...defaultProps} emailAddress="" />);
});
it('should disable Step3', async () => {
const steps = component.find('EuiSteps').dive();
expect(steps.find('Step3').prop('isDisabled')).toBe(true);
});
});
});

View file

@ -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<AlertsConfigurationProps> = (
props: AlertsConfigurationProps
) => {
const { onDone } = props;
const [emailActions, setEmailActions] = React.useState<ActionResult[]>([]);
const [selectedEmailActionId, setSelectedEmailActionId] = React.useState('');
const [editAction, setEditAction] = React.useState<ActionResult | null>(null);
const [emailAddress, setEmailAddress] = React.useState(props.emailAddress);
const [formErrors, setFormErrors] = React.useState<AlertsConfigurationForm>({ 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: (
<Step1
onActionDone={async () => 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: (
<Step2
emailAddress={emailAddress}
setEmailAddress={setEmailAddress}
showFormErrors={showFormErrors}
formErrors={formErrors}
isDisabled={isStep2Disabled()}
/>
),
},
{
title: i18n.translate('xpack.monitoring.alerts.configuration.confirm', {
defaultMessage: 'Confirm and save',
}),
status: getStep2Status(),
children: (
<Step3 isSaving={isSaving} save={save} isDisabled={isStep3Disabled()} error={saveError} />
),
},
];
return (
<div>
<EuiSteps steps={steps} />
</div>
);
};

View file

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

View file

@ -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<GetStep1Props>;
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(<Step1 {...defaultProps} />);
expect(component).toMatchSnapshot();
});
describe('creating', () => {
it('should render a create form', () => {
const customProps = {
emailActions: [],
selectedEmailActionId: NEW_ACTION_ID,
};
const component = shallow(<Step1 {...defaultProps} {...customProps} />);
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(<Step1 {...defaultProps} {...customProps} />);
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(<Step1 {...defaultProps} {...customProps} />);
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(<Step1 {...defaultProps} {...customProps} />);
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(<Step1 {...defaultProps} {...customProps} />);
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(<Step1 {...defaultProps} />);
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(<Step1 {...defaultProps} />);
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(<Step1 {...defaultProps} />);
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(<Step1 {...defaultProps} {...customProps} />);
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(<Step1 {...defaultProps} {...customProps} />);
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(<Step1 {...defaultProps} {...customProps} />);
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);
});
});
});

View file

@ -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<void>;
emailActions: ActionResult[];
selectedEmailActionId: string;
setSelectedEmailActionId: (id: string) => void;
emailAddress: string;
editAction: ActionResult | null;
setEditAction: (action: ActionResult | null) => void;
}
export const Step1: React.FC<GetStep1Props> = (props: GetStep1Props) => {
const [isTesting, setIsTesting] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const [testingStatus, setTestingStatus] = React.useState<string | boolean | null>(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 = (
<EuiButton
size="s"
iconType="play"
onClick={testEmailAction}
isLoading={isTesting}
isDisabled={isTestingDisabled}
>
{i18n.translate('xpack.monitoring.alerts.configuration.testConfiguration.buttonText', {
defaultMessage: 'Test',
})}
</EuiButton>
);
if (isTestingDisabled) {
return (
<EuiToolTip
position="top"
content={i18n.translate(
'xpack.monitoring.alerts.configuration.testConfiguration.disabledTooltipText',
{
defaultMessage: 'Please configure an email address below to test this action.',
}
)}
>
{testBtn}
</EuiToolTip>
);
}
return testBtn;
}
if (props.editAction) {
return (
<Fragment>
<EuiText>
<p>
{i18n.translate('xpack.monitoring.alerts.configuration.step1.editAction', {
defaultMessage: 'Edit the action below.',
})}
</p>
</EuiText>
<EuiSpacer />
<ManageEmailAction
createEmailAction={async (data: EmailActionData) => await createEmailAction(data)}
cancel={() => props.setEditAction(null)}
isNew={false}
action={props.editAction}
/>
</Fragment>
);
}
const newAction = (
<EuiText>
{i18n.translate('xpack.monitoring.alerts.configuration.newActionDropdownDisplay', {
defaultMessage: 'Create new email action...',
})}
</EuiText>
);
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: <EuiText>{actionLabel}</EuiText>,
dropdownDisplay: <EuiText>{actionLabel}</EuiText>,
};
}),
{
value: NEW_ACTION_ID,
inputDisplay: newAction,
dropdownDisplay: newAction,
},
];
let selectBox: React.ReactNode | null = (
<EuiSuperSelect
options={options}
valueOfSelected={props.selectedEmailActionId}
onChange={id => props.setSelectedEmailActionId(id)}
hasDividers
/>
);
let createNew = null;
if (props.selectedEmailActionId === NEW_ACTION_ID) {
createNew = (
<EuiPanel>
<ManageEmailAction
createEmailAction={async (data: EmailActionData) => await createEmailAction(data)}
isNew={true}
/>
</EuiPanel>
);
// 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 = (
<Fragment>
{selectBox}
<EuiSpacer />
</Fragment>
);
}
}
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 = (
<Fragment>
<EuiSpacer />
<EuiText color="secondary">
<p>
{i18n.translate('xpack.monitoring.alerts.configuration.testConfiguration.success', {
defaultMessage: 'Looks good on our end!',
})}
</p>
</EuiText>
</Fragment>
);
} else if (testingStatus === false) {
testingStatusUi = (
<Fragment>
<EuiSpacer />
<EuiCallOut
title={i18n.translate('xpack.monitoring.alerts.configuration.step1.testingError', {
defaultMessage:
'Unable to send test email. Please double check your email configuration.',
})}
color="danger"
iconType="alert"
>
<p>{fullTestingError}</p>
</EuiCallOut>
</Fragment>
);
}
manageConfiguration = (
<Fragment>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
iconType="pencil"
onClick={() => {
const editAction =
props.emailActions.find(action => action.id === props.selectedEmailActionId) ||
null;
props.setEditAction(editAction);
}}
>
{i18n.translate(
'xpack.monitoring.alerts.configuration.editConfiguration.buttonText',
{
defaultMessage: 'Edit',
}
)}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>{getTestButton()}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
color="danger"
iconType="trash"
onClick={() => deleteEmailAction(props.selectedEmailActionId)}
isLoading={isDeleting}
>
{i18n.translate(
'xpack.monitoring.alerts.configuration.deleteConfiguration.buttonText',
{
defaultMessage: 'Delete',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{testingStatusUi}
</Fragment>
);
}
return (
<Fragment>
{selectBox}
{manageConfiguration}
{createNew}
</Fragment>
);
};

View file

@ -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(<Step2 {...defaultProps} />);
expect(component).toMatchSnapshot();
});
it('should set the email address properly', () => {
const newEmail = 'email@email.com';
const component = shallow(<Step2 {...defaultProps} />);
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(<Step2 {...defaultProps} {...customProps} />);
expect(component).toMatchSnapshot();
});
it('should disable properly', () => {
const customProps = {
isDisabled: true,
};
const component = shallow(<Step2 {...defaultProps} {...customProps} />);
expect(component.find('EuiFieldText').prop('disabled')).toBe(true);
});
});

View file

@ -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<GetStep2Props> = (props: GetStep2Props) => {
return (
<EuiForm isInvalid={props.showFormErrors}>
<EuiFormRow
label={i18n.translate('xpack.monitoring.alerts.configuration.emailAddressLabel', {
defaultMessage: 'Email address',
})}
error={props.formErrors.email}
isInvalid={props.showFormErrors && !!props.formErrors.email}
>
<EuiFieldText
value={props.emailAddress}
disabled={props.isDisabled}
onChange={e => props.setEmailAddress(e.target.value)}
/>
</EuiFormRow>
</EuiForm>
);
};

View file

@ -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(<Step3 {...defaultProps} />);
expect(component).toMatchSnapshot();
});
it('should save properly', () => {
const component = shallow(<Step3 {...defaultProps} />);
component.find('EuiButton').simulate('click');
expect(defaultProps.save).toHaveBeenCalledWith();
});
it('should show a saving state', () => {
const customProps = { isSaving: true };
const component = shallow(<Step3 {...defaultProps} {...customProps} />);
expect(component).toMatchSnapshot();
});
it('should show a disabled state', () => {
const customProps = { isDisabled: true };
const component = shallow(<Step3 {...defaultProps} {...customProps} />);
expect(component).toMatchSnapshot();
});
it('should show an error', () => {
const customProps = { error: 'Test error' };
const component = shallow(<Step3 {...defaultProps} {...customProps} />);
expect(component).toMatchSnapshot();
});
});

View file

@ -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<GetStep3Props> = (props: GetStep3Props) => {
let errorUi = null;
if (props.error) {
errorUi = (
<Fragment>
<EuiCallOut
title={i18n.translate('xpack.monitoring.alerts.configuration.step3.saveError', {
defaultMessage: 'Unable to save',
})}
color="danger"
iconType="alert"
>
<p>{props.error}</p>
</EuiCallOut>
<EuiSpacer />
</Fragment>
);
}
return (
<Fragment>
{errorUi}
<EuiButton isLoading={props.isSaving} isDisabled={props.isDisabled} onClick={props.save}>
{i18n.translate('xpack.monitoring.alerts.configuration.save', {
defaultMessage: 'Save',
})}
</EuiButton>
</Fragment>
);
};

View file

@ -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<ManageActionModalProps> = (
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<EmailActionData | any>(
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: <EuiText>{service}</EuiText>,
dropdownDisplay: <EuiText>{service}</EuiText>,
}));
serviceOptions.push({
value: NEW_SERVICE_ID,
inputDisplay: (
<EuiText>
{i18n.translate('xpack.monitoring.alerts.migrate.manageAction.addingNewServiceText', {
defaultMessage: 'Adding new service...',
})}
</EuiText>
),
dropdownDisplay: (
<EuiText>
{i18n.translate('xpack.monitoring.alerts.migrate.manageAction.addNewServiceText', {
defaultMessage: 'Add new service...',
})}
</EuiText>
),
});
let addNewServiceUi = null;
if (createNewService) {
addNewServiceUi = (
<Fragment>
<EuiSpacer />
<EuiFieldText
value={newService}
onChange={e => setNewService(e.target.value)}
isInvalid={showErrors}
/>
</Fragment>
);
}
return (
<EuiForm isInvalid={showErrors} error={Object.values(errors)}>
<EuiFormRow
label={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.serviceText', {
defaultMessage: 'Service',
})}
helpText={
<EuiLink external target="_blank" href="https://nodemailer.com/smtp/well-known/">
{i18n.translate('xpack.monitoring.alerts.migrate.manageAction.serviceHelpText', {
defaultMessage: 'Find out more',
})}
</EuiLink>
}
error={errors.service}
isInvalid={showErrors && !!errors.service}
>
<Fragment>
<EuiSuperSelect
options={serviceOptions}
valueOfSelected={data.service}
onChange={id => {
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}
</Fragment>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.hostText', {
defaultMessage: 'Host',
})}
helpText={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.hostHelpText', {
defaultMessage: 'Host name of the service provider',
})}
error={errors.host}
isInvalid={showErrors && !!errors.host}
>
<EuiFieldText
value={data.host}
onChange={e => setData({ ...data, host: e.target.value })}
isInvalid={showErrors && !!errors.host}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.portText', {
defaultMessage: 'Port',
})}
helpText={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.portHelpText', {
defaultMessage: 'Port number of the service provider',
})}
error={errors.port}
isInvalid={showErrors && !!errors.port}
>
<EuiFieldNumber
value={data.port}
onChange={e => setData({ ...data, port: parseInt(e.target.value, 10) })}
isInvalid={showErrors && !!errors.port}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.secureText', {
defaultMessage: 'Secure',
})}
helpText={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.secureHelpText', {
defaultMessage: 'Whether to use TLS with the service provider',
})}
>
<EuiSwitch
label=""
checked={data.secure}
onChange={e => setData({ ...data, secure: e.target.checked })}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.fromText', {
defaultMessage: 'From',
})}
helpText={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.fromHelpText', {
defaultMessage: 'The from email address for alerts',
})}
error={errors.from}
isInvalid={showErrors && !!errors.from}
>
<EuiFieldText
value={data.from}
onChange={e => setData({ ...data, from: e.target.value })}
isInvalid={showErrors && !!errors.from}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.userText', {
defaultMessage: 'User',
})}
helpText={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.userHelpText', {
defaultMessage: 'The user to use with the service provider',
})}
error={errors.user}
isInvalid={showErrors && !!errors.user}
>
<EuiFieldText
value={data.user}
onChange={e => setData({ ...data, user: e.target.value })}
isInvalid={showErrors && !!errors.user}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.passwordText', {
defaultMessage: 'Password',
})}
helpText={i18n.translate('xpack.monitoring.alerts.migrate.manageAction.passwordHelpText', {
defaultMessage: 'The password to use with the service provider',
})}
error={errors.password}
isInvalid={showErrors && !!errors.password}
>
<EuiFieldPassword
value={data.password}
onChange={e => setData({ ...data, password: e.target.value })}
isInvalid={showErrors && !!errors.password}
/>
</EuiFormRow>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton type="submit" fill onClick={saveEmailAction} isLoading={isSaving}>
{isNew ? CREATE_LABEL : SAVE_LABEL}
</EuiButton>
</EuiFlexItem>
{!action || isNew ? null : (
<EuiFlexItem grow={false}>
<EuiButton onClick={cancel}>{CANCEL_LABEL}</EuiButton>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiForm>
);
};

View file

@ -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(<AlertsStatus {...defaultProps} />);
expect(component).toMatchSnapshot();
});
it('should render a flyout when clicking the link', async () => {
(getSetupModeState as jest.Mock).mockReturnValue({
enabled: true,
});
const component = shallow(<AlertsStatus {...defaultProps} />);
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(<AlertsStatus {...defaultProps} />);
await component.update();
expect(component.find('EuiCallOut')).toMatchSnapshot();
});
});

View file

@ -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<AlertsStatusProps> = (props: AlertsStatusProps) => {
const { emailAddress } = props;
const [setupModeEnabled, setSetupModeEnabled] = React.useState(getSetupModeState().enabled);
const [kibanaAlerts, setKibanaAlerts] = React.useState<Alert[]>([]);
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 (
<Fragment>
<EuiSpacer />
<EuiCallOut
title={i18n.translate(
'xpack.monitoring.alerts.configuration.securityConfigurationErrorTitle',
{
defaultMessage: 'API keys are not enabled in Elasticsearch',
}
)}
color="danger"
iconType="alert"
>
<p>
<FormattedMessage
id="xpack.monitoring.alerts.configuration.securityConfigurationErrorMessage"
defaultMessage="Refer to the {link} to enable API keys."
values={{
link: (
<EuiLink href={link} target="_blank">
{i18n.translate(
'xpack.monitoring.alerts.configuration.securityConfigurationError.docsLinkLabel',
{
defaultMessage: 'docs',
}
)}
</EuiLink>
),
}}
/>
</p>
</EuiCallOut>
</Fragment>
);
}
function renderContent() {
let flyout = null;
if (showMigrationFlyout) {
flyout = (
<EuiFlyout onClose={() => setShowMigrationFlyout(false)} aria-labelledby="flyoutTitle">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
{i18n.translate('xpack.monitoring.alerts.status.flyoutTitle', {
defaultMessage: 'Monitoring alerts',
})}
</h2>
</EuiTitle>
<EuiText>
<p>
{i18n.translate('xpack.monitoring.alerts.status.flyoutSubtitle', {
defaultMessage: 'Configure an email server and email address to receive alerts.',
})}
</p>
</EuiText>
{getSecurityConfigurationErrorUi()}
</EuiFlyoutHeader>
<EuiFlyoutBody>
<AlertsConfiguration
emailAddress={emailAddress}
onDone={() => setShowMigrationFlyout(false)}
/>
</EuiFlyoutBody>
</EuiFlyout>
);
}
const allMigrated = kibanaAlerts.length === NUMBER_OF_MIGRATED_ALERTS;
if (allMigrated) {
if (setupModeEnabled) {
return (
<Fragment>
<EuiCallOut
color="success"
title={i18n.translate('xpack.monitoring.alerts.status.upToDate', {
defaultMessage: 'Kibana alerting is up to date!',
})}
iconType="flag"
>
<p>
<EuiLink onClick={enterSetupModeAndOpenFlyout}>
{i18n.translate('xpack.monitoring.alerts.status.manage', {
defaultMessage: 'Want to make changes? Click here.',
})}
</EuiLink>
</p>
</EuiCallOut>
{flyout}
</Fragment>
);
}
} else {
return (
<Fragment>
<EuiCallOut
color="warning"
title={i18n.translate('xpack.monitoring.alerts.status.needToMigrateTitle', {
defaultMessage: 'Hey! We made alerting better!',
})}
>
<p>
<EuiLink onClick={enterSetupModeAndOpenFlyout}>
{i18n.translate('xpack.monitoring.alerts.status.needToMigrate', {
defaultMessage: 'Migrate cluster alerts to our new alerting platform.',
})}
</EuiLink>
</p>
</EuiCallOut>
{flyout}
</Fragment>
);
}
}
const content = renderContent();
if (content) {
return (
<Fragment>
{content}
<EuiSpacer />
</Fragment>
);
}
return null;
};

View file

@ -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) => (
<TopAlertItem item={item} key={`top-alert-item-${index}`} index={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 = (
<Fragment>
{message}
&nbsp;
<EuiLink href="#license">Please update your license</EuiLink>
</Fragment>
);
}
}
return (
<EuiCallOut key={idx} {...callOutProps}>
<p>{message}</p>
</EuiCallOut>
);
})
: alerts.map((item, index) => (
<TopAlertItem item={item} key={`top-alert-item-${index}`} index={index} />
));
return (
<div data-test-subj="clusterAlertsContainer">
@ -109,7 +159,7 @@ export function AlertsPanel({ alerts, changeUrl }) {
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{topAlertItems}
{alertsList}
<EuiSpacer size="xxl" />
</div>
);

View file

@ -10,15 +10,22 @@ import { KibanaPanel } from './kibana_panel';
import { LogstashPanel } from './logstash_panel';
import { AlertsPanel } from './alerts_panel';
import { BeatsPanel } from './beats_panel';
import { EuiPage, EuiPageBody, EuiScreenReaderOnly } from '@elastic/eui';
import { ApmPanel } from './apm_panel';
import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants';
import { FormattedMessage } from '@kbn/i18n/react';
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 ? (
<AlertsStatus emailAddress={props.emailAddress} />
) : null;
return (
<EuiPage>
<EuiPageBody>
@ -30,6 +37,9 @@ export function Overview(props) {
/>
</h1>
</EuiScreenReaderOnly>
{kibanaAlerts}
<AlertsPanel alerts={props.cluster.alerts} changeUrl={props.changeUrl} />
{!isFromStandaloneCluster ? (

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import 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) => {
// });
// }
// }

View file

@ -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');

View file

@ -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;
}

View file

@ -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);
});
});
});

View file

@ -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';
function isOnPage(hash) {
interface PluginsSetupWithCloud extends PluginsSetup {
cloud: CloudSetup;
}
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) {

View file

@ -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();

View file

@ -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(
<I18nContext>
<SetupModeRenderer
@ -69,6 +85,7 @@ uiRoutes.when('/overview', {
{flyoutComponent}
<Overview
cluster={data}
emailAddress={emailAddress}
setupMode={setupMode}
changeUrl={changeUrl}
showLicenseExpiration={showLicenseExpiration}

View file

@ -0,0 +1,453 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment-timezone';
import { getLicenseExpiration } from './license_expiration';
import {
ALERT_TYPE_LICENSE_EXPIRATION,
MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS,
} from '../../common/constants';
import { Logger } from 'src/core/server';
import { AlertServices } from '../../../alerting/server/types';
import { savedObjectsClientMock } from 'src/core/server/mocks';
import { AlertInstance } from '../../../alerting/server/alert_instance';
import {
AlertState,
AlertClusterState,
AlertParams,
LicenseExpirationAlertExecutorOptions,
} from './types';
import { SavedObject, SavedObjectAttributes } from 'src/core/server';
import { SavedObjectsClientContract } from 'src/core/server';
function fillLicense(license: any, clusterUuid?: string) {
return {
hits: {
hits: [
{
_source: {
license,
cluster_uuid: clusterUuid,
},
},
],
},
};
}
const clusterUuid = 'a4545jhjb';
const params: AlertParams = {
dateFormat: 'YYYY',
timezone: 'UTC',
};
interface MockServices {
callCluster: jest.Mock;
alertInstanceFactory: jest.Mock;
savedObjectsClient: jest.Mock;
}
const alertExecutorOptions: LicenseExpirationAlertExecutorOptions = {
alertId: '',
startedAt: new Date(),
services: {
callCluster: (path: string, opts: any) => new Promise(resolve => resolve()),
alertInstanceFactory: (id: string) => new AlertInstance(),
savedObjectsClient: {} as jest.Mocked<SavedObjectsClientContract>,
},
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<any> => {
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<SavedObjectAttributes> = {
id: '',
type: '',
references: [],
attributes: {
[MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
},
};
resolve(savedObject);
})
);
const services = {
callCluster: jest.fn(
(method: string, { filterPath }): Promise<any> => {
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<SavedObjectAttributes> = {
id: '',
type: '',
references: [],
attributes: {
[MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
},
};
resolve(savedObject);
})
);
const services = {
callCluster: jest.fn(
(method: string, { filterPath }): Promise<any> => {
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<SavedObjectAttributes> = {
id: '',
type: '',
references: [],
attributes: {
[MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
},
};
resolve(savedObject);
})
);
const services = {
callCluster: jest.fn(
(method: string, { filterPath }): Promise<any> => {
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<SavedObjectAttributes> = {
id: '',
type: '',
references: [],
attributes: {
[MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress,
},
};
resolve(savedObject);
})
);
const services = {
callCluster: jest.fn(
(method: string, { filterPath }): Promise<any> => {
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);
});
});

View file

@ -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<any> {
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<any> {
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<string>('dateFormat');
const timezone: string = await uiSettings.get<string>('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<AlertClusterUiState>(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;
},
};
};

View file

@ -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;
}

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { 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);
});
});

View file

@ -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<string[]> {
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;
}

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { 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');
});
});

View file

@ -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<AlertCluster[]> {
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,
}));
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { 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);
});
});

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IUiSettingsClient } from 'src/core/server';
import { MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS } from '../../../common/constants';
export async function fetchDefaultEmailAddress(
uiSettingsClient: IUiSettingsClient
): Promise<string> {
return await uiSettingsClient.get(MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS);
}

View file

@ -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,
},
]);
});
});

View file

@ -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<AlertLicense[]> {
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<any>(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;
});
}

View file

@ -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<any[]> {
// 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<AlertClusterState>(
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);
}

View file

@ -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-*'
);
});
});

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export function getCcsIndexPattern(indexPattern: string, remotes: string[]): string {
return `${indexPattern},${indexPattern
.split(',')
.map(pattern => {
return remotes.map(remoteName => `${remoteName}:${pattern}`).join(',');
})
.join(',')}`;
}

View file

@ -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.`);
});
});
});

View file

@ -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.`,
});
}

View file

@ -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])

View file

@ -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');
}

View file

@ -348,7 +348,6 @@ export const getCollectionStatus = async (
},
};
}
const liveClusterUuid = skipLiveData ? null : await getLiveElasticsearchClusterUuid(req);
const isLiveCluster = !clusterUuid || liveClusterUuid === clusterUuid;

View file

@ -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')
)
);
}
}
}

View file

@ -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 };
},
});
}

View file

@ -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';

View file

@ -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
)
);
},
});
}

View file

@ -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 {

View file

@ -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'),
};
};

View file

@ -9,3 +9,10 @@ export interface ActionType {
name: string;
enabled: boolean;
}
export interface ActionResult {
id: string;
actionTypeId: string;
name: string;
config: Record<string, any>;
}