mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
e28e149b46
commit
6398a9911d
55 changed files with 4223 additions and 130 deletions
|
@ -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'];
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
70
x-pack/legacy/plugins/monitoring/public/components/alerts/__snapshots__/status.test.tsx.snap
generated
Normal file
70
x-pack/legacy/plugins/monitoring/public/components/alerts/__snapshots__/status.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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}
|
||||
|
||||
<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>
|
||||
);
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
36
x-pack/legacy/plugins/monitoring/public/jest.helpers.ts
Normal file
36
x-pack/legacy/plugins/monitoring/public/jest.helpers.ts
Normal 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) => {
|
||||
|
||||
// });
|
||||
// }
|
||||
// }
|
|
@ -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');
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
};
|
45
x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts
vendored
Normal file
45
x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}));
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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-*'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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(',')}`;
|
||||
}
|
|
@ -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.`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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.`,
|
||||
});
|
||||
}
|
|
@ -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])
|
||||
|
|
|
@ -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');
|
||||
}
|
|
@ -348,7 +348,6 @@ export const getCollectionStatus = async (
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
const liveClusterUuid = skipLiveData ? null : await getLiveElasticsearchClusterUuid(req);
|
||||
const isLiveCluster = !clusterUuid || liveClusterUuid === clusterUuid;
|
||||
|
||||
|
|
|
@ -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')
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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'),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -9,3 +9,10 @@ export interface ActionType {
|
|||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
id: string;
|
||||
actionTypeId: string;
|
||||
name: string;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue