diff --git a/x-pack/plugins/ml/public/alerting/advanced_settings.tsx b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/advanced_settings.tsx similarity index 93% rename from x-pack/plugins/ml/public/alerting/advanced_settings.tsx rename to x-pack/plugins/ml/public/alerting/anomaly_detection_rule/advanced_settings.tsx index 093c3d615a510..f3f2f3b042780 100644 --- a/x-pack/plugins/ml/public/alerting/advanced_settings.tsx +++ b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/advanced_settings.tsx @@ -16,9 +16,9 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; -import { MlAnomalyDetectionAlertAdvancedSettings } from '../../common/types/alerts'; -import { TimeIntervalControl } from './time_interval_control'; -import { TOP_N_BUCKETS_COUNT } from '../../common/constants/alerts'; +import { type MlAnomalyDetectionAlertAdvancedSettings } from '../../../common/types/alerts'; +import { TimeIntervalControl } from '../time_interval_control'; +import { TOP_N_BUCKETS_COUNT } from '../../../common/constants/alerts'; interface AdvancedSettingsProps { value: MlAnomalyDetectionAlertAdvancedSettings; diff --git a/x-pack/plugins/ml/public/alerting/config_validator.tsx b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/config_validator.tsx similarity index 90% rename from x-pack/plugins/ml/public/alerting/config_validator.tsx rename to x-pack/plugins/ml/public/alerting/anomaly_detection_rule/config_validator.tsx index 3afc02dc60600..c7d9187e35057 100644 --- a/x-pack/plugins/ml/public/alerting/config_validator.tsx +++ b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/config_validator.tsx @@ -9,12 +9,12 @@ import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { parseInterval } from '../../common/util/parse_interval'; -import { CombinedJobWithStats } from '../../common/types/anomaly_detection_jobs'; -import { DATAFEED_STATE } from '../../common/constants/states'; -import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts'; -import { MlAnomalyAlertTriggerProps } from './ml_anomaly_alert_trigger'; -import { TOP_N_BUCKETS_COUNT } from '../../common/constants/alerts'; +import { parseInterval } from '../../../common/util/parse_interval'; +import { type CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; +import { DATAFEED_STATE } from '../../../common/constants/states'; +import { type MlAnomalyDetectionAlertParams } from '../../../common/types/alerts'; +import { type MlAnomalyAlertTriggerProps } from './ml_anomaly_alert_trigger'; +import { TOP_N_BUCKETS_COUNT } from '../../../common/constants/alerts'; interface ConfigValidatorProps { alertInterval: string; diff --git a/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/index.ts b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/index.ts new file mode 100644 index 0000000000000..0e710651a0c68 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { registerAnomalyDetectionRule } from './register_anomaly_detection_rule'; diff --git a/x-pack/plugins/ml/public/alerting/interim_results_control.tsx b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/interim_results_control.tsx similarity index 100% rename from x-pack/plugins/ml/public/alerting/interim_results_control.tsx rename to x-pack/plugins/ml/public/alerting/anomaly_detection_rule/interim_results_control.tsx diff --git a/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/ml_anomaly_alert_trigger.tsx similarity index 78% rename from x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx rename to x-pack/plugins/ml/public/alerting/anomaly_detection_rule/ml_anomaly_alert_trigger.tsx index 45bf7b1ca613b..547ec627decf2 100644 --- a/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx +++ b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/ml_anomaly_alert_trigger.tsx @@ -12,28 +12,34 @@ import { i18n } from '@kbn/i18n'; import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; import { isDefined } from '@kbn/ml-is-defined'; import { ML_ANOMALY_RESULT_TYPE, ML_ANOMALY_THRESHOLD } from '@kbn/ml-anomaly-utils'; -import { JobSelectorControl } from './job_selector'; -import { useMlKibana } from '../application/contexts/kibana'; -import { jobsApiProvider } from '../application/services/ml_api_service/jobs'; -import { HttpService } from '../application/services/http_service'; -import { useToastNotificationService } from '../application/services/toast_notification_service'; -import { SeverityControl } from '../application/components/severity_control'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { MlCapabilities } from '../../../common/types/capabilities'; +import { ML_PAGES } from '../../../common/constants/locator'; +import type { MlCoreSetup } from '../../plugin'; +import { JobSelectorControl } from '../job_selector'; +import { jobsApiProvider } from '../../application/services/ml_api_service/jobs'; +import { HttpService } from '../../application/services/http_service'; +import { useToastNotificationService } from '../../application/services/toast_notification_service'; +import { SeverityControl } from '../../application/components/severity_control'; import { ResultTypeSelector } from './result_type_selector'; -import { alertingApiProvider } from '../application/services/ml_api_service/alerting'; +import { alertingApiProvider } from '../../application/services/ml_api_service/alerting'; import { PreviewAlertCondition } from './preview_alert_condition'; -import { +import type { MlAnomalyDetectionAlertAdvancedSettings, MlAnomalyDetectionAlertParams, -} from '../../common/types/alerts'; +} from '../../../common/types/alerts'; import { InterimResultsControl } from './interim_results_control'; import { ConfigValidator } from './config_validator'; -import { CombinedJobWithStats } from '../../common/types/anomaly_detection_jobs'; +import { type CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; import { AdvancedSettings } from './advanced_settings'; -import { getLookbackInterval, getTopNBuckets } from '../../common/util/alerts'; -import { parseInterval } from '../../common/util/parse_interval'; +import { getLookbackInterval, getTopNBuckets } from '../../../common/util/alerts'; +import { parseInterval } from '../../../common/util/parse_interval'; export type MlAnomalyAlertTriggerProps = - RuleTypeParamsExpressionProps; + RuleTypeParamsExpressionProps & { + getStartServices: MlCoreSetup['getStartServices']; + mlCapabilities: MlCapabilities; + }; const MlAnomalyAlertTrigger: FC = ({ ruleParams, @@ -42,11 +48,36 @@ const MlAnomalyAlertTrigger: FC = ({ errors, ruleInterval, alertNotifyWhen, + getStartServices, + mlCapabilities, }) => { const { services: { http }, - } = useMlKibana(); - const mlHttpService = useMemo(() => new HttpService(http), [http]); + } = useKibana(); + + const [newJobUrl, setNewJobUrl] = useState(undefined); + + useEffect(() => { + let mounted = true; + + if (!mlCapabilities.canCreateJob) return; + + getStartServices().then((startServices) => { + const locator = startServices[2].locator; + if (!locator) return; + locator.getUrl({ page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB }).then((url) => { + if (mounted) { + setNewJobUrl(url); + } + }); + }); + + return () => { + mounted = false; + }; + }, [getStartServices, mlCapabilities]); + + const mlHttpService = useMemo(() => new HttpService(http!), [http]); const adJobsApiService = useMemo(() => jobsApiProvider(mlHttpService), [mlHttpService]); const alertingApiService = useMemo(() => alertingApiProvider(mlHttpService), [mlHttpService]); const { displayErrorToast } = useToastNotificationService(); @@ -159,6 +190,7 @@ const MlAnomalyAlertTrigger: FC = ({ return ( ) => { + const MlAlertTrigger = lazy(() => import('./ml_anomaly_alert_trigger')); + return ( + + ); + }, + validate: (ruleParams: MlAnomalyDetectionAlertParams) => { + const validationResult = { + errors: { + jobSelection: new Array(), + severity: new Array(), + resultType: new Array(), + topNBuckets: new Array(), + lookbackInterval: new Array(), + } as Record, + }; + + if (!ruleParams.jobSelection?.jobIds?.length && !ruleParams.jobSelection?.groupIds?.length) { + validationResult.errors.jobSelection.push( + i18n.translate('xpack.ml.alertTypes.anomalyDetection.jobSelection.errorMessage', { + defaultMessage: 'Job selection is required', + }) + ); + } + + // Since 7.13 we support single job selection only + if ( + (Array.isArray(ruleParams.jobSelection?.groupIds) && + ruleParams.jobSelection?.groupIds.length > 0) || + (Array.isArray(ruleParams.jobSelection?.jobIds) && + ruleParams.jobSelection?.jobIds.length > 1) + ) { + validationResult.errors.jobSelection.push( + i18n.translate('xpack.ml.alertTypes.anomalyDetection.singleJobSelection.errorMessage', { + defaultMessage: 'Only one job per rule is allowed', + }) + ); + } + + if (ruleParams.severity === undefined) { + validationResult.errors.severity.push( + i18n.translate('xpack.ml.alertTypes.anomalyDetection.severity.errorMessage', { + defaultMessage: 'Anomaly severity is required', + }) + ); + } + + if (ruleParams.resultType === undefined) { + validationResult.errors.resultType.push( + i18n.translate('xpack.ml.alertTypes.anomalyDetection.resultType.errorMessage', { + defaultMessage: 'Result type is required', + }) + ); + } + + if (!!ruleParams.lookbackInterval && validateLookbackInterval(ruleParams.lookbackInterval)) { + validationResult.errors.lookbackInterval.push( + i18n.translate('xpack.ml.alertTypes.anomalyDetection.lookbackInterval.errorMessage', { + defaultMessage: 'Lookback interval is invalid', + }) + ); + } + + if ( + typeof ruleParams.topNBuckets === 'number' && + validateTopNBucket(ruleParams.topNBuckets) + ) { + validationResult.errors.topNBuckets.push( + i18n.translate('xpack.ml.alertTypes.anomalyDetection.topNBuckets.errorMessage', { + defaultMessage: 'Number of buckets is invalid', + }) + ); + } + + return validationResult; + }, + requiresAppContext: false, + defaultActionMessage: i18n.translate( + 'xpack.ml.alertTypes.anomalyDetection.defaultActionMessage', + { + defaultMessage: `[\\{\\{rule.name\\}\\}] Elastic Stack Machine Learning Alert: +- Job IDs: \\{\\{context.jobIds\\}\\} +- Time: \\{\\{context.timestampIso8601\\}\\} +- Anomaly score: \\{\\{context.score\\}\\} + +\\{\\{context.message\\}\\} + +\\{\\{#context.topInfluencers.length\\}\\} + Top influencers: + \\{\\{#context.topInfluencers\\}\\} + \\{\\{influencer_field_name\\}\\} = \\{\\{influencer_field_value\\}\\} [\\{\\{score\\}\\}] + \\{\\{/context.topInfluencers\\}\\} +\\{\\{/context.topInfluencers.length\\}\\} + +\\{\\{#context.topRecords.length\\}\\} + Top records: + \\{\\{#context.topRecords\\}\\} + \\{\\{function\\}\\}(\\{\\{field_name\\}\\}) \\{\\{by_field_value\\}\\}\\{\\{over_field_value\\}\\}\\{\\{partition_field_value\\}\\} [\\{\\{score\\}\\}]. Typical: \\{\\{typical\\}\\}, Actual: \\{\\{actual\\}\\} + \\{\\{/context.topRecords\\}\\} +\\{\\{/context.topRecords.length\\}\\} + +\\{\\{! Replace kibanaBaseUrl if not configured in Kibana \\}\\} +[Open in Anomaly Explorer](\\{\\{\\{kibanaBaseUrl\\}\\}\\}\\{\\{\\{context.anomalyExplorerUrl\\}\\}\\}) +`, + } + ), + }); +} diff --git a/x-pack/plugins/ml/public/alerting/result_type_selector.tsx b/x-pack/plugins/ml/public/alerting/anomaly_detection_rule/result_type_selector.tsx similarity index 100% rename from x-pack/plugins/ml/public/alerting/result_type_selector.tsx rename to x-pack/plugins/ml/public/alerting/anomaly_detection_rule/result_type_selector.tsx diff --git a/x-pack/plugins/ml/public/alerting/job_selector.tsx b/x-pack/plugins/ml/public/alerting/job_selector.tsx index 2a4791a29e576..30662bb400918 100644 --- a/x-pack/plugins/ml/public/alerting/job_selector.tsx +++ b/x-pack/plugins/ml/public/alerting/job_selector.tsx @@ -9,6 +9,8 @@ import React, { FC, ReactNode, useCallback, useEffect, useMemo, useState } from import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps, EuiFormRow } from '@elastic/eui'; +import useMountedState from 'react-use/lib/useMountedState'; +import { useMlKibana } from '../application/contexts/kibana'; import { JobId } from '../../common/types/anomaly_detection_jobs'; import { MlApiServices } from '../application/services/ml_api_service'; import { ALL_JOBS_SELECTION } from '../../common/constants/alerts'; @@ -33,6 +35,8 @@ export interface JobSelectorControlProps { * Allows selecting all jobs, even those created afterward. */ allowSelectAll?: boolean; + /** Adds an option to create a new anomaly detection job */ + createJobUrl?: string; /** * Available options to select. By default suggest all existing jobs. */ @@ -47,8 +51,18 @@ export const JobSelectorControl: FC = ({ multiSelect = false, label, allowSelectAll = false, + createJobUrl, options: defaultOptions, }) => { + const { + services: { + notifications: { toasts }, + application: { navigateToUrl }, + }, + } = useMlKibana(); + + const isMounted = useMountedState(); + const [options, setOptions] = useState>>([]); const jobIds = useMemo(() => new Set(), []); const groupIds = useMemo(() => new Set(), []); @@ -69,10 +83,13 @@ export const JobSelectorControl: FC = ({ jobIdOptions.forEach((v) => { jobIds.add(v); }); + groupIdOptions.forEach((v) => { groupIds.add(v); }); + if (!isMounted()) return; + setOptions([ ...(allowSelectAll ? [ @@ -91,11 +108,24 @@ export const JobSelectorControl: FC = ({ }, ] : []), + { label: i18n.translate('xpack.ml.jobSelector.jobOptionsLabel', { defaultMessage: 'Jobs', }), - options: jobIdOptions.map((v) => ({ label: v })), + options: [ + ...(createJobUrl + ? [ + { + label: i18n.translate('xpack.ml.jobSelector.createNewLabel', { + defaultMessage: '--- Create new ---', + }), + value: 'createNew', + }, + ] + : []), + ...jobIdOptions.map((v) => ({ label: v })), + ], }, ...(multiSelect ? [ @@ -109,19 +139,40 @@ export const JobSelectorControl: FC = ({ : []), ]); } catch (e) { - // TODO add error handling + toasts.addError(e, { + title: i18n.translate('xpack.ml.jobSelector.fetchJobErrorTitle', { + defaultMessage: 'Failed to load anomaly detection jobs', + }), + }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [adJobsApiService]); + }, [ + adJobsApiService, + allowSelectAll, + createJobUrl, + groupIds, + isMounted, + jobIds, + multiSelect, + toasts, + ]); // eslint-disable-next-line react-hooks/exhaustive-deps const onSelectionChange: EuiComboBoxProps['onChange'] = useCallback( - ((selectionUpdate) => { + (async (selectionUpdate) => { if (selectionUpdate.some((selectedOption) => selectedOption.value === ALL_JOBS_SELECTION)) { onChange({ jobIds: [ALL_JOBS_SELECTION] }); return; } + if ( + !!createJobUrl && + selectionUpdate.some((selectedOption) => selectedOption.value === 'createNew') + ) { + // Redirect to the job wizard page + await navigateToUrl(createJobUrl); + return; + } + const selectedJobIds: JobId[] = []; const selectedGroupIds: string[] = []; selectionUpdate.forEach(({ label: selectedLabel }: { label: string }) => { @@ -138,14 +189,14 @@ export const JobSelectorControl: FC = ({ ...(selectedGroupIds.length > 0 ? { groupIds: selectedGroupIds } : {}), }); }) as Exclude['onChange'], undefined>, - [jobIds, groupIds, defaultOptions] + [jobIds, groupIds, defaultOptions, createJobUrl] ); useEffect(() => { if (defaultOptions) return; fetchOptions(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [createJobUrl]); return ( import('./ml_anomaly_alert_trigger')), - validate: (ruleParams: MlAnomalyDetectionAlertParams) => { - const validationResult = { - errors: { - jobSelection: new Array(), - severity: new Array(), - resultType: new Array(), - topNBuckets: new Array(), - lookbackInterval: new Array(), - } as Record, - }; - - if (!ruleParams.jobSelection?.jobIds?.length && !ruleParams.jobSelection?.groupIds?.length) { - validationResult.errors.jobSelection.push( - i18n.translate('xpack.ml.alertTypes.anomalyDetection.jobSelection.errorMessage', { - defaultMessage: 'Job selection is required', - }) - ); - } - - // Since 7.13 we support single job selection only - if ( - (Array.isArray(ruleParams.jobSelection?.groupIds) && - ruleParams.jobSelection?.groupIds.length > 0) || - (Array.isArray(ruleParams.jobSelection?.jobIds) && - ruleParams.jobSelection?.jobIds.length > 1) - ) { - validationResult.errors.jobSelection.push( - i18n.translate('xpack.ml.alertTypes.anomalyDetection.singleJobSelection.errorMessage', { - defaultMessage: 'Only one job per rule is allowed', - }) - ); - } - - if (ruleParams.severity === undefined) { - validationResult.errors.severity.push( - i18n.translate('xpack.ml.alertTypes.anomalyDetection.severity.errorMessage', { - defaultMessage: 'Anomaly severity is required', - }) - ); - } - - if (ruleParams.resultType === undefined) { - validationResult.errors.resultType.push( - i18n.translate('xpack.ml.alertTypes.anomalyDetection.resultType.errorMessage', { - defaultMessage: 'Result type is required', - }) - ); - } - - if (!!ruleParams.lookbackInterval && validateLookbackInterval(ruleParams.lookbackInterval)) { - validationResult.errors.lookbackInterval.push( - i18n.translate('xpack.ml.alertTypes.anomalyDetection.lookbackInterval.errorMessage', { - defaultMessage: 'Lookback interval is invalid', - }) - ); - } - - if ( - typeof ruleParams.topNBuckets === 'number' && - validateTopNBucket(ruleParams.topNBuckets) - ) { - validationResult.errors.topNBuckets.push( - i18n.translate('xpack.ml.alertTypes.anomalyDetection.topNBuckets.errorMessage', { - defaultMessage: 'Number of buckets is invalid', - }) - ); - } - - return validationResult; - }, - requiresAppContext: false, - defaultActionMessage: i18n.translate( - 'xpack.ml.alertTypes.anomalyDetection.defaultActionMessage', - { - defaultMessage: `[\\{\\{rule.name\\}\\}] Elastic Stack Machine Learning Alert: -- Job IDs: \\{\\{context.jobIds\\}\\} -- Time: \\{\\{context.timestampIso8601\\}\\} -- Anomaly score: \\{\\{context.score\\}\\} - -\\{\\{context.message\\}\\} - -\\{\\{#context.topInfluencers.length\\}\\} - Top influencers: - \\{\\{#context.topInfluencers\\}\\} - \\{\\{influencer_field_name\\}\\} = \\{\\{influencer_field_value\\}\\} [\\{\\{score\\}\\}] - \\{\\{/context.topInfluencers\\}\\} -\\{\\{/context.topInfluencers.length\\}\\} - -\\{\\{#context.topRecords.length\\}\\} - Top records: - \\{\\{#context.topRecords\\}\\} - \\{\\{function\\}\\}(\\{\\{field_name\\}\\}) \\{\\{by_field_value\\}\\}\\{\\{over_field_value\\}\\}\\{\\{partition_field_value\\}\\} [\\{\\{score\\}\\}]. Typical: \\{\\{typical\\}\\}, Actual: \\{\\{actual\\}\\} - \\{\\{/context.topRecords\\}\\} -\\{\\{/context.topRecords.length\\}\\} - -\\{\\{! Replace kibanaBaseUrl if not configured in Kibana \\}\\} -[Open in Anomaly Explorer](\\{\\{\\{kibanaBaseUrl\\}\\}\\}\\{\\{\\{context.anomalyExplorerUrl\\}\\}\\}) -`, - } - ), - }); + registerAnomalyDetectionRule(triggersActionsUi, getStartServices, mlCapabilities); registerJobsHealthAlertingRule(triggersActionsUi, alerting); diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index b476ea10ac72d..441b88b2cc14e 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -242,6 +242,7 @@ export class MlPlugin implements Plugin { registerMlAlerts( pluginsSetup.triggersActionsUi, core.getStartServices, + mlCapabilities, pluginsSetup.alerting ); }