From 981d678e4207a4d850ae2b4b7fba3cb69a499e59 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 14 Jul 2020 19:53:14 +0200 Subject: [PATCH] [Uptime] Duration Anomaly Alert (#71208) --- .../providers/results_service.ts | 9 +- .../plugins/uptime/common/constants/alerts.ts | 5 + .../uptime/common/constants/rest_api.ts | 2 + .../lib/__tests__/ml.test.ts} | 2 +- x-pack/plugins/uptime/common/lib/index.ts | 2 + x-pack/plugins/uptime/common/lib/ml.ts | 27 ++++ x-pack/plugins/uptime/kibana.json | 2 +- .../ml/__tests__/ml_manage_job.test.tsx | 8 +- .../monitor/ml/confirm_alert_delete.tsx | 38 +++++ .../components/monitor/ml/manage_ml_job.tsx | 62 ++++++-- .../monitor/ml/ml_flyout_container.tsx | 74 +++++----- .../components/monitor/ml/ml_integeration.tsx | 2 +- .../components/monitor/ml/ml_job_link.tsx | 2 +- .../components/monitor/ml/translations.tsx | 14 ++ .../monitor/ml/use_anomaly_alert.ts | 30 ++++ .../monitor_duration_container.tsx | 2 +- .../alerts/alert_expression_popover.tsx | 2 +- .../alerts/anomaly_alert/anomaly_alert.tsx | 86 +++++++++++ .../alerts/anomaly_alert/select_severity.tsx | 135 ++++++++++++++++++ .../alerts/anomaly_alert/translations.ts | 26 ++++ .../lib/alert_types/duration_anomaly.tsx | 37 +++++ .../uptime/public/lib/alert_types/index.ts | 2 + .../public/lib/alert_types/translations.ts | 22 ++- .../plugins/uptime/public/pages/monitor.tsx | 5 + .../uptime/public/state/actions/alerts.ts | 15 ++ .../plugins/uptime/public/state/actions/ui.ts | 2 + .../plugins/uptime/public/state/api/alerts.ts | 27 ++++ .../uptime/public/state/api/ml_anomaly.ts | 27 +--- .../uptime/public/state/effects/alerts.ts | 39 +++++ .../uptime/public/state/effects/index.ts | 2 + .../uptime/public/state/effects/ml_anomaly.ts | 26 +++- .../uptime/public/state/kibana_service.ts | 4 + .../__tests__/__snapshots__/ui.test.ts.snap | 2 + .../state/reducers/__tests__/ui.test.ts | 6 + .../uptime/public/state/reducers/alerts.ts | 29 ++++ .../uptime/public/state/reducers/index.ts | 2 + .../uptime/public/state/reducers/ui.ts | 7 + .../state/selectors/__tests__/index.test.ts | 5 + .../uptime/public/state/selectors/index.ts | 6 + .../lib/adapters/framework/adapter_types.ts | 2 + .../lib/alerts/__tests__/status_check.test.ts | 41 +++--- .../server/lib/alerts/duration_anomaly.ts | 129 +++++++++++++++++ .../plugins/uptime/server/lib/alerts/index.ts | 2 + .../uptime/server/lib/alerts/translations.ts | 90 ++++++++++++ .../plugins/uptime/server/lib/alerts/types.ts | 8 +- x-pack/plugins/uptime/server/uptime_server.ts | 2 +- .../functional/services/uptime/ml_anomaly.ts | 20 +++ .../apps/uptime/anomaly_alert.ts | 131 +++++++++++++++++ .../apps/uptime/index.ts | 1 + 49 files changed, 1109 insertions(+), 112 deletions(-) rename x-pack/plugins/uptime/{public/state/api/__tests__/ml_anomaly.test.ts => common/lib/__tests__/ml.test.ts} (95%) create mode 100644 x-pack/plugins/uptime/common/lib/ml.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts create mode 100644 x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx create mode 100644 x-pack/plugins/uptime/public/state/actions/alerts.ts create mode 100644 x-pack/plugins/uptime/public/state/api/alerts.ts create mode 100644 x-pack/plugins/uptime/public/state/effects/alerts.ts create mode 100644 x-pack/plugins/uptime/public/state/reducers/alerts.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts diff --git a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts index 366a1f8b8c6f..6af4eb008567 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts @@ -25,7 +25,14 @@ export function getResultsServiceProvider({ }: SharedServicesChecks): ResultsServiceProvider { return { resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { - const hasMlCapabilities = getHasMlCapabilities(request); + // Uptime is using this service in anomaly alert, kibana alerting doesn't provide request object + // So we are adding a dummy request for now + // TODO: Remove this once kibana alerting provides request object + const hasMlCapabilities = + request.params !== 'DummyKibanaRequest' + ? getHasMlCapabilities(request) + : (_caps: string[]) => Promise.resolve(); + const { getAnomaliesTableData } = resultsServiceProvider(mlClusterClient); return { async getAnomaliesTableData(...args) { diff --git a/x-pack/plugins/uptime/common/constants/alerts.ts b/x-pack/plugins/uptime/common/constants/alerts.ts index a259fc0a3eb8..61a7a02bf8b3 100644 --- a/x-pack/plugins/uptime/common/constants/alerts.ts +++ b/x-pack/plugins/uptime/common/constants/alerts.ts @@ -20,9 +20,14 @@ export const ACTION_GROUP_DEFINITIONS: ActionGroupDefinitions = { id: 'xpack.uptime.alerts.actionGroups.tls', name: 'Uptime TLS Alert', }, + DURATION_ANOMALY: { + id: 'xpack.uptime.alerts.actionGroups.durationAnomaly', + name: 'Uptime Duration Anomaly', + }, }; export const CLIENT_ALERT_TYPES = { MONITOR_STATUS: 'xpack.uptime.alerts.monitorStatus', TLS: 'xpack.uptime.alerts.tls', + DURATION_ANOMALY: 'xpack.uptime.alerts.durationAnomaly', }; diff --git a/x-pack/plugins/uptime/common/constants/rest_api.ts b/x-pack/plugins/uptime/common/constants/rest_api.ts index 169d175f02d3..f3f06f776260 100644 --- a/x-pack/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/plugins/uptime/common/constants/rest_api.ts @@ -24,4 +24,6 @@ export enum API_URLS { ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`, ML_CAPABILITIES = '/api/ml/ml_capabilities', ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`, + ALERT = '/api/alerts/alert/', + ALERTS_FIND = '/api/alerts/_find', } diff --git a/x-pack/plugins/uptime/public/state/api/__tests__/ml_anomaly.test.ts b/x-pack/plugins/uptime/common/lib/__tests__/ml.test.ts similarity index 95% rename from x-pack/plugins/uptime/public/state/api/__tests__/ml_anomaly.test.ts rename to x-pack/plugins/uptime/common/lib/__tests__/ml.test.ts index 838e5b8246b4..122755638db7 100644 --- a/x-pack/plugins/uptime/public/state/api/__tests__/ml_anomaly.test.ts +++ b/x-pack/plugins/uptime/common/lib/__tests__/ml.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getMLJobId } from '../ml_anomaly'; +import { getMLJobId } from '../ml'; describe('ML Anomaly API', () => { it('it generates a lowercase job id', async () => { diff --git a/x-pack/plugins/uptime/common/lib/index.ts b/x-pack/plugins/uptime/common/lib/index.ts index 2daec0adf87e..33fe5b80d469 100644 --- a/x-pack/plugins/uptime/common/lib/index.ts +++ b/x-pack/plugins/uptime/common/lib/index.ts @@ -6,3 +6,5 @@ export * from './combine_filters_and_user_search'; export * from './stringify_kueries'; + +export { getMLJobId } from './ml'; diff --git a/x-pack/plugins/uptime/common/lib/ml.ts b/x-pack/plugins/uptime/common/lib/ml.ts new file mode 100644 index 000000000000..8be7c472fa5b --- /dev/null +++ b/x-pack/plugins/uptime/common/lib/ml.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ML_JOB_ID } from '../constants'; + +export const getJobPrefix = (monitorId: string) => { + // ML App doesn't support upper case characters in job name + // Also Spaces and the characters / ? , " < > | * are not allowed + // so we will replace all special chars with _ + + const prefix = monitorId.replace(/[^A-Z0-9]+/gi, '_').toLowerCase(); + + // ML Job ID can't be greater than 64 length, so will be substring it, and hope + // At such big length, there is minimum chance of having duplicate monitor id + // Subtracting ML_JOB_ID constant as well + const postfix = '_' + ML_JOB_ID; + + if ((prefix + postfix).length > 64) { + return prefix.substring(0, 64 - postfix.length) + '_'; + } + return prefix + '_'; +}; + +export const getMLJobId = (monitorId: string) => `${getJobPrefix(monitorId)}${ML_JOB_ID}`; diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index a057e546e441..f2b028e323ff 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack", "uptime"], "id": "uptime", "kibanaVersion": "kibana", - "optionalPlugins": ["capabilities", "data", "home", "observability"], + "optionalPlugins": ["capabilities", "data", "home", "observability", "ml"], "requiredPlugins": [ "alerts", "embeddable", diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx index 30038b030be5..841c577a4014 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx @@ -11,8 +11,8 @@ import { renderWithRouter, shallowWithRouter } from '../../../../lib'; describe('Manage ML Job', () => { it('shallow renders without errors', () => { - const spy = jest.spyOn(redux, 'useSelector'); - spy.mockReturnValue(true); + jest.spyOn(redux, 'useSelector').mockReturnValue(true); + jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); const wrapper = shallowWithRouter( @@ -21,8 +21,8 @@ describe('Manage ML Job', () => { }); it('renders without errors', () => { - const spy = jest.spyOn(redux, 'useSelector'); - spy.mockReturnValue(true); + jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); + jest.spyOn(redux, 'useSelector').mockReturnValue(true); const wrapper = renderWithRouter( diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx new file mode 100644 index 000000000000..cd5e509e3ad8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as labels from './translations'; + +interface Props { + onConfirm: () => void; + onCancel: () => void; +} + +export const ConfirmAlertDeletion: React.FC = ({ onConfirm, onCancel }) => { + return ( + + +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx index 248ea179ccd2..5c3674761af8 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx @@ -7,7 +7,8 @@ import React, { useContext, useState } from 'react'; import { EuiButtonEmpty, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; +import { CLIENT_ALERT_TYPES } from '../../../../common/constants'; import { canDeleteMLJobSelector, hasMLJobSelector, @@ -18,6 +19,10 @@ import * as labels from './translations'; import { getMLJobLinkHref } from './ml_job_link'; import { useGetUrlParams } from '../../../hooks'; import { useMonitorId } from '../../../hooks'; +import { setAlertFlyoutType, setAlertFlyoutVisible } from '../../../state/actions'; +import { useAnomalyAlert } from './use_anomaly_alert'; +import { ConfirmAlertDeletion } from './confirm_alert_delete'; +import { deleteAlertAction } from '../../../state/actions/alerts'; interface Props { hasMLJob: boolean; @@ -40,6 +45,15 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro const monitorId = useMonitorId(); + const dispatch = useDispatch(); + + const anomalyAlert = useAnomalyAlert(); + + const [isConfirmAlertDeleteOpen, setIsConfirmAlertDeleteOpen] = useState(false); + + const deleteAnomalyAlert = () => + dispatch(deleteAlertAction.get({ alertId: anomalyAlert?.id as string })); + const button = ( , + onClick: () => { + if (anomalyAlert) { + setIsConfirmAlertDeleteOpen(true); + } else { + dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY)); + dispatch(setAlertFlyoutVisible(true)); + } + }, + }, { name: labels.DISABLE_ANOMALY_DETECTION, 'data-test-subj': 'uptimeDeleteMLJobBtn', @@ -82,12 +111,29 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro ]; return ( - setIsPopOverOpen(false)}> - - + <> + setIsPopOverOpen(false)} + > + + + {isConfirmAlertDeleteOpen && ( + { + deleteAnomalyAlert(); + setIsConfirmAlertDeleteOpen(false); + }} + onCancel={() => { + setIsConfirmAlertDeleteOpen(false); + }} + /> + )} + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx index e4bb3d0ac9e1..84634f328621 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx @@ -13,59 +13,61 @@ import { isMLJobCreatingSelector, selectDynamicSettings, } from '../../../state/selectors'; -import { createMLJobAction, getExistingMLJobAction } from '../../../state/actions'; +import { + createMLJobAction, + getExistingMLJobAction, + setAlertFlyoutType, + setAlertFlyoutVisible, +} from '../../../state/actions'; import { MLJobLink } from './ml_job_link'; import * as labels from './translations'; -import { - useKibana, - KibanaReactNotifications, -} from '../../../../../../../src/plugins/kibana_react/public'; import { MLFlyoutView } from './ml_flyout'; -import { ML_JOB_ID } from '../../../../common/constants'; +import { CLIENT_ALERT_TYPES, ML_JOB_ID } from '../../../../common/constants'; import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; import { useGetUrlParams } from '../../../hooks'; import { getDynamicSettings } from '../../../state/actions/dynamic_settings'; import { useMonitorId } from '../../../hooks'; +import { kibanaService } from '../../../state/kibana_service'; +import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; interface Props { onClose: () => void; } const showMLJobNotification = ( - notifications: KibanaReactNotifications, monitorId: string, basePath: string, range: { to: string; from: string }, success: boolean, - message = '' + error?: Error ) => { if (success) { - notifications.toasts.success({ - title: ( -

{labels.JOB_CREATED_SUCCESS_TITLE}

- ), - body: ( -

- {labels.JOB_CREATED_SUCCESS_MESSAGE} - - {labels.VIEW_JOB} - -

- ), - toastLifeTimeMs: 10000, - }); + kibanaService.toasts.addSuccess( + { + title: toMountPoint( +

{labels.JOB_CREATED_SUCCESS_TITLE}

+ ), + text: toMountPoint( +

+ {labels.JOB_CREATED_SUCCESS_MESSAGE} + + {labels.VIEW_JOB} + +

+ ), + }, + { toastLifeTimeMs: 10000 } + ); } else { - notifications.toasts.danger({ - title:

{labels.JOB_CREATION_FAILED}

, - body: message ??

{labels.JOB_CREATION_FAILED_MESSAGE}

, + kibanaService.toasts.addError(error!, { + title: labels.JOB_CREATION_FAILED, + toastMessage: labels.JOB_CREATION_FAILED_MESSAGE, toastLifeTimeMs: 10000, }); } }; export const MachineLearningFlyout: React.FC = ({ onClose }) => { - const { notifications } = useKibana(); - const dispatch = useDispatch(); const { data: hasMLJob, error } = useSelector(hasNewMLJobSelector); const isMLJobCreating = useSelector(isMLJobCreatingSelector); @@ -100,7 +102,6 @@ export const MachineLearningFlyout: React.FC = ({ onClose }) => { if (isCreatingJob && !isMLJobCreating) { if (hasMLJob) { showMLJobNotification( - notifications, monitorId as string, basePath, { to: dateRangeEnd, from: dateRangeStart }, @@ -112,31 +113,22 @@ export const MachineLearningFlyout: React.FC = ({ onClose }) => { loadMLJob(ML_JOB_ID); refreshApp(); + dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY)); + dispatch(setAlertFlyoutVisible(true)); } else { showMLJobNotification( - notifications, monitorId as string, basePath, { to: dateRangeEnd, from: dateRangeStart }, false, - error?.message || error?.body?.message + error as Error ); } setIsCreatingJob(false); onClose(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - hasMLJob, - notifications, - onClose, - isCreatingJob, - error, - isMLJobCreating, - monitorId, - dispatch, - basePath, - ]); + }, [hasMLJob, onClose, isCreatingJob, error, isMLJobCreating, monitorId, dispatch, basePath]); useEffect(() => { if (hasExistingMLJob && !isMLJobCreating && !hasMLJob && heartbeatIndices) { diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx index 1de19dda3b88..aa67c7ba1c2f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx @@ -16,12 +16,12 @@ import { import { deleteMLJobAction, getExistingMLJobAction, resetMLState } from '../../../state/actions'; import { ConfirmJobDeletion } from './confirm_delete'; import { UptimeRefreshContext } from '../../../contexts'; -import { getMLJobId } from '../../../state/api/ml_anomaly'; import * as labels from './translations'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ManageMLJobComponent } from './manage_ml_job'; import { JobStat } from '../../../../../../plugins/ml/public'; import { useMonitorId } from '../../../hooks'; +import { getMLJobId } from '../../../../common/lib'; export const MLIntegrationComponent = () => { const [isMlFlyoutOpen, setIsMlFlyoutOpen] = useState(false); diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx index 4b6f7e3ba061..adc05695b437 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx @@ -8,7 +8,7 @@ import React from 'react'; import url from 'url'; import { EuiButtonEmpty } from '@elastic/eui'; import rison, { RisonValue } from 'rison-node'; -import { getMLJobId } from '../../../state/api/ml_anomaly'; +import { getMLJobId } from '../../../../common/lib'; interface Props { monitorId: string; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx index bcc3fca77065..90ebdf10a73f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx @@ -89,6 +89,20 @@ export const DISABLE_ANOMALY_DETECTION = i18n.translate( } ); +export const ENABLE_ANOMALY_ALERT = i18n.translate( + 'xpack.uptime.ml.enableAnomalyDetectionPanel.enableAnomalyAlert', + { + defaultMessage: 'Enable anomaly alert', + } +); + +export const DISABLE_ANOMALY_ALERT = i18n.translate( + 'xpack.uptime.ml.enableAnomalyDetectionPanel.disableAnomalyAlert', + { + defaultMessage: 'Disable anomaly alert', + } +); + export const MANAGE_ANOMALY_DETECTION = i18n.translate( 'xpack.uptime.ml.enableAnomalyDetectionPanel.manageAnomalyDetectionTitle', { diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts new file mode 100644 index 000000000000..d204cdf10012 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getExistingAlertAction } from '../../../state/actions/alerts'; +import { alertSelector, selectAlertFlyoutVisibility } from '../../../state/selectors'; +import { UptimeRefreshContext } from '../../../contexts'; +import { useMonitorId } from '../../../hooks'; + +export const useAnomalyAlert = () => { + const { lastRefresh } = useContext(UptimeRefreshContext); + + const dispatch = useDispatch(); + + const monitorId = useMonitorId(); + + const { data: anomalyAlert } = useSelector(alertSelector); + + const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); + + useEffect(() => { + dispatch(getExistingAlertAction.get({ monitorId })); + }, [monitorId, lastRefresh, dispatch, alertFlyoutVisible]); + + return anomalyAlert; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx index df8ceed76b79..29edb69f4674 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx @@ -19,10 +19,10 @@ import { selectDurationLines, } from '../../../state/selectors'; import { UptimeRefreshContext } from '../../../contexts'; -import { getMLJobId } from '../../../state/api/ml_anomaly'; import { JobStat } from '../../../../../ml/public'; import { MonitorDurationComponent } from './monitor_duration'; import { MonitorIdParam } from '../../../../common/types'; +import { getMLJobId } from '../../../../common/lib'; export const MonitorDuration: React.FC = ({ monitorId }) => { const { diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx index 0ae8c3a93da9..b5ef240e67db 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx @@ -14,8 +14,8 @@ interface AlertExpressionPopoverProps { 'data-test-subj': string; isEnabled?: boolean; id: string; + value: string | JSX.Element; isInvalid?: boolean; - value: string; } const getColor = ( diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx new file mode 100644 index 000000000000..4b84012575ae --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx @@ -0,0 +1,86 @@ +/* + * 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 { + EuiExpression, + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiHealth, + EuiText, +} from '@elastic/eui'; +import { useSelector } from 'react-redux'; +import React, { useEffect, useState } from 'react'; +import { AnomalyTranslations } from './translations'; +import { AlertExpressionPopover } from '../alert_expression_popover'; +import { DEFAULT_SEVERITY, SelectSeverity } from './select_severity'; +import { monitorIdSelector } from '../../../../state/selectors'; +import { getSeverityColor, getSeverityType } from '../../../../../../ml/public'; + +interface Props { + alertParams: { [key: string]: any }; + setAlertParams: (key: string, value: any) => void; +} + +// eslint-disable-next-line import/no-default-export +export default function AnomalyAlertComponent({ setAlertParams, alertParams }: Props) { + const [severity, setSeverity] = useState(DEFAULT_SEVERITY); + + const monitorIdStore = useSelector(monitorIdSelector); + + const monitorId = monitorIdStore || alertParams?.monitorId; + + useEffect(() => { + setAlertParams('monitorId', monitorId); + }, [monitorId, setAlertParams]); + + useEffect(() => { + setAlertParams('severity', severity.val); + }, [severity, setAlertParams]); + + return ( + <> + + + + +
{monitorId}
+ + } + /> +
+ + + } + data-test-subj={'uptimeAnomalySeverity'} + description={AnomalyTranslations.hasAnomalyWithSeverity} + id="severity" + value={ + + {getSeverityType(severity.val)} + + } + isEnabled={true} + /> + +
+ + + ); +} diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx new file mode 100644 index 000000000000..0932d0c6eca8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { getSeverityColor } from '../../../../../../ml/public'; + +const warningLabel = i18n.translate('xpack.uptime.controls.selectSeverity.warningLabel', { + defaultMessage: 'warning', +}); +const minorLabel = i18n.translate('xpack.uptime.controls.selectSeverity.minorLabel', { + defaultMessage: 'minor', +}); +const majorLabel = i18n.translate('xpack.uptime.controls.selectSeverity.majorLabel', { + defaultMessage: 'major', +}); +const criticalLabel = i18n.translate('xpack.uptime.controls.selectSeverity.criticalLabel', { + defaultMessage: 'critical', +}); + +const optionsMap = { + [warningLabel]: 0, + [minorLabel]: 25, + [majorLabel]: 50, + [criticalLabel]: 75, +}; + +interface TableSeverity { + val: number; + display: string; + color: string; +} + +export const SEVERITY_OPTIONS: TableSeverity[] = [ + { + val: 0, + display: warningLabel, + color: getSeverityColor(0), + }, + { + val: 25, + display: minorLabel, + color: getSeverityColor(25), + }, + { + val: 50, + display: majorLabel, + color: getSeverityColor(50), + }, + { + val: 75, + display: criticalLabel, + color: getSeverityColor(75), + }, +]; + +function optionValueToThreshold(value: number) { + // Get corresponding threshold object with required display and val properties from the specified value. + let threshold = SEVERITY_OPTIONS.find((opt) => opt.val === value); + + // Default to warning if supplied value doesn't map to one of the options. + if (threshold === undefined) { + threshold = SEVERITY_OPTIONS[0]; + } + + return threshold; +} + +export const DEFAULT_SEVERITY = SEVERITY_OPTIONS[3]; + +const getSeverityOptions = () => + SEVERITY_OPTIONS.map(({ color, display, val }) => ({ + 'data-test-subj': `alertAnomaly${display}`, + value: display, + inputDisplay: ( + + + {display} + + + ), + dropdownDisplay: ( + + + {display} + + + +

+ +

+
+
+ ), + })); + +interface Props { + onChange: (sev: TableSeverity) => void; + value: TableSeverity; +} + +export const SelectSeverity: FC = ({ onChange, value }) => { + const [severity, setSeverity] = useState(DEFAULT_SEVERITY); + + const onSeverityChange = (valueDisplay: string) => { + const option = optionValueToThreshold(optionsMap[valueDisplay]); + setSeverity(option); + onChange(option); + }; + + useEffect(() => { + setSeverity(value); + }, [value]); + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts new file mode 100644 index 000000000000..5fd37609f86b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const AnomalyTranslations = { + criteriaAriaLabel: i18n.translate('xpack.uptime.alerts.anomaly.criteriaExpression.ariaLabel', { + defaultMessage: 'An expression displaying the criteria for a selected monitor.', + }), + whenMonitor: i18n.translate('xpack.uptime.alerts.anomaly.criteriaExpression.description', { + defaultMessage: 'When monitor', + }), + scoreAriaLabel: i18n.translate('xpack.uptime.alerts.anomaly.scoreExpression.ariaLabel', { + defaultMessage: 'An expression displaying the criteria for an anomaly alert threshold.', + }), + hasAnomalyWithSeverity: i18n.translate( + 'xpack.uptime.alerts.anomaly.scoreExpression.description', + { + defaultMessage: 'has anomaly with severity', + description: 'An expression displaying the criteria for an anomaly alert threshold.', + } + ), +}; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx new file mode 100644 index 000000000000..f0eb30546158 --- /dev/null +++ b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; +import { CLIENT_ALERT_TYPES } from '../../../common/constants'; +import { DurationAnomalyTranslations } from './translations'; +import { AlertTypeInitializer } from '.'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { store } from '../../state'; + +const { name, defaultActionMessage } = DurationAnomalyTranslations; +const AnomalyAlertExpression = React.lazy(() => + import('../../components/overview/alerts/anomaly_alert/anomaly_alert') +); +export const initDurationAnomalyAlertType: AlertTypeInitializer = ({ + core, + plugins, +}): AlertTypeModel => ({ + id: CLIENT_ALERT_TYPES.DURATION_ANOMALY, + iconClass: 'uptimeApp', + alertParamsExpression: (params: any) => ( + + + + + + ), + name, + validate: () => ({ errors: {} }), + defaultActionMessage, + requiresAppContext: false, +}); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/plugins/uptime/public/lib/alert_types/index.ts index f2f72311d226..5eb693c6bd5c 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/index.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/index.ts @@ -9,6 +9,7 @@ import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; import { initMonitorStatusAlertType } from './monitor_status'; import { initTlsAlertType } from './tls'; import { ClientPluginsStart } from '../../apps/plugin'; +import { initDurationAnomalyAlertType } from './duration_anomaly'; export type AlertTypeInitializer = (dependenies: { core: CoreStart; @@ -18,4 +19,5 @@ export type AlertTypeInitializer = (dependenies: { export const alertTypeInitializers: AlertTypeInitializer[] = [ initMonitorStatusAlertType, initTlsAlertType, + initDurationAnomalyAlertType, ]; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts index 11fa70bc56f4..9232dd590ad5 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts @@ -26,7 +26,7 @@ export const TlsTranslations = { {expiringConditionalOpen} Expiring cert count: {expiringCount} Expiring Certificates: {expiringCommonNameAndDate} -{expiringConditionalClose} +{expiringConditionalClose} {agingConditionalOpen} Aging cert count: {agingCount} @@ -49,3 +49,23 @@ Aging Certificates: {agingCommonNameAndDate} defaultMessage: 'Uptime TLS', }), }; + +export const DurationAnomalyTranslations = { + defaultActionMessage: i18n.translate('xpack.uptime.alerts.durationAnomaly.defaultActionMessage', { + defaultMessage: `Abnormal ({severity} level) response time detected on {monitor} with url {monitorUrl} at {anomalyStartTimestamp}. Anomaly severity score is {severityScore}. +Response times as high as {slowestAnomalyResponse} have been detected from location {observerLocation}. Expected response time is {expectedResponseTime}.`, + values: { + severity: '{{state.severity}}', + anomalyStartTimestamp: '{{state.anomalyStartTimestamp}}', + monitor: '{{state.monitor}}', + monitorUrl: '{{{state.monitorUrl}}}', + slowestAnomalyResponse: '{{state.slowestAnomalyResponse}}', + expectedResponseTime: '{{state.expectedResponseTime}}', + severityScore: '{{state.severityScore}}', + observerLocation: '{{state.observerLocation}}', + }, + }), + name: i18n.translate('xpack.uptime.alerts.durationAnomaly.clientName', { + defaultMessage: 'Uptime Duration Anomaly', + }), +}; diff --git a/x-pack/plugins/uptime/public/pages/monitor.tsx b/x-pack/plugins/uptime/public/pages/monitor.tsx index ab7cf5b2cb3e..f7012fc5119e 100644 --- a/x-pack/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor.tsx @@ -16,6 +16,7 @@ import { MonitorCharts } from '../components/monitor'; import { MonitorStatusDetails, PingList } from '../components/monitor'; import { getDynamicSettings } from '../state/actions/dynamic_settings'; import { Ping } from '../../common/runtime_types/ping'; +import { setSelectedMonitorId } from '../state/actions'; const isAutogeneratedId = (id: string) => { const autoGeneratedId = /^auto-(icmp|http|tcp)-OX[A-F0-9]{16}-[a-f0-9]{16}/; @@ -43,6 +44,10 @@ export const MonitorPage: React.FC = () => { const monitorId = useMonitorId(); + useEffect(() => { + dispatch(setSelectedMonitorId(monitorId)); + }, [monitorId, dispatch]); + const selectedMonitor = useSelector(monitorStatusSelector); useTrackPageview({ app: 'uptime', path: 'monitor' }); diff --git a/x-pack/plugins/uptime/public/state/actions/alerts.ts b/x-pack/plugins/uptime/public/state/actions/alerts.ts new file mode 100644 index 000000000000..a650a9ba8d08 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/actions/alerts.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAsyncAction } from './utils'; +import { MonitorIdParam } from './types'; +import { Alert } from '../../../../triggers_actions_ui/public'; + +export const getExistingAlertAction = createAsyncAction( + 'GET EXISTING ALERTS' +); + +export const deleteAlertAction = createAsyncAction<{ alertId: string }, any>('DELETE ALERTS'); diff --git a/x-pack/plugins/uptime/public/state/actions/ui.ts b/x-pack/plugins/uptime/public/state/actions/ui.ts index 04ad6c2fa0bf..9387506e4e7b 100644 --- a/x-pack/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/plugins/uptime/public/state/actions/ui.ts @@ -25,3 +25,5 @@ export const setSearchTextAction = createAction('SET SEARCH'); export const toggleIntegrationsPopover = createAction( 'TOGGLE INTEGRATION POPOVER STATE' ); + +export const setSelectedMonitorId = createAction('SET MONITOR ID'); diff --git a/x-pack/plugins/uptime/public/state/api/alerts.ts b/x-pack/plugins/uptime/public/state/api/alerts.ts new file mode 100644 index 000000000000..526abd6b303e --- /dev/null +++ b/x-pack/plugins/uptime/public/state/api/alerts.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { apiService } from './utils'; +import { API_URLS } from '../../../common/constants'; +import { MonitorIdParam } from '../actions/types'; +import { Alert } from '../../../../triggers_actions_ui/public'; + +export const fetchAlertRecords = async ({ monitorId }: MonitorIdParam): Promise => { + const data = { + page: 1, + per_page: 500, + filter: 'alert.attributes.alertTypeId:(xpack.uptime.alerts.durationAnomaly)', + default_search_operator: 'AND', + sort_field: 'name.keyword', + sort_order: 'asc', + }; + const alerts = await apiService.get(API_URLS.ALERTS_FIND, data); + return alerts.data.find((alert: Alert) => alert.params.monitorId === monitorId); +}; + +export const disableAnomalyAlert = async ({ alertId }: { alertId: string }) => { + return await apiService.delete(API_URLS.ALERT + alertId); +}; diff --git a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts index 5ec7a6262db6..1d25f35e8f38 100644 --- a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts @@ -7,38 +7,19 @@ import moment from 'moment'; import { apiService } from './utils'; import { AnomalyRecords, AnomalyRecordsParams } from '../actions'; -import { API_URLS, ML_JOB_ID, ML_MODULE_ID } from '../../../common/constants'; +import { API_URLS, ML_MODULE_ID } from '../../../common/constants'; import { - MlCapabilitiesResponse, DataRecognizerConfigResponse, JobExistResult, + MlCapabilitiesResponse, } from '../../../../../plugins/ml/public'; import { CreateMLJobSuccess, DeleteJobResults, - MonitorIdParam, HeartbeatIndicesParam, + MonitorIdParam, } from '../actions/types'; - -const getJobPrefix = (monitorId: string) => { - // ML App doesn't support upper case characters in job name - // Also Spaces and the characters / ? , " < > | * are not allowed - // so we will replace all special chars with _ - - const prefix = monitorId.replace(/[^A-Z0-9]+/gi, '_').toLowerCase(); - - // ML Job ID can't be greater than 64 length, so will be substring it, and hope - // At such big length, there is minimum chance of having duplicate monitor id - // Subtracting ML_JOB_ID constant as well - const postfix = '_' + ML_JOB_ID; - - if ((prefix + postfix).length > 64) { - return prefix.substring(0, 64 - postfix.length) + '_'; - } - return prefix + '_'; -}; - -export const getMLJobId = (monitorId: string) => `${getJobPrefix(monitorId)}${ML_JOB_ID}`; +import { getJobPrefix, getMLJobId } from '../../../common/lib/ml'; export const getMLCapabilities = async (): Promise => { return await apiService.get(API_URLS.ML_CAPABILITIES); diff --git a/x-pack/plugins/uptime/public/state/effects/alerts.ts b/x-pack/plugins/uptime/public/state/effects/alerts.ts new file mode 100644 index 000000000000..5f71b0bea7b2 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/effects/alerts.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action } from 'redux-actions'; +import { call, put, takeLatest, select } from 'redux-saga/effects'; +import { fetchEffectFactory } from './fetch_effect'; +import { deleteAlertAction, getExistingAlertAction } from '../actions/alerts'; +import { disableAnomalyAlert, fetchAlertRecords } from '../api/alerts'; +import { kibanaService } from '../kibana_service'; +import { monitorIdSelector } from '../selectors'; + +export function* fetchAlertsEffect() { + yield takeLatest( + getExistingAlertAction.get, + fetchEffectFactory( + fetchAlertRecords, + getExistingAlertAction.success, + getExistingAlertAction.fail + ) + ); + + yield takeLatest(String(deleteAlertAction.get), function* (action: Action<{ alertId: string }>) { + try { + const response = yield call(disableAnomalyAlert, action.payload); + yield put(deleteAlertAction.success(response)); + kibanaService.core.notifications.toasts.addSuccess('Alert successfully deleted!'); + const monitorId = yield select(monitorIdSelector); + yield put(getExistingAlertAction.get({ monitorId })); + } catch (err) { + kibanaService.core.notifications.toasts.addError(err, { + title: 'Alert cannot be deleted', + }); + yield put(deleteAlertAction.fail(err)); + } + }); +} diff --git a/x-pack/plugins/uptime/public/state/effects/index.ts b/x-pack/plugins/uptime/public/state/effects/index.ts index 211067c840d5..b13ba7f1a910 100644 --- a/x-pack/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/plugins/uptime/public/state/effects/index.ts @@ -17,6 +17,7 @@ import { fetchMonitorDurationEffect } from './monitor_duration'; import { fetchMLJobEffect } from './ml_anomaly'; import { fetchIndexStatusEffect } from './index_status'; import { fetchCertificatesEffect } from '../certificates/certificates'; +import { fetchAlertsEffect } from './alerts'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); @@ -33,4 +34,5 @@ export function* rootEffect() { yield fork(fetchMonitorDurationEffect); yield fork(fetchIndexStatusEffect); yield fork(fetchCertificatesEffect); + yield fork(fetchAlertsEffect); } diff --git a/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts index a6a376b546ab..00f8a388c689 100644 --- a/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { takeLatest } from 'redux-saga/effects'; +import { Action } from 'redux-actions'; +import { call, put, select, takeLatest } from 'redux-saga/effects'; import { getMLCapabilitiesAction, getExistingMLJobAction, @@ -20,6 +21,9 @@ import { deleteMLJob, getMLCapabilities, } from '../api/ml_anomaly'; +import { deleteAlertAction } from '../actions/alerts'; +import { alertSelector } from '../selectors'; +import { MonitorIdParam } from '../actions/types'; export function* fetchMLJobEffect() { yield takeLatest( @@ -38,10 +42,22 @@ export function* fetchMLJobEffect() { getAnomalyRecordsAction.fail ) ); - yield takeLatest( - deleteMLJobAction.get, - fetchEffectFactory(deleteMLJob, deleteMLJobAction.success, deleteMLJobAction.fail) - ); + + yield takeLatest(String(deleteMLJobAction.get), function* (action: Action) { + try { + const response = yield call(deleteMLJob, action.payload); + yield put(deleteMLJobAction.success(response)); + + // let's delete alert as well if it's there + const { data: anomalyAlert } = yield select(alertSelector); + if (anomalyAlert) { + yield put(deleteAlertAction.get({ alertId: anomalyAlert.id as string })); + } + } catch (err) { + yield put(deleteMLJobAction.fail(err)); + } + }); + yield takeLatest( getMLCapabilitiesAction.get, fetchEffectFactory( diff --git a/x-pack/plugins/uptime/public/state/kibana_service.ts b/x-pack/plugins/uptime/public/state/kibana_service.ts index 4fd2d446daa1..f1eb3af9da66 100644 --- a/x-pack/plugins/uptime/public/state/kibana_service.ts +++ b/x-pack/plugins/uptime/public/state/kibana_service.ts @@ -20,6 +20,10 @@ class KibanaService { apiService.http = this._core.http; } + public get toasts() { + return this._core.notifications.toasts; + } + private constructor() {} static getInstance(): KibanaService { diff --git a/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index c11b146101d3..040fbf7f4fe0 100644 --- a/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -9,6 +9,7 @@ Object { "id": "popover-2", "open": true, }, + "monitorId": "test", "searchText": "", } `; @@ -19,6 +20,7 @@ Object { "basePath": "yyz", "esKuery": "", "integrationsPopoverOpen": null, + "monitorId": "test", "searchText": "", } `; diff --git a/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index 4683c654270d..c265cd9fc7ec 100644 --- a/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -24,6 +24,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -43,6 +44,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -59,6 +61,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -68,6 +71,7 @@ describe('ui reducer', () => { "basePath": "", "esKuery": "", "integrationsPopoverOpen": null, + "monitorId": "test", "searchText": "", } `); @@ -83,6 +87,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -92,6 +97,7 @@ describe('ui reducer', () => { "basePath": "", "esKuery": "", "integrationsPopoverOpen": null, + "monitorId": "test", "searchText": "lorem ipsum", } `); diff --git a/x-pack/plugins/uptime/public/state/reducers/alerts.ts b/x-pack/plugins/uptime/public/state/reducers/alerts.ts new file mode 100644 index 000000000000..a2cd844e2496 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/reducers/alerts.ts @@ -0,0 +1,29 @@ +/* + * 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 { handleActions } from 'redux-actions'; +import { getAsyncInitialState, handleAsyncAction } from './utils'; +import { AsyncInitialState } from './types'; +import { deleteAlertAction, getExistingAlertAction } from '../actions/alerts'; +import { Alert } from '../../../../triggers_actions_ui/public'; + +export interface AlertsState { + alert: AsyncInitialState; + alertDeletion: AsyncInitialState; +} + +const initialState: AlertsState = { + alert: getAsyncInitialState(), + alertDeletion: getAsyncInitialState(), +}; + +export const alertsReducer = handleActions( + { + ...handleAsyncAction('alert', getExistingAlertAction), + ...handleAsyncAction('alertDeletion', deleteAlertAction), + }, + initialState +); diff --git a/x-pack/plugins/uptime/public/state/reducers/index.ts b/x-pack/plugins/uptime/public/state/reducers/index.ts index c05c740ab8eb..01baf7cf07c9 100644 --- a/x-pack/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/plugins/uptime/public/state/reducers/index.ts @@ -20,6 +20,7 @@ import { indexStatusReducer } from './index_status'; import { mlJobsReducer } from './ml_anomaly'; import { certificatesReducer } from '../certificates/certificates'; import { selectedFiltersReducer } from './selected_filters'; +import { alertsReducer } from './alerts'; export const rootReducer = combineReducers({ monitor: monitorReducer, @@ -37,4 +38,5 @@ export const rootReducer = combineReducers({ indexStatus: indexStatusReducer, certificates: certificatesReducer, selectedFilters: selectedFiltersReducer, + alerts: alertsReducer, }); diff --git a/x-pack/plugins/uptime/public/state/reducers/ui.ts b/x-pack/plugins/uptime/public/state/reducers/ui.ts index 3cf4ae9c0bbf..568234a3a83c 100644 --- a/x-pack/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/plugins/uptime/public/state/reducers/ui.ts @@ -14,6 +14,7 @@ import { setAlertFlyoutType, setAlertFlyoutVisible, setSearchTextAction, + setSelectedMonitorId, } from '../actions'; export interface UiState { @@ -23,6 +24,7 @@ export interface UiState { esKuery: string; searchText: string; integrationsPopoverOpen: PopoverState | null; + monitorId: string; } const initialState: UiState = { @@ -31,6 +33,7 @@ const initialState: UiState = { esKuery: '', searchText: '', integrationsPopoverOpen: null, + monitorId: '', }; export const uiReducer = handleActions( @@ -64,6 +67,10 @@ export const uiReducer = handleActions( ...state, searchText: action.payload, }), + [String(setSelectedMonitorId)]: (state, action: Action) => ({ + ...state, + monitorId: action.payload, + }), }, initialState ); diff --git a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts index b1885ddeeba3..de8615c7016a 100644 --- a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -45,6 +45,7 @@ describe('state selectors', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: '', }, monitorStatus: { status: null, @@ -108,6 +109,10 @@ describe('state selectors', () => { }, }, selectedFilters: null, + alerts: { + alertDeletion: { data: null, loading: false }, + alert: { data: null, loading: false }, + }, }; it('selects base path from state', () => { diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index 4c2b671203f0..bf6c9b3666a6 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -59,6 +59,8 @@ export const hasNewMLJobSelector = ({ ml }: AppState) => ml.createJob; export const isMLJobCreatingSelector = ({ ml }: AppState) => ml.createJob.loading; export const isMLJobDeletingSelector = ({ ml }: AppState) => ml.deleteJob.loading; +export const isAnomalyAlertDeletingSelector = ({ alerts }: AppState) => + alerts.alertDeletion.loading; export const isMLJobDeletedSelector = ({ ml }: AppState) => ml.deleteJob; @@ -88,3 +90,7 @@ export const esKuerySelector = ({ ui: { esKuery } }: AppState) => esKuery; export const searchTextSelector = ({ ui: { searchText } }: AppState) => searchText; export const selectedFiltersSelector = ({ selectedFilters }: AppState) => selectedFilters; + +export const monitorIdSelector = ({ ui: { monitorId } }: AppState) => monitorId; + +export const alertSelector = ({ alerts }: AppState) => alerts.alert; diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index 2e732f59e4f3..75d9c8aa959b 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -14,6 +14,7 @@ import { import { UMKibanaRoute } from '../../../rest_api'; import { PluginSetupContract } from '../../../../../features/server'; import { DynamicSettings } from '../../../../common/runtime_types'; +import { MlPluginSetup as MlSetup } from '../../../../../ml/server'; export type APICaller = ( endpoint: string, @@ -39,6 +40,7 @@ export interface UptimeCorePlugins { alerts: any; elasticsearch: any; usageCollection: UsageCollectionSetup; + ml: MlSetup; } export interface UMBackendFrameworkAdapter { diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts index d85752768b47..a38132d0f7a8 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -17,7 +17,7 @@ import { GetMonitorStatusResult } from '../../requests'; import { AlertType } from '../../../../../alerts/server'; import { IRouter } from 'kibana/server'; import { UMServerLibs } from '../../lib'; -import { UptimeCoreSetup } from '../../adapters'; +import { UptimeCorePlugins, UptimeCoreSetup } from '../../adapters'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; @@ -33,9 +33,10 @@ const bootstrapDependencies = (customRequests?: any) => { // these server/libs parameters don't have any functionality, which is fine // because we aren't testing them here const server: UptimeCoreSetup = { router }; + const plugins: UptimeCorePlugins = {} as any; const libs: UMServerLibs = { requests: {} } as UMServerLibs; libs.requests = { ...libs.requests, ...customRequests }; - return { server, libs }; + return { server, libs, plugins }; }; /** @@ -82,8 +83,8 @@ describe('status check alert', () => { expect.assertions(4); const mockGetter = jest.fn(); mockGetter.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); - const alert = statusCheckAlertFactory(server, libs); + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs, plugins); // @ts-ignore the executor can return `void`, but ours never does const state: Record = await alert.executor(mockOptions()); @@ -128,8 +129,8 @@ describe('status check alert', () => { status: 'down', }, ]); - const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); - const alert = statusCheckAlertFactory(server, libs); + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions(); const alertServices: AlertServicesMock = options.services; // @ts-ignore the executor can return `void`, but ours never does @@ -213,11 +214,11 @@ describe('status check alert', () => { status: 'down', }, ]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 4, timerange: { from: 'now-14h', to: 'now' }, @@ -286,11 +287,11 @@ describe('status check alert', () => { status: 'down', }, ]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 3, timerangeUnit: 'm', @@ -371,11 +372,11 @@ describe('status check alert', () => { toISOStringSpy.mockImplementation(() => 'search test'); const mockGetter = jest.fn(); mockGetter.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getIndexPattern: jest.fn(), getMonitorStatus: mockGetter, }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 20, timerangeCount: 30, @@ -467,12 +468,12 @@ describe('status check alert', () => { availabilityRatio: 0.909245845760545, }, ]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ availability: { range: 35, @@ -559,11 +560,11 @@ describe('status check alert', () => { mockGetter.mockReturnValue([]); const mockAvailability = jest.fn(); mockAvailability.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ availability: { range: 23, @@ -600,11 +601,11 @@ describe('status check alert', () => { mockGetter.mockReturnValue([]); const mockAvailability = jest.fn(); mockAvailability.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ availability: { range: 23, @@ -748,8 +749,8 @@ describe('status check alert', () => { let alert: AlertType; beforeEach(() => { - const { server, libs } = bootstrapDependencies(); - alert = statusCheckAlertFactory(server, libs); + const { server, libs, plugins } = bootstrapDependencies(); + alert = statusCheckAlertFactory(server, libs, plugins); }); it('creates an alert with expected params', () => { diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts new file mode 100644 index 000000000000..7dd357e99b83 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts @@ -0,0 +1,129 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { ILegacyScopedClusterClient } from 'kibana/server'; +import { updateState } from './common'; +import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants'; +import { commonStateTranslations, durationAnomalyTranslations } from './translations'; +import { AnomaliesTableRecord } from '../../../../ml/common/types/anomalies'; +import { getSeverityType } from '../../../../ml/common/util/anomaly_utils'; +import { getLatestMonitor } from '../requests'; +import { savedObjectsAdapter } from '../saved_objects'; +import { UptimeCorePlugins } from '../adapters/framework'; +import { UptimeAlertTypeFactory } from './types'; +import { Ping } from '../../../common/runtime_types/ping'; +import { getMLJobId } from '../../../common/lib'; + +const { DURATION_ANOMALY } = ACTION_GROUP_DEFINITIONS; + +export const getAnomalySummary = (anomaly: AnomaliesTableRecord, monitorInfo: Ping) => { + return { + severity: getSeverityType(anomaly.severity), + severityScore: Math.round(anomaly.severity), + anomalyStartTimestamp: moment(anomaly.source.timestamp).toISOString(), + monitor: anomaly.source['monitor.id'], + monitorUrl: monitorInfo.url?.full, + slowestAnomalyResponse: Math.round(anomaly.actualSort / 1000) + ' ms', + expectedResponseTime: Math.round(anomaly.typicalSort / 1000) + ' ms', + observerLocation: anomaly.entityValue, + }; +}; + +const getAnomalies = async ( + plugins: UptimeCorePlugins, + mlClusterClient: ILegacyScopedClusterClient, + params: Record, + lastCheckedAt: string +) => { + const { getAnomaliesTableData } = plugins.ml.resultsServiceProvider(mlClusterClient, { + params: 'DummyKibanaRequest', + } as any); + + return await getAnomaliesTableData( + [getMLJobId(params.monitorId)], + [], + [], + 'auto', + params.severity, + moment(lastCheckedAt).valueOf(), + moment().valueOf(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + 500, + 10, + undefined + ); +}; + +export const durationAnomalyAlertFactory: UptimeAlertTypeFactory = (_server, _libs, plugins) => ({ + id: 'xpack.uptime.alerts.durationAnomaly', + name: durationAnomalyTranslations.alertFactoryName, + validate: { + params: schema.object({ + monitorId: schema.string(), + severity: schema.number(), + }), + }, + defaultActionGroupId: DURATION_ANOMALY.id, + actionGroups: [ + { + id: DURATION_ANOMALY.id, + name: DURATION_ANOMALY.name, + }, + ], + actionVariables: { + context: [], + state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], + }, + producer: 'uptime', + async executor(options) { + const { + services: { + alertInstanceFactory, + callCluster, + savedObjectsClient, + getLegacyScopedClusterClient, + }, + state, + params, + } = options; + + const { anomalies } = + (await getAnomalies( + plugins, + getLegacyScopedClusterClient(plugins.ml.mlClient), + params, + state.lastCheckedAt + )) ?? {}; + + const foundAnomalies = anomalies?.length > 0; + + if (foundAnomalies) { + const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( + savedObjectsClient + ); + const monitorInfo = await getLatestMonitor({ + dynamicSettings, + callES: callCluster, + dateStart: 'now-15m', + dateEnd: 'now', + monitorId: params.monitorId, + }); + anomalies.forEach((anomaly, index) => { + const alertInstance = alertInstanceFactory(DURATION_ANOMALY.id + index); + const summary = getAnomalySummary(anomaly, monitorInfo); + alertInstance.replaceState({ + ...updateState(state, false), + ...summary, + }); + alertInstance.scheduleActions(DURATION_ANOMALY.id); + }); + } + + return updateState(state, foundAnomalies); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/index.ts b/x-pack/plugins/uptime/server/lib/alerts/index.ts index 661df39ece62..c8d3037f98ae 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/index.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/index.ts @@ -7,8 +7,10 @@ import { UptimeAlertTypeFactory } from './types'; import { statusCheckAlertFactory } from './status_check'; import { tlsAlertFactory } from './tls'; +import { durationAnomalyAlertFactory } from './duration_anomaly'; export const uptimeAlertTypeFactories: UptimeAlertTypeFactory[] = [ statusCheckAlertFactory, tlsAlertFactory, + durationAnomalyAlertFactory, ]; diff --git a/x-pack/plugins/uptime/server/lib/alerts/translations.ts b/x-pack/plugins/uptime/server/lib/alerts/translations.ts index e41930aad5af..50eedcd4fa69 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/translations.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/translations.ts @@ -148,3 +148,93 @@ export const tlsTranslations = { }, }), }; + +export const durationAnomalyTranslations = { + alertFactoryName: i18n.translate('xpack.uptime.alerts.durationAnomaly', { + defaultMessage: 'Uptime Duration Anomaly', + }), + actionVariables: [ + { + name: 'severity', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.severity', + { + defaultMessage: 'The severity of the anomaly.', + } + ), + }, + { + name: 'anomalyStartTimestamp', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.anomalyStartTimestamp', + { + defaultMessage: 'ISO8601 timestamp of the start of the anomaly.', + } + ), + }, + { + name: 'monitor', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.monitor', + { + defaultMessage: + 'A human friendly rendering of name or ID, preferring name (e.g. My Monitor)', + } + ), + }, + { + name: 'monitorId', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.monitorId', + { + defaultMessage: 'ID of the monitor.', + } + ), + }, + { + name: 'monitorUrl', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.monitorUrl', + { + defaultMessage: 'URL of the monitor.', + } + ), + }, + { + name: 'slowestAnomalyResponse', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.slowestAnomalyResponse', + { + defaultMessage: 'Slowest response time during anomaly bucket with unit (ms, s) attached.', + } + ), + }, + { + name: 'expectedResponseTime', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.expectedResponseTime', + { + defaultMessage: 'Expected response time', + } + ), + }, + { + name: 'severityScore', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.severityScore', + { + defaultMessage: 'Anomaly severity score', + } + ), + }, + { + name: 'observerLocation', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.observerLocation', + { + defaultMessage: 'Observer location from which heartbeat check is performed.', + } + ), + }, + ], +}; diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts index a321cc124ac2..172930bc3dd3 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/types.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -5,7 +5,11 @@ */ import { AlertType } from '../../../../alerts/server'; -import { UptimeCoreSetup } from '../adapters'; +import { UptimeCorePlugins, UptimeCoreSetup } from '../adapters'; import { UMServerLibs } from '../lib'; -export type UptimeAlertTypeFactory = (server: UptimeCoreSetup, libs: UMServerLibs) => AlertType; +export type UptimeAlertTypeFactory = ( + server: UptimeCoreSetup, + libs: UMServerLibs, + plugins: UptimeCorePlugins +) => AlertType; diff --git a/x-pack/plugins/uptime/server/uptime_server.ts b/x-pack/plugins/uptime/server/uptime_server.ts index fb90dfe2be6c..afad5896ae64 100644 --- a/x-pack/plugins/uptime/server/uptime_server.ts +++ b/x-pack/plugins/uptime/server/uptime_server.ts @@ -19,6 +19,6 @@ export const initUptimeServer = ( ); uptimeAlertTypeFactories.forEach((alertTypeFactory) => - plugins.alerts.registerType(alertTypeFactory(server, libs)) + plugins.alerts.registerType(alertTypeFactory(server, libs, plugins)) ); }; diff --git a/x-pack/test/functional/services/uptime/ml_anomaly.ts b/x-pack/test/functional/services/uptime/ml_anomaly.ts index a5f138b7a571..ac9f6ab2b3d1 100644 --- a/x-pack/test/functional/services/uptime/ml_anomaly.ts +++ b/x-pack/test/functional/services/uptime/ml_anomaly.ts @@ -20,12 +20,18 @@ export function UptimeMLAnomalyProvider({ getService }: FtrProviderContext) { }, async openMLManageMenu() { + await this.cancelAlertFlyout(); return retry.tryForTime(30000, async () => { await testSubjects.click('uptimeManageMLJobBtn'); await testSubjects.existOrFail('uptimeManageMLContextMenu'); }); }, + async cancelAlertFlyout() { + if (await testSubjects.exists('euiFlyoutCloseButton')) + await testSubjects.click('euiFlyoutCloseButton', 60 * 1000); + }, + async alreadyHasJob() { return await testSubjects.exists('uptimeManageMLJobBtn'); }, @@ -55,5 +61,19 @@ export function UptimeMLAnomalyProvider({ getService }: FtrProviderContext) { async hasNoLicenseInfo() { return await testSubjects.missingOrFail('uptimeMLLicenseInfo', { timeout: 1000 }); }, + + async openAlertFlyout() { + return await testSubjects.click('uptimeEnableAnomalyAlertBtn'); + }, + + async disableAnomalyAlertIsVisible() { + return await testSubjects.exists('uptimeDisableAnomalyAlertBtn'); + }, + + async changeAlertThreshold(level: string) { + await testSubjects.click('uptimeAnomalySeverity'); + await testSubjects.click('anomalySeveritySelect'); + await testSubjects.click(`alertAnomaly${level}`); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts new file mode 100644 index 000000000000..03343bff642c --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts @@ -0,0 +1,131 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + describe('uptime anomaly alert', () => { + const pageObjects = getPageObjects(['common', 'uptime']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + const monitorId = '0000-intermittent'; + + const uptime = getService('uptime'); + + const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; + const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; + let alerts: any; + const alertId = 'uptime-anomaly-alert'; + + before(async () => { + alerts = getService('uptime').alerts; + + await uptime.navigation.goToUptime(); + + await uptime.navigation.loadDataAndGoToMonitorPage( + DEFAULT_DATE_START, + DEFAULT_DATE_END, + monitorId + ); + }); + + it('can delete existing job', async () => { + if (await uptime.ml.alreadyHasJob()) { + await uptime.ml.openMLManageMenu(); + await uptime.ml.deleteMLJob(); + await uptime.navigation.refreshApp(); + } + }); + + it('can open ml flyout', async () => { + await uptime.ml.openMLFlyout(); + }); + + it('has permission to create job', async () => { + expect(uptime.ml.canCreateJob()).to.eql(true); + expect(uptime.ml.hasNoLicenseInfo()).to.eql(false); + }); + + it('can create job successfully', async () => { + await uptime.ml.createMLJob(); + await pageObjects.common.closeToast(); + await uptime.ml.cancelAlertFlyout(); + }); + + it('can open ML Manage Menu', async () => { + await uptime.ml.openMLManageMenu(); + }); + + it('can open anomaly alert flyout', async () => { + await uptime.ml.openAlertFlyout(); + }); + + it('can set alert name', async () => { + await alerts.setAlertName(alertId); + }); + + it('can set alert tags', async () => { + await alerts.setAlertTags(['uptime', 'anomaly-alert']); + }); + + it('can change anomaly alert threshold', async () => { + await uptime.ml.changeAlertThreshold('major'); + }); + + it('can save alert', async () => { + await alerts.clickSaveAlertButton(); + await pageObjects.common.closeToast(); + }); + + it('has created a valid alert with expected parameters', async () => { + let alert: any; + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get(`/api/alerts/_find?search=${alertId}`); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === alertId + ); + expect(alertsFromThisTest).to.have.length(1); + alert = alertsFromThisTest[0]; + }); + + // Ensure the parameters and other stateful data + // on the alert match up with the values we provided + // for our test helper to input into the flyout. + const { actions, alertTypeId, consumer, id, params, tags } = alert; + try { + expect(actions).to.eql([]); + expect(alertTypeId).to.eql('xpack.uptime.alerts.durationAnomaly'); + expect(consumer).to.eql('uptime'); + expect(tags).to.eql(['uptime', 'anomaly-alert']); + expect(params.monitorId).to.eql(monitorId); + expect(params.severity).to.eql(50); + } finally { + await supertest.delete(`/api/alerts/alert/${id}`).set('kbn-xsrf', 'true').expect(204); + } + }); + + it('change button to disable anomaly alert', async () => { + await uptime.ml.openMLManageMenu(); + expect(uptime.ml.disableAnomalyAlertIsVisible()).to.eql(true); + }); + + it('can delete job successfully', async () => { + await uptime.ml.deleteMLJob(); + }); + + it('verifies that alert is also deleted', async () => { + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get(`/api/alerts/_find?search=${alertId}`); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === alertId + ); + expect(alertsFromThisTest).to.have.length(0); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts index ce91a2a26ce9..3016bd6d68f9 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts @@ -22,6 +22,7 @@ export default ({ getService, loadTestFile }: FtrProviderContext) => { after(async () => await esArchiver.unload(ARCHIVE)); loadTestFile(require.resolve('./alert_flyout')); + loadTestFile(require.resolve('./anomaly_alert')); }); }); };