diff --git a/x-pack/legacy/plugins/monitoring/common/constants.js b/x-pack/legacy/plugins/monitoring/common/constants.ts similarity index 81% rename from x-pack/legacy/plugins/monitoring/common/constants.js rename to x-pack/legacy/plugins/monitoring/common/constants.ts index e40941396b661..1fa1c1f4ceb2e 100644 --- a/x-pack/legacy/plugins/monitoring/common/constants.js +++ b/x-pack/legacy/plugins/monitoring/common/constants.ts @@ -112,7 +112,7 @@ export const CLOUD_METADATA_SERVICES = { // GCP documentation shows both 'metadata.google.internal' (mostly) and '169.254.169.254' (sometimes) // To bypass potential DNS changes, the IP was used because it's shared with other cloud services - GCP_URL_PREFIX: 'http://169.254.169.254/computeMetadata/v1/instance' + GCP_URL_PREFIX: 'http://169.254.169.254/computeMetadata/v1/instance', }; /** @@ -122,13 +122,13 @@ export const LOGSTASH = { MAJOR_VER_REQD_FOR_PIPELINES: 6, /* - * Names ES keys on for different Logstash pipeline queues. - * @type {string} - */ + * Names ES keys on for different Logstash pipeline queues. + * @type {string} + */ QUEUE_TYPES: { MEMORY: 'memory', - PERSISTED: 'persisted' - } + PERSISTED: 'persisted', + }, }; export const DEBOUNCE_SLOW_MS = 17; // roughly how long it takes to render a frame at 60fps @@ -162,12 +162,12 @@ export const ELASTICSEARCH_SYSTEM_ID = 'elasticsearch'; export const INFRA_SOURCE_ID = 'internal-stack-monitoring'; /* -* These constants represent code paths within `getClustersFromRequest` -* that an api call wants to invoke. This is meant as an optimization to -* avoid unnecessary ES queries (looking at you logstash) when the data -* is not used. In the long term, it'd be nice to have separate api calls -* instead of this path logic. -*/ + * These constants represent code paths within `getClustersFromRequest` + * that an api call wants to invoke. This is meant as an optimization to + * avoid unnecessary ES queries (looking at you logstash) when the data + * is not used. In the long term, it'd be nice to have separate api calls + * instead of this path logic. + */ export const CODE_PATH_ALL = 'all'; export const CODE_PATH_ALERTS = 'alerts'; export const CODE_PATH_KIBANA = 'kibana'; @@ -222,3 +222,39 @@ 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 = true; + +/** + * 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`; + +/** + * 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; + +/** + * We store config data in a single saved object of this id + */ +export const MONITORING_CONFIG_SAVED_OBJECT_ID = 'monitoring'; +export const MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS = 'alertingEmailAddress'; diff --git a/x-pack/legacy/plugins/monitoring/index.js b/x-pack/legacy/plugins/monitoring/index.js index 97046bfb7d5b4..98952622de0d7 100644 --- a/x-pack/legacy/plugins/monitoring/index.js +++ b/x-pack/legacy/plugins/monitoring/index.js @@ -17,7 +17,7 @@ import { initInfraSource } from './server/lib/logs/init_infra_source'; * @return {Object} Monitoring UI Kibana plugin object */ export const monitoring = (kibana) => new kibana.Plugin({ - require: ['kibana', 'elasticsearch', 'xpack_main'], + require: ['kibana', 'elasticsearch', 'xpack_main', 'alerting'], id: 'monitoring', configPrefix: 'xpack.monitoring', publicDir: resolve(__dirname, 'public'), @@ -48,6 +48,7 @@ export const monitoring = (kibana) => new kibana.Plugin({ const serverConfig = server.config(); const serverFacade = { + newPlatform: server.newPlatform, config: () => ({ get: key => { if (configs.includes(key)) { @@ -74,7 +75,7 @@ export const monitoring = (kibana) => new kibana.Plugin({ const plugins = { xpack_main: server.plugins.xpack_main, elasticsearch: server.plugins.elasticsearch, - infra: server.plugins.infra, + alerting: server.plugins.alerting, }; new Plugin().setup(serverFacade, plugins); diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx new file mode 100644 index 0000000000000..e3519f17dc3ba --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx @@ -0,0 +1,238 @@ +/* + * 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, + EuiLink, + EuiSpacer, + EuiFieldNumber, + EuiFieldPassword, + EuiSwitch, + EuiButton, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionResult } from '../../../../actions/server/types'; +import { getMissingFieldErrors, hasErrors } from '../../lib/form_validation'; + +export interface EmailActionData { + service: string; + host: string; + port: number | string; // support a string to ensure the user can backspace to an empty field + secure: boolean; + from: string; + user: string; + password: string; +} + +interface ManageActionModalProps { + createEmailAction: (handler: EmailActionData) => void; + deleteEmailAction: () => 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 DELETE_LABEL = i18n.translate('xpack.monitoring.alerts.migrate.manageAction.deleteLabel', { + defaultMessage: 'Delete', +}); +const CANCEL_LABEL = i18n.translate('xpack.monitoring.alerts.migrate.manageAction.cancelLabel', { + defaultMessage: 'Cancel', +}); + +export const ManageEmailAction: React.FC = ( + props: ManageActionModalProps +) => { + const { createEmailAction, deleteEmailAction, cancel, isNew, action } = props; + + const defaultData = Object.assign({}, DEFAULT_DATA, action ? action.config : {}); + const [isSaving, setIsSaving] = React.useState(false); + const [showErrors, setShowErrors] = React.useState(false); + const [errors, setErrors] = React.useState( + getMissingFieldErrors(defaultData, DEFAULT_DATA) + ); + const [data, setData] = React.useState(defaultData); + + React.useEffect(() => { + setErrors(getMissingFieldErrors(data, DEFAULT_DATA)); + }, [data]); + + function saveEmailAction() { + setShowErrors(true); + if (!hasErrors(errors)) { + setShowErrors(false); + setIsSaving(true); + createEmailAction(data); + } + } + + return ( + + + {i18n.translate('xpack.monitoring.alerts.migrate.manageAction.serviceHelpText', { + defaultMessage: 'Find out more', + })} + + } + error={errors.service} + isInvalid={showErrors && !!errors.service} + > + setData({ ...data, service: e.target.value })} + isInvalid={showErrors} + /> + + + + setData({ ...data, host: e.target.value })} + isInvalid={showErrors} + /> + + + + setData({ ...data, port: parseInt(e.target.value, 10) || '' })} + isInvalid={showErrors} + /> + + + + setData({ ...data, secure: e.target.checked })} + /> + + + + setData({ ...data, from: e.target.value })} + isInvalid={showErrors} + /> + + + + setData({ ...data, user: e.target.value })} + isInvalid={showErrors} + /> + + + + setData({ ...data, password: e.target.value })} + isInvalid={showErrors} + /> + + + + + + + + {isNew ? CREATE_LABEL : SAVE_LABEL} + + + {!action || isNew ? null : ( + + {CANCEL_LABEL} + + )} + {isNew ? null : ( + + + {DELETE_LABEL} + + + )} + + + ); +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/migration.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/migration.tsx new file mode 100644 index 0000000000000..2caefa7907c81 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/migration.tsx @@ -0,0 +1,312 @@ +/* + * 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 { omit, pick } from 'lodash'; +import { kfetch } from 'ui/kfetch'; +import { + EuiSteps, + EuiSpacer, + EuiText, + EuiSuperSelect, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionResult } from '../../../../actions/server/types'; +import { ALERT_ACTION_TYPE_EMAIL } from '../../../common/constants'; +import { toggleSetupMode } from '../../lib/setup_mode'; +import { EmailActionData, ManageEmailAction } from './manage_email_action'; +import { getMissingFieldErrors } from '../../lib/form_validation'; + +interface MigrationProps { + clusterUuid: string; + emailAddress: string; + onDone: Function; +} + +interface MigrationForm { + email: string | null; +} + +const NEW_ACTION_ID = '__new__'; + +export const Migration: React.FC = (props: MigrationProps) => { + const { clusterUuid, onDone } = props; + + const [emailActions, setEmailActions] = React.useState([]); + const [editAction, setEditAction] = React.useState(null); + const [selectedEmailActionId, setSelectedEmailActionId] = React.useState(''); + const [emailAddress, setEmailAddress] = React.useState(props.emailAddress); + const [formErrors, setFormErrors] = React.useState({ email: null }); + const [showFormErrors, setShowFormErrors] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + + React.useEffect(() => { + async function fetchMigrationStatus() { + await fetchEmailActions(); + await toggleSetupMode(true); + } + + fetchMigrationStatus(); + }, [clusterUuid]); + + 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); + } + setEmailActions(actions); + } + + async function createEmailAction(data: EmailActionData) { + if (editAction) { + await kfetch({ + method: 'PUT', + pathname: `/api/action/${editAction.id}`, + body: JSON.stringify({ + description: editAction.description, + config: omit(data, ['user', 'password']), + secrets: pick(data, ['user', 'password']), + }), + }); + } else { + await kfetch({ + method: 'POST', + pathname: '/api/action', + body: JSON.stringify({ + description: i18n.translate('xpack.monitoring.alerts.actions.emailAction.description', { + defaultMessage: 'Kibana alerting is up to date!', + }), + actionTypeId: ALERT_ACTION_TYPE_EMAIL, + config: omit(data, ['user', 'password']), + secrets: pick(data, ['user', 'password']), + }), + }); + } + + await fetchEmailActions(); + } + + async function deleteEmailAction() { + if (!editAction) { + return null; + } + + await kfetch({ + method: 'DELETE', + pathname: `/api/action/${editAction.id}`, + }); + + setEditAction(null); + if (selectedEmailActionId === editAction.id) { + setSelectedEmailActionId(''); + } + await fetchEmailActions(); + } + + async function save() { + if (emailAddress.length === 0) { + setShowFormErrors(true); + return; + } + setIsSaving(true); + setShowFormErrors(false); + + await kfetch({ + method: 'POST', + pathname: `/api/monitoring/v1/clusters/${clusterUuid}/alerts`, + body: JSON.stringify({ selectedEmailActionId, emailAddress }), + }); + + onDone(); + } + + function getStep1() { + const title = i18n.translate('xpack.monitoring.alerts.migration.selectEmailAction', { + defaultMessage: 'Select email action', + }); + + if (editAction) { + return { + title, + children: ( + + +

+ {i18n.translate('xpack.monitoring.alerts.migration.step2.noActions', { + defaultMessage: 'Edit the action below.', + })} +

+
+ + await createEmailAction(data)} + deleteEmailAction={deleteEmailAction} + cancel={() => setEditAction(null)} + isNew={false} + action={editAction} + /> +
+ ), + status: 'incomplete', + }; + } + + const options = [ + ...emailActions.map(action => { + const actionLabel = i18n.translate( + 'xpack.monitoring.alerts.migrate.selectAction.inputDisplay', + { + defaultMessage: 'From: {from}, Service: {service}', + values: { + service: action.config.service, + from: action.config.from, + }, + } + ); + + return { + value: action.id, + inputDisplay: {actionLabel}, + dropdownDisplay: ( + + + {actionLabel} + + + setEditAction(action)} /> + + + ), + }; + }), + { + value: NEW_ACTION_ID, + inputDisplay: ( + + {i18n.translate('xpack.monitoring.alerts.migration.newActionInputDisplay', { + defaultMessage: 'Creating new action...', + })} + + ), + dropdownDisplay: ( + + {i18n.translate('xpack.monitoring.alerts.migration.newActionDropdownDisplay', { + defaultMessage: 'Create new email action...', + })} + + ), + }, + ]; + + let createNew = null; + if (selectedEmailActionId === NEW_ACTION_ID) { + createNew = ( + + + + await createEmailAction(data)} + deleteEmailAction={deleteEmailAction} + isNew={true} + /> + + + ); + } + + return { + title, + children: ( +
+ setSelectedEmailActionId(id)} + hasDividers + /> + {createNew} +
+ ), + status: selectedEmailActionId ? 'complete' : 'incomplete', + }; + } + + function getStep2() { + const isDisabled = !!editAction || !selectedEmailActionId; + return { + title: i18n.translate('xpack.monitoring.alerts.migration.setEmailAddress', { + defaultMessage: 'Set the email to receive alerts', + }), + status: isDisabled ? 'disabled' : 'incomplete', + children: ( + + + + setEmailAddress(e.target.value)} + /> + + + + ), + }; + } + + function getStep3() { + const isDisabled = !!editAction || !selectedEmailActionId; + return { + title: i18n.translate('xpack.monitoring.alerts.migration.setEmailAddress', { + defaultMessage: 'Confirm and save', + }), + status: isDisabled ? 'disabled' : 'incomplete', + children: ( + + + {i18n.translate('xpack.monitoring.alerts.migration.save', { + defaultMessage: 'Save', + })} + + + ), + }; + } + + const steps = [getStep1(), getStep2(), getStep3()]; + + return ( +
+ +
+ ); +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/migration_status.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/migration_status.tsx new file mode 100644 index 0000000000000..82f133f43c329 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/migration_status.tsx @@ -0,0 +1,151 @@ +/* + * 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, + EuiButtonEmpty, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Alert } from '../../../../alerting/server/types'; +import { getSetupModeState, addSetupModeCallback } from '../../lib/setup_mode'; +import { + NUMBER_OF_MIGRATED_ALERTS, + KIBANA_ALERTING_ENABLED, + ALERT_TYPE_PREFIX, +} from '../../../common/constants'; +import { Migration } from './migration'; + +interface MigrationStatusProps { + clusterUuid: string; + emailAddress: string; +} + +export const MigrationStatus: React.FC = (props: MigrationStatusProps) => { + const { clusterUuid, emailAddress } = props; + + const [setupModeEnabled, setSetupModeEnabled] = React.useState(getSetupModeState().enabled); + const [kibanaAlerts, setKibanaAlerts] = React.useState([]); + + const [showMigrationFlyout, setShowMigrationFlyout] = React.useState(false); + + React.useEffect(() => { + async function fetchMigrationStatus() { + 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); + } + + fetchMigrationStatus(); + }, [clusterUuid, setupModeEnabled, showMigrationFlyout]); + + addSetupModeCallback(() => setSetupModeEnabled(getSetupModeState().enabled)); + + function renderContent() { + let flyout = null; + if (showMigrationFlyout) { + flyout = ( + setShowMigrationFlyout(false)} aria-labelledby="flyoutTitle"> + + +

+ {i18n.translate('xpack.monitoring.alerts.migration.flyoutTitle', { + defaultMessage: 'Alerting migration', + })} +

+
+ +

+ {i18n.translate('xpack.monitoring.alerts.migration.flyoutSubtitle', { + defaultMessage: 'Configure an email server and email address to receive alerts.', + })} +

+
+
+ + setShowMigrationFlyout(false)} + /> + +
+ ); + } + + const allMigrated = kibanaAlerts.length === NUMBER_OF_MIGRATED_ALERTS; + if (allMigrated) { + if (setupModeEnabled) { + return ( + + +

+ setShowMigrationFlyout(true)}> + {i18n.translate('xpack.monitoring.alerts.migrate.manage', { + defaultMessage: 'Manage email action and/or receiving email address', + })} + +

+
+ {flyout} +
+ ); + } + } else { + return ( + + +

+ setShowMigrationFlyout(true)}> + {i18n.translate('xpack.monitoring.alerts.migrate.needToMigrate', { + defaultMessage: + 'Click here to migrate cluster alerts to our new alerting platform.', + })} + +

+
+ {flyout} +
+ ); + } + } + + if (!KIBANA_ALERTING_ENABLED) { + return null; + } + + const content = renderContent(); + if (content) { + return ( + + {content} + + + ); + } + + return null; +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js index 895c61f19785a..2733ee0c31fa9 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js @@ -10,7 +10,7 @@ import { KibanaPanel } from './kibana_panel'; import { LogstashPanel } from './logstash_panel'; import { AlertsPanel } from './alerts_panel'; import { BeatsPanel } from './beats_panel'; - +import { MigrationStatus } from '../../alerts/migration_status'; import { EuiPage, EuiPageBody } from '@elastic/eui'; import { ApmPanel } from './apm_panel'; import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants'; @@ -21,6 +21,11 @@ export function Overview(props) { return ( + + { !isFromStandaloneCluster ? diff --git a/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.js b/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.tsx similarity index 77% rename from x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.js rename to x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.tsx index 004910960d28e..b9bc12d6caca5 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.tsx @@ -7,23 +7,18 @@ 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 { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -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) { return ( -

- { err.data.message } -

+

{err.data.message}

{ + return (err: any) => { if (err.status === 403) { // redirect to error message view kbnUrl.redirect('access-denied'); - } else if (err.status === 404 && !contains(window.location.hash, 'no-data')) { // pass through if this is a 404 and we're already on the no-data page + } else if (err.status === 404 && !contains(window.location.hash, 'no-data')) { + // pass through if this is a 404 and we're already on the no-data page toastNotifications.addDanger({ title: ( ), + /> + ), text: (
- { formatMonitoringError(err) } + {formatMonitoringError(err)} - window.location.reload()} - > + window.location.reload()}>
- ) + ), }); } else { toastNotifications.addDanger({ @@ -75,8 +68,9 @@ export function ajaxErrorHandlersProvider($injector) { ), - text: formatMonitoringError(err) + /> + ), + text: formatMonitoringError(err), }); } diff --git a/x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts b/x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts new file mode 100644 index 0000000000000..98d56f9790be4 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { isString, isNumber, capitalize } from 'lodash'; + +export function getRequiredFieldError(field: string): string { + return i18n.translate('xpack.monitoring.alerts.migrate.manageAction.requiredFieldError', { + defaultMessage: '{field} is a required field.', + values: { + field: capitalize(field), + }, + }); +} + +export function getMissingFieldErrors(data: any, defaultData: any) { + const errors: any = {}; + + for (const key in data) { + if (!data.hasOwnProperty(key)) { + continue; + } + + if (isString(defaultData[key])) { + if (!data[key] || data[key].length === 0) { + errors[key] = getRequiredFieldError(key); + } + } else if (isNumber(defaultData[key])) { + if (isNaN(data[key]) || data[key] === 0) { + errors[key] = getRequiredFieldError(key); + } + } + } + + return errors; +} + +export function hasErrors(errors: any) { + for (const error in errors) { + if (error.length) { + return true; + } + } + return false; +} diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js index b5878c7ec5181..5992b31088967 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js @@ -70,7 +70,7 @@ describe('setup_mode', () => { catch (err) { error = err; } - expect(error).toEqual('Unable to interact with setup ' + 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`.'); }); diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.ts similarity index 69% rename from x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js rename to x-pack/legacy/plugins/monitoring/public/lib/setup_mode.ts index 3e7d182f1514c..9d593602d8798 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.ts @@ -4,35 +4,47 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ajaxErrorHandlersProvider } from './ajax_error_handler'; import { get, contains } from 'lodash'; import chrome from 'ui/chrome'; +import { ajaxErrorHandlersProvider } from './ajax_error_handler'; -function isOnPage(hash) { +function isOnPage(hash: string) { return contains(window.location.hash, hash); } -const angularState = { +interface IAngularState { + injector: any; + scope: any; +} + +const angularState: IAngularState = { injector: null, scope: null, }; const checkAngularState = () => { if (!angularState.injector || !angularState.scope) { - throw 'Unable to interact with setup mode because the angular injector was not previously set.' - + ' This needs to be set by calling `initSetupModeState`.'; + throw new Error( + 'Unable to interact with setup mode because the angular injector was not previously set.' + + ' This needs to be set by calling `initSetupModeState`.' + ); } }; -const setupModeState = { +interface ISetupModeState { + enabled: boolean; + data: any; + callbacks: Function[]; +} +const setupModeState: ISetupModeState = { enabled: false, data: null, - callbacks: [] + callbacks: [], }; 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(() => { @@ -42,7 +54,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'); @@ -53,30 +65,27 @@ export const fetchCollectionData = async (uuid, fetchWithoutClusterUuid = false) let url = '../api/monitoring/v1/setup/collection'; if (uuid) { url += `/node/${uuid}`; - } - else if (!fetchWithoutClusterUuid && clusterUuid) { + } else if (!fetchWithoutClusterUuid && clusterUuid) { url += `/cluster/${clusterUuid}`; - } - else { + } else { url += '/cluster'; } try { const response = await http.post(url, { ccs }); return response.data; - } - catch (err) { + } catch (err) { const Private = angularState.injector.get('Private'); const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); return ajaxErrorHandlers(err); } }; -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; @@ -88,9 +97,10 @@ 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 migratedEsNodes = Object.values(get(data, 'elasticsearch.byUuid', {})) - .filter(node => node.isPartiallyMigrated || node.isFullyMigrated); + const liveClusterUuid: string = get(data, '_meta.liveClusterUuid'); + const migratedEsNodes = Object.values(get(data, 'elasticsearch.byUuid', {})).filter( + (node: any) => node.isPartiallyMigrated || node.isFullyMigrated + ); if (liveClusterUuid && migratedEsNodes.length > 0) { setNewlyDiscoveredClusterUuid(liveClusterUuid); } @@ -107,15 +117,14 @@ export const disableElasticsearchInternalCollection = async () => { try { const response = await http.post(url); return response.data; - } - catch (err) { + } catch (err) { const Private = angularState.injector.get('Private'); const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); return ajaxErrorHandlers(err); } }; -export const toggleSetupMode = inSetupMode => { +export const toggleSetupMode = (inSetupMode: boolean) => { checkAngularState(); const globalState = angularState.injector.get('globalState'); @@ -141,13 +150,15 @@ export const setSetupModeMenuItem = () => { const globalState = angularState.injector.get('globalState'); const navItems = globalState.inSetupMode ? [] - : [{ - id: 'enter', - label: 'Enter Setup Mode', - description: 'Enter setup', - run: () => toggleSetupMode(true), - testId: 'enterSetupMode' - }]; + : [ + { + id: 'enter', + label: 'Enter Setup Mode', + description: 'Enter setup', + run: () => toggleSetupMode(true), + testId: 'enterSetupMode', + }, + ]; angularState.scope.topNavMenu = [...navItems]; // LOL angular @@ -156,10 +167,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) { @@ -172,7 +187,7 @@ export const isInSetupMode = async () => { return true; } - const $injector = angularState.injector || await chrome.dangerouslyGetActiveInjector(); + const $injector = angularState.injector || (await chrome.dangerouslyGetActiveInjector()); const globalState = $injector.get('globalState'); return globalState.inSetupMode; }; diff --git a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js index 8ffb30cee9623..c13940b391f9f 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js @@ -24,7 +24,7 @@ function getPageData($injector) { const globalState = $injector.get('globalState'); const $http = $injector.get('$http'); const Private = $injector.get('Private'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/alerts`; + const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/legacy_alerts`; const timeBounds = timefilter.getBounds(); diff --git a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js index 60cb8349070e4..941c6f0a08499 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; +import { isEmpty } from 'lodash'; +import chrome from 'ui/chrome'; import { i18n } from '@kbn/i18n'; import uiRoutes from 'ui/routes'; import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; @@ -12,7 +14,11 @@ import { MonitoringViewBaseController } from '../../'; import { Overview } from 'plugins/monitoring/components/cluster/overview'; import { I18nContext } from 'ui/i18n'; import { SetupModeRenderer } from '../../../components/renderers'; -import { CODE_PATH_ALL } from '../../../../common/constants'; +import { + CODE_PATH_ALL, + MONITORING_CONFIG_SAVED_OBJECT_ID, + MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS +} 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 savedObjectsClient = chrome.getSavedObjectsClient(); super({ title: i18n.translate('xpack.monitoring.cluster.overviewTitle', { @@ -52,7 +59,15 @@ uiRoutes.when('/overview', { }); }; - $scope.$watch(() => this.data, data => { + $scope.$watch(() => this.data, async data => { + if (isEmpty(data)) { + return; + } + + const monitoringConfig = await savedObjectsClient.get('config', MONITORING_CONFIG_SAVED_OBJECT_ID); + const emailAddress = monitoringConfig.get(MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS) + || chrome.getInjected('monitoringLegacyEmailAddress'); + this.renderReact( { + 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(), + }; + const getLogger = (): Logger => logger; + + afterEach(() => { + (logger.warn as jest.Mock).mockClear(); + }); + + it('should have the right id and actionGroups', () => { + const alert = getLicenseExpiration(getMonitoringCluster, getLogger); + expect(alert.id).toBe(ALERT_TYPE_LICENSE_EXPIRATION); + expect(alert.actionGroups).toEqual(['default']); + }); + + interface MockServices { + callCluster: jest.Mock; + alertInstanceFactory: jest.Mock; + savedObjectsClient: jest.Mock; + } + it('should return the state if no license is provided', async () => { + const alert = getLicenseExpiration(getMonitoringCluster, getLogger); + + const services: MockServices | AlertServices = { + callCluster: jest.fn(), + alertInstanceFactory: jest.fn(), + savedObjectsClient: SavedObjectsClientMock.create(), + }; + const params = { + clusterUuid: '1abd45', + }; + const state = { foo: 1 }; + + const result = await alert.executor({ + alertId: '', + startedAt: new Date(), + services, + params, + state, + }); + + expect(result).toEqual(state); + }); + + it('should log a warning if no email is provided', async () => { + const alert = getLicenseExpiration(getMonitoringCluster, getLogger); + + const services = { + callCluster: jest.fn( + (method: string, params): Promise => { + return new Promise(resolve => { + if (params.filterPath === '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 params = { + clusterUuid: '1abd45', + }; + const state = {}; + + await alert.executor({ + alertId: '', + startedAt: new Date(), + 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.` + + ` Please configure 'xpack.monitoring.cluster_alerts.email_notifications.email_address'.` + ); + }); + + 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 emailAddress = 'foo@foo.com'; + const alert = getLicenseExpiration(getMonitoringCluster, getLogger); + + const savedObjectsClient = SavedObjectsClientMock.create(); + savedObjectsClient.get.mockReturnValue( + new Promise(resolve => { + const savedObject: T = { + attributes: { + [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress, + }, + }; + resolve(savedObject); + }) + ); + const services = { + callCluster: jest.fn( + (method: string, params): Promise => { + return new Promise(resolve => { + if (params.filterPath === 'hits.hits._source.license.*') { + resolve( + fillLicense({ + status: 'active', + type: 'gold', + expiry_date_in_millis: moment() + .add(7, 'days') + .valueOf(), + }) + ); + } + resolve({}); + }); + } + ), + alertInstanceFactory, + savedObjectsClient, + }; + + const params = { + clusterUuid: '1abd45', + }; + const state = {}; + + const result = await alert.executor({ + alertId: '', + startedAt: new Date(), + services, + params, + state, + }); + + expect((result as AlertState).expired_check_date_in_millis > 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 emailAddress = 'foo@foo.com'; + const alert = getLicenseExpiration(getMonitoringCluster, getLogger); + + const savedObjectsClient = SavedObjectsClientMock.create(); + savedObjectsClient.get.mockReturnValue( + new Promise(resolve => { + const savedObject: T = { + attributes: { + [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress, + }, + }; + resolve(savedObject); + }) + ); + const services = { + callCluster: jest.fn( + (method: string, params): Promise => { + return new Promise(resolve => { + if (params.filterPath === 'hits.hits._source.license.*') { + resolve( + fillLicense({ + status: 'active', + type: 'gold', + expiry_date_in_millis: moment() + .add(120, 'days') + .valueOf(), + }) + ); + } + resolve({}); + }); + } + ), + alertInstanceFactory, + savedObjectsClient, + }; + + const params = { + clusterUuid: '1abd45', + }; + const state = { + expired_check_date_in_millis: moment() + .subtract(1, 'day') + .valueOf(), + }; + + const result = await alert.executor({ + alertId: '', + startedAt: new Date(), + services, + params, + state, + }); + + expect((result as AlertState).expired_check_date_in_millis).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 emailAddress = 'foo@foo.com'; + const alert = getLicenseExpiration(getMonitoringCluster, getLogger); + + const savedObjectsClient = SavedObjectsClientMock.create(); + savedObjectsClient.get.mockReturnValue( + new Promise(resolve => { + const savedObject: T = { + attributes: { + [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress, + }, + }; + resolve(savedObject); + }) + ); + const services = { + callCluster: jest.fn( + (method: string, params): Promise => { + return new Promise(resolve => { + if (params.filterPath === 'hits.hits._source.license.*') { + resolve( + fillLicense({ + status: 'active', + type: 'trial', + expiry_date_in_millis: moment() + .add(15, 'days') + .valueOf(), + }) + ); + } + resolve({}); + }); + } + ), + alertInstanceFactory, + savedObjectsClient, + }; + + const params = { + clusterUuid: '1abd45', + }; + const state = {}; + + const result = await alert.executor({ + alertId: '', + startedAt: new Date(), + services, + params, + state, + }); + expect((result as AlertState).expired_check_date_in_millis).toBe(0); + 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 emailAddress = 'foo@foo.com'; + const alert = getLicenseExpiration(getMonitoringCluster, getLogger); + + const savedObjectsClient = SavedObjectsClientMock.create(); + savedObjectsClient.get.mockReturnValue( + new Promise(resolve => { + const savedObject: T = { + attributes: { + [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress, + }, + }; + resolve(savedObject); + }) + ); + const services = { + callCluster: jest.fn( + (method: string, params): Promise => { + return new Promise(resolve => { + if (params.filterPath === 'hits.hits._source.license.*') { + resolve( + fillLicense({ + status: 'active', + type: 'trial', + expiry_date_in_millis: moment() + .add(13, 'days') + .valueOf(), + }) + ); + } + resolve({}); + }); + } + ), + alertInstanceFactory, + savedObjectsClient, + }; + + const params = { + clusterUuid: '1abd45', + }; + const state = {}; + + const result = await alert.executor({ + alertId: '', + startedAt: new Date(), + services, + params, + state, + }); + expect((result as AlertState).expired_check_date_in_millis > 0).toBe(true); + expect(scheduleActions.mock.calls.length).toBe(1); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts b/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts new file mode 100644 index 0000000000000..c34285edb2be7 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { Logger } from 'src/core/server'; +import { ALERT_TYPE_LICENSE_EXPIRATION } from '../../common/constants'; +import { AlertType, AlertExecutorOptions } from '../../../alerting'; +import { fetchLicense } from '../lib/alerts/fetch_license'; +import { fetchDefaultEmailAddress } from '../lib/alerts/fetch_default_email_address'; +import { License, AlertState } from './types'; + +const EXPIRES_DAYS = [60, 30, 14, 7]; + +export const getLicenseExpiration = ( + getMonitoringCluster: any, + getLogger: (contexts: string[]) => Logger +): AlertType => { + async function getCallCluster(services: any): Promise { + const monitoringCluster = await getMonitoringCluster(); + if (!monitoringCluster) { + return services.callCluster; + } + + return monitoringCluster.callCluster; + } + + const logger = getLogger([ALERT_TYPE_LICENSE_EXPIRATION]); + return { + id: ALERT_TYPE_LICENSE_EXPIRATION, + name: 'Monitoring Alert - License Expiration', + actionGroups: ['default'], + async executor({ services, params, state }: AlertExecutorOptions): Promise { + logger.debug( + `Firing alert with params: ${JSON.stringify(params)} and state: ${JSON.stringify(state)}` + ); + + const { clusterUuid } = params; + const callCluster = await getCallCluster(services); + + // Fetch licensing information from cluster_stats documents + const license: License = await fetchLicense(callCluster, clusterUuid); + if (!license) { + logger.warn(`No license found for ${ALERT_TYPE_LICENSE_EXPIRATION}.`); + return state; + } + + const emailAddress = await fetchDefaultEmailAddress(services.savedObjectsClient); + 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.` + + ` Please configure 'xpack.monitoring.cluster_alerts.email_notifications.email_address'.` + ); + return; + } + + const $expiry = moment.utc(license.expiry_date_in_millis); + let isExpired = false; + + if (license.status !== 'active') { + isExpired = true; + } else if (license.expiry_date_in_millis) { + for (let i = EXPIRES_DAYS.length - 1; i >= 0; i--) { + if (license.type === 'trial' && i < 2) { + break; + } + + const $fromNow = moment().add(EXPIRES_DAYS[i], 'days'); + if ($fromNow.isAfter($expiry)) { + isExpired = true; + } + } + } + + const result: AlertState = { + expired_check_date_in_millis: 0, + ...state, + }; + const instance = services.alertInstanceFactory(ALERT_TYPE_LICENSE_EXPIRATION); + + if (isExpired && !state.expired_check_date_in_millis) { + logger.debug(`License will expire soon, sending email`); + instance.scheduleActions('default', { + subject: 'NEW X-Pack Monitoring: License Expiration', + message: `This cluster's license is going to expire on ${$expiry.format()}. Please update your license.`, + to: emailAddress, + }); + result.expired_check_date_in_millis = moment().valueOf(); + } else if (!isExpired && state.expired_check_date_in_millis) { + logger.debug(`License expiration has been resolved, sending email`); + instance.scheduleActions('default', { + subject: 'RESOLVED X-Pack Monitoring: License Expiration', + message: `This cluster alert has been resolved: This cluster's license was going to expire on ${$expiry.format()}.`, + to: emailAddress, + }); + result.expired_check_date_in_millis = 0; + } + + return result; + }, + }; +}; diff --git a/x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts b/x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts new file mode 100644 index 0000000000000..8f45ba52fe2c6 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts @@ -0,0 +1,16 @@ +/* + * 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'; + +export interface License { + status: string; + type: string; + expiry_date_in_millis: number; +} + +export interface AlertState { + expired_check_date_in_millis: number | Moment; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.ts new file mode 100644 index 0000000000000..035f971a31a3c --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { get } from 'lodash'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { + MONITORING_CONFIG_SAVED_OBJECT_ID, + MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS, +} from '../../../common/constants'; + +export async function fetchDefaultEmailAddress( + savedObjectsClient: SavedObjectsClientContract +): Promise { + const monitoringConfig = await savedObjectsClient.get( + 'config', + MONITORING_CONFIG_SAVED_OBJECT_ID + ); + const emailAddress = get( + monitoringConfig, + `attributes.${MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS}` + ) as string; + return emailAddress; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_license.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_license.ts new file mode 100644 index 0000000000000..1c0ffe7d6a63c --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_license.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { get } from 'lodash'; +import { License } from '../../alerts/types'; +import { INDEX_PATTERN_ELASTICSEARCH } from '../../../common/constants'; + +export async function fetchLicense(callCluster: any, clusterUuid: string): Promise { + const params = { + index: INDEX_PATTERN_ELASTICSEARCH, + filterPath: 'hits.hits._source.license.*', + body: { + size: 1, + sort: [{ timestamp: { order: 'desc' } }], + query: { + bool: { + filter: [ + { + term: { + cluster_uuid: clusterUuid, + }, + }, + { + term: { + type: 'cluster_stats', + }, + }, + { + range: { + timestamp: { + gte: 'now-2m', + }, + }, + }, + ], + }, + }, + }, + }; + + const response = await callCluster('search', params); + return get(response, 'hits.hits[0]._source.license'); +} diff --git a/x-pack/legacy/plugins/monitoring/server/plugin.js b/x-pack/legacy/plugins/monitoring/server/plugin.js index ab7813aa26566..aa741c801d76f 100644 --- a/x-pack/legacy/plugins/monitoring/server/plugin.js +++ b/x-pack/legacy/plugins/monitoring/server/plugin.js @@ -5,7 +5,7 @@ */ 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'; @@ -18,6 +18,7 @@ import { getOpsStatsCollector, getSettingsCollector, } from './kibana_monitoring/collectors'; +import { getLicenseExpiration } from './alerts/license_expiration'; export class Plugin { setup(core, plugins) { @@ -135,5 +136,31 @@ export class Plugin { showCgroupMetricsLogstash: config.get('xpack.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) { + // 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.newPlatform.coreContext.logger.get('plugins', LOGGING_TAG, ...contexts); + } + plugins.alerting.setup.registerType(getLicenseExpiration(getMonitoringCluster, getLogger)); + } } } diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js new file mode 100644 index 0000000000000..e8be35a2a4710 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import { isFunction } from 'lodash'; +import { + ALERT_TYPE_LICENSE_EXPIRATION, + MONITORING_CONFIG_SAVED_OBJECT_ID, + MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS +} from '../../../../../common/constants'; + +async function createAlerts(alertsClient, { selectedEmailActionId, clusterUuid }) { + const createdAlerts = []; + + // Create alerts + const ALERT_TYPES = { + [ALERT_TYPE_LICENSE_EXPIRATION]: { + interval: '1m', + alertTypeParams: { + clusterUuid, + }, + 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, savedObjectsClient) { + try { + await savedObjectsClient.get('config', MONITORING_CONFIG_SAVED_OBJECT_ID); + } + catch (err) { + await savedObjectsClient.create( + 'config', + { [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress }, + { id: MONITORING_CONFIG_SAVED_OBJECT_ID }, + ); + return; + } + + await savedObjectsClient.update( + 'config', + MONITORING_CONFIG_SAVED_OBJECT_ID, + { [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress } + ); +} + + +export function createKibanaAlertsRoute(server) { + server.route({ + method: 'POST', + path: '/api/monitoring/v1/clusters/{clusterUuid}/alerts', + config: { + validate: { + params: Joi.object({ + clusterUuid: Joi.string().required() + }), + 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; + const savedObjectsClient = isFunction(req.getSavedObjectsClient) ? req.getSavedObjectsClient() : null; + if (!alertsClient || !savedObjectsClient) { + return headers.response().code(404); + } + + const [ + alerts, + emailResponse + ] = await Promise.all([ + createAlerts(alertsClient, { ...req.params, selectedEmailActionId }), + saveEmailAddress(emailAddress, savedObjectsClient) + ]); + + return { alerts, emailResponse }; + } + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js index 093e71e66ee2d..eaa9caf39889c 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js @@ -4,47 +4,6 @@ * 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'; +export * from './migration_status'; diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js new file mode 100644 index 0000000000000..75fc46fd00360 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import { alertsClusterSearch } from '../../../../cluster_alerts/alerts_cluster_search'; +import { checkLicense } from '../../../../cluster_alerts/check_license'; +import { getClusterLicense } from '../../../../lib/cluster/get_cluster_license'; +import { prefixIndexPattern } from '../../../../lib/ccs_utils'; +import { INDEX_PATTERN_ELASTICSEARCH, INDEX_ALERTS } from '../../../../../common/constants'; + +/* + * Cluster Alerts route. + */ +export function legacyClusterAlertsRoute(server) { + server.route({ + method: 'POST', + path: '/api/monitoring/v1/clusters/{clusterUuid}/legacy_alerts', + config: { + validate: { + params: Joi.object({ + clusterUuid: Joi.string().required() + }), + payload: Joi.object({ + ccs: Joi.string().optional(), + timeRange: Joi.object({ + min: Joi.date().required(), + max: Joi.date().required() + }).required() + }) + } + }, + handler(req) { + const config = server.config(); + const ccs = req.payload.ccs; + const clusterUuid = req.params.clusterUuid; + const esIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_ELASTICSEARCH, ccs); + const alertsIndex = prefixIndexPattern(config, INDEX_ALERTS, ccs); + const options = { + start: req.payload.timeRange.min, + end: req.payload.timeRange.max + }; + + return getClusterLicense(req, esIndexPattern, clusterUuid) + .then(license => alertsClusterSearch(req, alertsIndex, { cluster_uuid: clusterUuid, license }, checkLicense, options)); + } + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/migration_status.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/migration_status.js new file mode 100644 index 0000000000000..e9bfa52be184f --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/migration_status.js @@ -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 Joi from 'joi'; +import { get } from 'lodash'; + +/* + * Cluster Alerts route. + */ +export function clusterAlertMigrationStatusRoute(server) { + server.route({ + method: 'POST', + path: '/api/monitoring/v1/clusters/{clusterUuid}/migration_status', + config: { + validate: { + params: Joi.object({ + clusterUuid: Joi.string().required() + }) + } + }, + async handler(req) { + // const config = server.config(); + // const clusterUuid = req.params.clusterUuid; + + + + // Check exporters + const exporters = []; + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); + const response = await callWithRequest(req, 'transport.request', { + method: 'GET', + path: '/_cluster/settings?include_defaults', + filter_path: [ + 'persistent.xpack.monitoring', + 'transient.xpack.monitoring', + 'defaults.xpack.monitoring' + ] + }); + const sources = ['persistent', 'transient', 'defaults']; + for (const source of sources) { + const exporter = get(response[source], 'xpack.monitoring.exporters'); + if (exporter) { + exporters.push(exporter); + } + } + + return { + needToDisableWatches: exporters.length > 0, + }; + } + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js index dc0549a283972..11d8ec9a955aa 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js @@ -8,9 +8,7 @@ export { checkAccessRoute } from './check_access'; -export { - clusterAlertsRoute -} from './alerts/'; +export * from './alerts/'; export { beatsDetailRoute, beatsListingRoute, diff --git a/x-pack/legacy/plugins/monitoring/ui_exports.js b/x-pack/legacy/plugins/monitoring/ui_exports.js index a10b83086f738..ef83ded9130bf 100644 --- a/x-pack/legacy/plugins/monitoring/ui_exports.js +++ b/x-pack/legacy/plugins/monitoring/ui_exports.js @@ -28,7 +28,8 @@ export const getUiExports = () => ({ injectDefaultVars(server) { const config = server.config(); return { - monitoringUiEnabled: config.get('xpack.monitoring.ui.enabled') + monitoringUiEnabled: config.get('xpack.monitoring.ui.enabled'), + monitoringLegacyEmailAddress: config.get('xpack.monitoring.cluster_alerts.email_notifications.email_address'), }; }, hacks: [ 'plugins/monitoring/hacks/toggle_app_link_in_nav' ],