[Uptime] Duration Anomaly Alert (#71208)

This commit is contained in:
Shahzad 2020-07-14 19:53:14 +02:00 committed by GitHub
parent 8f8736cce8
commit 981d678e42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1109 additions and 112 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -6,3 +6,5 @@
export * from './combine_filters_and_user_search';
export * from './stringify_kueries';
export { getMLJobId } from './ml';

View file

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

View file

@ -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",

View file

@ -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(
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />
@ -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(
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { 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<Props> = ({ onConfirm, onCancel }) => {
return (
<EuiOverlayMask>
<EuiConfirmModal
title={labels.DISABLE_ANOMALY_ALERT}
onCancel={onCancel}
onConfirm={onConfirm}
cancelButtonText="Cancel"
confirmButtonText="Delete"
buttonColor="danger"
defaultFocusedButton="confirm"
data-test-subj="uptimeMLAlertDeleteConfirmModel"
>
<p>
<FormattedMessage
id="xpack.uptime.monitorDetails.ml.confirmAlertDeleteMessage"
defaultMessage="Are you sure you want to delete the alert for anomalies?"
/>
</p>
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -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 = (
<EuiButtonEmpty
data-test-subj={hasMLJob ? 'uptimeManageMLJobBtn' : 'uptimeEnableAnomalyBtn'}
@ -68,6 +82,21 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro
}),
target: '_blank',
},
{
name: anomalyAlert ? labels.DISABLE_ANOMALY_ALERT : labels.ENABLE_ANOMALY_ALERT,
'data-test-subj': anomalyAlert
? 'uptimeDisableAnomalyAlertBtn'
: 'uptimeEnableAnomalyAlertBtn',
icon: <EuiIcon type={anomalyAlert ? 'bellSlash' : 'bell'} size="m" />,
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 (
<EuiPopover button={button} isOpen={isPopOverOpen} closePopover={() => setIsPopOverOpen(false)}>
<EuiContextMenu
initialPanelId={0}
panels={panels}
data-test-subj="uptimeManageMLContextMenu"
/>
</EuiPopover>
<>
<EuiPopover
button={button}
isOpen={isPopOverOpen}
closePopover={() => setIsPopOverOpen(false)}
>
<EuiContextMenu
initialPanelId={0}
panels={panels}
data-test-subj="uptimeManageMLContextMenu"
/>
</EuiPopover>
{isConfirmAlertDeleteOpen && (
<ConfirmAlertDeletion
onConfirm={() => {
deleteAnomalyAlert();
setIsConfirmAlertDeleteOpen(false);
}}
onCancel={() => {
setIsConfirmAlertDeleteOpen(false);
}}
/>
)}
</>
);
};

View file

@ -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: (
<p data-test-subj="uptimeMLJobSuccessfullyCreated">{labels.JOB_CREATED_SUCCESS_TITLE}</p>
),
body: (
<p>
{labels.JOB_CREATED_SUCCESS_MESSAGE}
<MLJobLink monitorId={monitorId} basePath={basePath} dateRange={range}>
{labels.VIEW_JOB}
</MLJobLink>
</p>
),
toastLifeTimeMs: 10000,
});
kibanaService.toasts.addSuccess(
{
title: toMountPoint(
<p data-test-subj="uptimeMLJobSuccessfullyCreated">{labels.JOB_CREATED_SUCCESS_TITLE}</p>
),
text: toMountPoint(
<p>
{labels.JOB_CREATED_SUCCESS_MESSAGE}
<MLJobLink monitorId={monitorId} basePath={basePath} dateRange={range}>
{labels.VIEW_JOB}
</MLJobLink>
</p>
),
},
{ toastLifeTimeMs: 10000 }
);
} else {
notifications.toasts.danger({
title: <p data-test-subj="uptimeMLJobCreationFailed">{labels.JOB_CREATION_FAILED}</p>,
body: message ?? <p>{labels.JOB_CREATION_FAILED_MESSAGE}</p>,
kibanaService.toasts.addError(error!, {
title: labels.JOB_CREATION_FAILED,
toastMessage: labels.JOB_CREATION_FAILED_MESSAGE,
toastLifeTimeMs: 10000,
});
}
};
export const MachineLearningFlyout: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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) {

View file

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

View file

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

View file

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

View file

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

View file

@ -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<MonitorIdParam> = ({ monitorId }) => {
const {

View file

@ -14,8 +14,8 @@ interface AlertExpressionPopoverProps {
'data-test-subj': string;
isEnabled?: boolean;
id: string;
value: string | JSX.Element;
isInvalid?: boolean;
value: string;
}
const getColor = (

View file

@ -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 (
<>
<EuiSpacer size="l" />
<EuiFlexGroup direction={'column'} gutterSize="none">
<EuiFlexItem grow={false}>
<EuiExpression
description={AnomalyTranslations.whenMonitor}
value={
<EuiText className="eui-displayInlineBlock">
<h5>{monitorId}</h5>
</EuiText>
}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AlertExpressionPopover
aria-label={AnomalyTranslations.scoreAriaLabel}
content={
<SelectSeverity
data-test-subj="uptimeAnomalySeverityValue"
value={severity}
onChange={setSeverity}
/>
}
data-test-subj={'uptimeAnomalySeverity'}
description={AnomalyTranslations.hasAnomalyWithSeverity}
id="severity"
value={
<EuiHealth
style={{ textTransform: 'capitalize' }}
color={getSeverityColor(severity.val)}
>
{getSeverityType(severity.val)}
</EuiHealth>
}
isEnabled={true}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
</>
);
}

View file

@ -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: (
<Fragment>
<EuiHealth color={color} style={{ lineHeight: 'inherit' }}>
{display}
</EuiHealth>
</Fragment>
),
dropdownDisplay: (
<Fragment>
<EuiHealth color={color} style={{ lineHeight: 'inherit' }}>
{display}
</EuiHealth>
<EuiSpacer size="xs" />
<EuiText size="xs" color="subdued">
<p className="euiTextColor--subdued">
<FormattedMessage
id="xpack.uptime.controls.selectSeverity.scoreDetailsDescription"
defaultMessage="score {value} and above"
values={{ value: val }}
/>
</p>
</EuiText>
</Fragment>
),
}));
interface Props {
onChange: (sev: TableSeverity) => void;
value: TableSeverity;
}
export const SelectSeverity: FC<Props> = ({ 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 (
<EuiSuperSelect
hasDividers
style={{ width: 200 }}
options={getSeverityOptions()}
valueOfSelected={severity.display}
onChange={onSeverityChange}
data-test-subj={'anomalySeveritySelect'}
/>
);
};

View file

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

View file

@ -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) => (
<ReduxProvider store={store}>
<KibanaContextProvider services={{ ...core, ...plugins }}>
<AnomalyAlertExpression {...params} />
</KibanaContextProvider>
</ReduxProvider>
),
name,
validate: () => ({ errors: {} }),
defaultActionMessage,
requiresAppContext: false,
});

View file

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

View file

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

View file

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

View file

@ -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<MonitorIdParam, Alert>(
'GET EXISTING ALERTS'
);
export const deleteAlertAction = createAsyncAction<{ alertId: string }, any>('DELETE ALERTS');

View file

@ -25,3 +25,5 @@ export const setSearchTextAction = createAction<string>('SET SEARCH');
export const toggleIntegrationsPopover = createAction<PopoverState>(
'TOGGLE INTEGRATION POPOVER STATE'
);
export const setSelectedMonitorId = createAction<string>('SET MONITOR ID');

View file

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

View file

@ -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<MlCapabilitiesResponse> => {
return await apiService.get(API_URLS.ML_CAPABILITIES);

View file

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

View file

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

View file

@ -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<MonitorIdParam>) {
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(

View file

@ -20,6 +20,10 @@ class KibanaService {
apiService.http = this._core.http;
}
public get toasts() {
return this._core.notifications.toasts;
}
private constructor() {}
static getInstance(): KibanaService {

View file

@ -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": "",
}
`;

View file

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

View file

@ -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<Alert>;
alertDeletion: AsyncInitialState<boolean>;
}
const initialState: AlertsState = {
alert: getAsyncInitialState(),
alertDeletion: getAsyncInitialState(),
};
export const alertsReducer = handleActions<AlertsState>(
{
...handleAsyncAction<AlertsState>('alert', getExistingAlertAction),
...handleAsyncAction<AlertsState>('alertDeletion', deleteAlertAction),
},
initialState
);

View file

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

View file

@ -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<UiState, UiPayload>(
@ -64,6 +67,10 @@ export const uiReducer = handleActions<UiState, UiPayload>(
...state,
searchText: action.payload,
}),
[String(setSelectedMonitorId)]: (state, action: Action<string>) => ({
...state,
monitorId: action.payload,
}),
},
initialState
);

View file

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

View file

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

View file

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

View file

@ -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<string, any> = 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', () => {

View file

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

View file

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

View file

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

View file

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

View file

@ -19,6 +19,6 @@ export const initUptimeServer = (
);
uptimeAlertTypeFactories.forEach((alertTypeFactory) =>
plugins.alerts.registerType(alertTypeFactory(server, libs))
plugins.alerts.registerType(alertTypeFactory(server, libs, plugins))
);
};

View file

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

View file

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

View file

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